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

+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)
}