Search
- Albums and songs search Deezer and MusicBrainz at once and merge the
results: duplicates keep the Deezer copy, best word matches first.
Deezer's search misses albums it carries (Death Magnetic) and most game
soundtracks; MusicBrainz finds them
- MusicBrainz secondary types are shown ("Soundtrack", "Live")
- Settings: Deezer + MusicBrainz (default), Deezer only, MusicBrainz only
- Search results show which service they came from
Previews
- Songs and albums get a play button (cards, rows, detail view, editor,
and Deezer search results) for a 30-second Deezer clip; albums play
their most popular track
- Clips are looked up fresh (Deezer URLs expire), cached 10 minutes, and
streamed through Canon with Range support; hidden lists stay private
- Entries from MusicBrainz or added by hand are matched to Deezer on save
so they can preview too
- "Play previews" toggle in Settings → Profile
Tests cover merging and title/artist matching; `-tags live` runs checks
against the real services.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
293 lines
8.0 KiB
Go
293 lines
8.0 KiB
Go
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 {
|
|
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)
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
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, 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},
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
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, ", ")
|
|
}
|
|
case "track":
|
|
var t deezerTrack
|
|
if err := d.get(ctx, "/track/"+id, &t, &t.deezerErr); err != nil {
|
|
return res, err
|
|
}
|
|
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, nil
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
|
|
func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error) {
|
|
if err := m.r.mb.wait(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
var out []Result
|
|
if m.kind == "album" {
|
|
var resp struct {
|
|
ReleaseGroups []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"`
|
|
} `json:"release-groups"`
|
|
}
|
|
u := "https://musicbrainz.org/ws/2/release-group?" + url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode()
|
|
if err := m.r.getJSON(ctx, u, nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, rg := range resp.ReleaseGroups {
|
|
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, " · ")
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, nil
|
|
}
|
|
var resp struct {
|
|
Recordings []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"`
|
|
} `json:"recordings"`
|
|
}
|
|
u := "https://musicbrainz.org/ws/2/recording?" + url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode()
|
|
if err := m.r.getJSON(ctx, u, nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, rec := range resp.Recordings {
|
|
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)
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, nil
|
|
}
|