Search Deezer and MusicBrainz together; add preview clips

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>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-25 10:10:15 -07:00
1 parent 6d92b46130
commit b09533cc00
20 files changed
+736 -19

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
//go:build live
// Live checks against the real music services: go test -tags live ./internal/providers
package providers
import (
"context"
"strings"
"testing"
"time"
)
func TestLiveMusic(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
reg := NewRegistry(func(context.Context) Credentials { return Credentials{} })
albums, _ := reg.For(ctx, "album")
for _, c := range []struct{ query, want string }{
{"death magnetic", "Death Magnetic"},
{"super mario galaxy soundtrack", "Super Mario Galaxy Original Soundtrack"},
{"chrono trigger original soundtrack", "Chrono Trigger Original Soundtrack"},
} {
res, err := albums.Search(ctx, c.query)
if err != nil {
t.Fatalf("%s: %v", c.query, err)
}
found := -1
for i, r := range res {
if r.Title == c.want {
found = i
break
}
}
t.Logf("%q → %q at #%d (%s, type %q)", c.query, c.want, found+1, pick(res, found).Provider, pick(res, found).Extra["type"])
if found < 0 || found > 2 {
t.Errorf("%q: want %q in the top 3", c.query, c.want)
}
}
ref, err := reg.MatchDeezer(ctx, "album", "Death Magnetic", "Metallica")
t.Logf("Death Magnetic → Deezer %q", ref)
if err != nil || !strings.HasPrefix(ref, "album:") {
t.Fatalf("match: %q %v", ref, err)
}
u, err := reg.DeezerPreview(ctx, ref)
if err != nil || !strings.HasPrefix(u, "https://") {
t.Fatalf("preview: %q %v", u, err)
}
t.Logf("preview URL host: %s", strings.SplitN(strings.TrimPrefix(u, "https://"), "/", 2)[0])
song, err := reg.MatchDeezer(ctx, "song", "Dreams", "Fleetwood Mac")
t.Logf("Dreams → Deezer %q", song)
if err != nil || song == "" {
t.Errorf("song match: %q %v", song, err)
}
}
func pick(res []Result, i int) Result {
if i < 0 {
return Result{}
}
return res[i]
}
+10 -1
View File
@@ -134,6 +134,9 @@ func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
}
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{}
@@ -206,6 +209,7 @@ func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error
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"`
@@ -225,8 +229,13 @@ func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error
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" {
r.Extra["type"] = rg.PrimaryType
types = append([]string{rg.PrimaryType}, types...)
}
if len(types) > 0 {
r.Extra["type"] = strings.Join(types, " · ")
}
out = append(out, r)
}
+203
View File
@@ -0,0 +1,203 @@
package providers
import (
"context"
"errors"
"sort"
"strconv"
"strings"
"sync"
"unicode"
)
// bothMusic searches Deezer and MusicBrainz together. Deezer has the best
// art and 30-second previews, but its search misses albums it carries
// (Death Magnetic only turns up for an exact artist+album query) and has
// few game soundtracks. MusicBrainz fills those gaps.
type bothMusic struct {
dz *deezer
mb *musicbrainz
}
func (b *bothMusic) Name() string { return "deezer+musicbrainz" }
func (b *bothMusic) Label() string { return "Deezer + MusicBrainz" }
func (b *bothMusic) Search(ctx context.Context, query string) ([]Result, error) {
var (
wg sync.WaitGroup
dzRes, mbRes []Result
dzErr, mbErr error
)
wg.Add(2)
go func() { defer wg.Done(); dzRes, dzErr = b.dz.Search(ctx, query) }()
go func() { defer wg.Done(); mbRes, mbErr = b.mb.Search(ctx, query) }()
wg.Wait()
if dzErr != nil && mbErr != nil {
return nil, errors.Join(dzErr, mbErr)
}
return mergeResults(query, dzRes, mbRes), nil
}
func (b *bothMusic) Details(ctx context.Context, res Result) (Result, error) {
return b.dz.Details(ctx, res)
}
// mergeResults interleaves two result lists, drops secondary results that
// duplicate a primary one (same title and artist), and then orders by how
// many of the query's words each result contains, keeping the interleaved
// order for ties.
func mergeResults(query string, primary, secondary []Result) []Result {
seen := map[string]bool{}
for _, r := range primary {
seen[normalize(r.Title)+"|"+normalize(r.Subtitle)] = true
}
var merged []Result
for i := 0; i < len(primary) || i < len(secondary); i++ {
if i < len(primary) {
merged = append(merged, primary[i])
}
if i < len(secondary) && !seen[normalize(secondary[i].Title)+"|"+normalize(secondary[i].Subtitle)] {
merged = append(merged, secondary[i])
}
}
words := strings.Fields(normalize(query))
score := func(r Result) int {
hay := " " + normalize(r.Title+" "+r.Subtitle+" "+r.Extra["album"]) + " "
n := 0
for _, w := range words {
if strings.Contains(hay, " "+w+" ") {
n++
}
}
return n
}
sort.SliceStable(merged, func(i, j int) bool { return score(merged[i]) > score(merged[j]) })
return merged
}
// normalize lowercases and keeps only letters and digits, separated by
// single spaces.
func normalize(s string) string {
return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
}), " ")
}
// baseTitle drops bracketed qualifiers: "Dreams (2004 Remaster)" → "dreams".
func baseTitle(s string) string {
var b strings.Builder
depth := 0
for _, r := range s {
switch r {
case '(', '[', '{':
depth++
case ')', ']', '}':
if depth > 0 {
depth--
}
default:
if depth == 0 {
b.WriteRune(r)
}
}
}
return normalize(b.String())
}
// sameArtist matches whole words, so "Frank Ocean" matches "Frank Ocean
// feat. André 3000" but "Queen" doesn't match "Queens of the Stone Age".
func sameArtist(a, b string) bool {
a, b = normalize(a), normalize(b)
if a == "" || b == "" {
return false
}
pa, pb := " "+a+" ", " "+b+" "
return strings.Contains(pa, pb) || strings.Contains(pb, pa)
}
func sameTitle(a, b string) bool {
a, b = baseTitle(a), baseTitle(b)
return a != "" && b != "" && (a == b || strings.HasPrefix(a, b+" ") || strings.HasPrefix(b, a+" "))
}
// MatchDeezer finds the Deezer track or album for a title and artist, so
// entries added from MusicBrainz or by hand can still play a preview. It
// returns "" when there's no confident match.
func (r *Registry) MatchDeezer(ctx context.Context, category, title, artist string) (string, error) {
if strings.TrimSpace(title) == "" || strings.TrimSpace(artist) == "" {
return "", nil
}
d := &deezer{r: r, kind: category}
field := "track"
if category == "album" {
field = "album"
}
clean := strings.NewReplacer(`"`, "").Replace
queries := []string{
`artist:"` + clean(artist) + `" ` + field + `:"` + clean(title) + `"`,
artist + " " + title,
}
for _, q := range queries {
results, err := d.Search(ctx, q)
if err != nil {
return "", err
}
for _, res := range results {
if sameTitle(res.Title, title) && sameArtist(res.Subtitle, artist) {
return res.ProviderID, nil
}
}
}
return "", nil
}
// ErrNoPreview means Deezer has no preview clip for the item.
var ErrNoPreview = errors.New("no preview available")
// DeezerPreview returns a fresh URL for a 30-second preview clip. ref is a
// Deezer id as stored on items: "track:123" or "album:456". For albums it
// picks the album's most popular track. Deezer's clip URLs expire, so they
// must be looked up close to when they're played.
func (r *Registry) DeezerPreview(ctx context.Context, ref string) (string, error) {
kind, id, _ := strings.Cut(ref, ":")
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
return "", ErrNoPreview
}
d := &deezer{r: r}
switch kind {
case "track":
var t struct {
deezerErr
Preview string `json:"preview"`
}
if err := d.get(ctx, "/track/"+id, &t, &t.deezerErr); err != nil {
return "", err
}
if t.Preview == "" {
return "", ErrNoPreview
}
return t.Preview, nil
case "album":
var resp struct {
deezerErr
Data []struct {
Preview string `json:"preview"`
Rank int64 `json:"rank"`
} `json:"data"`
}
if err := d.get(ctx, "/album/"+id+"/tracks?limit=200", &resp, &resp.deezerErr); err != nil {
return "", err
}
best, bestRank := "", int64(-1)
for _, t := range resp.Data {
if t.Preview != "" && t.Rank > bestRank {
best, bestRank = t.Preview, t.Rank
}
}
if best == "" {
return "", ErrNoPreview
}
return best, nil
}
return "", ErrNoPreview
}
+60
View File
@@ -0,0 +1,60 @@
package providers
import "testing"
func TestMergeResults(t *testing.T) {
dz := []Result{
{Provider: "deezer", Title: "Death Race", Subtitle: "Magnetico"},
{Provider: "deezer", Title: "Celeste (Original Soundtrack)", Subtitle: "Lena Raine"},
}
mb := []Result{
{Provider: "musicbrainz", Title: "Death Magnetic", Subtitle: "Metallica"},
{Provider: "musicbrainz", Title: "Death Race", Subtitle: "Magnetico"}, // duplicate of a Deezer hit
}
got := mergeResults("death magnetic", dz, mb)
if len(got) != 3 {
t.Fatalf("want duplicates dropped, got %d results: %+v", len(got), got)
}
if got[0].Title != "Death Magnetic" {
t.Errorf("best match should come first, got %q", got[0].Title)
}
for _, r := range got {
if r.Title == "Death Race" && r.Provider != "deezer" {
t.Errorf("duplicate should keep the Deezer copy (it has previews)")
}
}
}
func TestMatching(t *testing.T) {
titles := []struct {
a, b string
want bool
}{
{"Dreams", "Dreams (2004 Remaster)", true},
{"Death Magnetic", "Death Magnetic", true},
{"Celeste: Original Soundtrack", "Celeste (Original Soundtrack)", true},
{"Hey Ya!", "Hey Ya", true},
{"Dreams", "Sweet Dreams", false},
{"Nights", "Nights in White Satin", true},
{"Blonde", "Blond", false},
}
for _, c := range titles {
if got := sameTitle(c.a, c.b); got != c.want {
t.Errorf("sameTitle(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
}
}
artists := []struct {
a, b string
want bool
}{
{"Metallica", "METALLICA", true},
{"Frank Ocean", "Frank Ocean feat. André 3000", true},
{"Queen", "Queens of the Stone Age", false},
{"", "Queen", false},
}
for _, c := range artists {
if got := sameArtist(c.a, c.b); got != c.want {
t.Errorf("sameArtist(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
}
}
}
+8 -4
View File
@@ -42,7 +42,8 @@ type Searcher interface {
}
// Detailer is implemented by searchers that can fill in more metadata (a
// director, a release year) once the owner picks a result.
// director, a release year) once the owner picks a result. Results from
// another provider are returned unchanged.
type Detailer interface {
Details(ctx context.Context, r Result) (Result, error)
}
@@ -62,7 +63,7 @@ type Credentials struct {
TVDBPin string
IGDBClientID string
IGDBClientSecret string
MusicProvider string // "deezer" or "musicbrainz"
MusicProvider string // "both" (default), "deezer" or "musicbrainz"
TVProvider string // "tvdb" or "tmdb"
}
@@ -106,10 +107,13 @@ func (r *Registry) For(ctx context.Context, category string) (Searcher, error) {
}
return &tvdb{r: r, key: c.TVDBKey, pin: c.TVDBPin}, nil
case "album", "song":
if c.MusicProvider == "musicbrainz" {
switch c.MusicProvider {
case "deezer":
return &deezer{r: r, kind: category}, nil
case "musicbrainz":
return &musicbrainz{r: r, kind: category}, nil
}
return &deezer{r: r, kind: category}, nil
return &bothMusic{dz: &deezer{r: r, kind: category}, mb: &musicbrainz{r: r, kind: category}}, nil
case "game":
if c.IGDBClientID == "" || c.IGDBClientSecret == "" {
return nil, NotConfiguredError{"IGDB", "Add your Twitch client ID and secret under Settings → Integrations."}
+3
View File
@@ -82,6 +82,9 @@ func (t *tmdb) Search(ctx context.Context, query string) ([]Result, error) {
}
func (t *tmdb) Details(ctx context.Context, res Result) (Result, error) {
if res.Provider != "tmdb" {
return res, nil
}
_, id, ok := strings.Cut(res.ProviderID, ":")
if !ok {
return res, nil