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:
1 parent
6d92b46130
commit
b09533cc00
20 files changed
+736
-19
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user