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 }