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

+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."}