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

+23 -4
View File
@@ -199,6 +199,7 @@ func (s *Server) apiAddItem(w http.ResponseWriter, r *http.Request) {
it.Image, it.ImageSource = name, res.ImageURL
}
}
s.matchDeezer(ctx, list.Category, &it)
saved, err := s.store.AddItem(ctx, it, in.Rank)
if err != nil {
s.releaseImage(ctx, it.Image)
@@ -212,7 +213,7 @@ func (s *Server) apiAddItem(w http.ResponseWriter, r *http.Request) {
// and the search result is used as-is.
func (s *Server) fillDetails(ctx context.Context, category string, res providers.Result) providers.Result {
p, err := s.providers.For(ctx, category)
if err != nil || p.Name() != res.Provider {
if err != nil {
return res
}
d, ok := p.(providers.Detailer)
@@ -283,6 +284,11 @@ func (s *Server) apiUpdateItem(w http.ResponseWriter, r *http.Request) {
}
it.Image, it.ImageSource = name, url
}
if it.Title != cur.Title || it.Subtitle != cur.Subtitle || it.Extra["deezer"] == "" {
if l, err := s.store.ListByID(ctx, it.ListID); err == nil {
s.matchDeezer(ctx, l.Category, &it)
}
}
saved, err := s.store.UpdateItem(ctx, it)
if err != nil {
if it.Image != cur.Image {
@@ -381,7 +387,8 @@ func (s *Server) apiDeleteItem(w http.ResponseWriter, r *http.Request) {
type searchResult struct {
providers.Result
ExistingRank int `json:"existingRank,omitempty"`
ExistingRank int `json:"existingRank,omitempty"`
PreviewRef string `json:"previewRef,omitempty"` // Deezer id to audition before adding
}
func (s *Server) apiSearch(w http.ResponseWriter, r *http.Request) {
@@ -409,6 +416,9 @@ func (s *Server) apiSearch(w http.ResponseWriter, r *http.Request) {
out := make([]searchResult, 0, len(results))
for _, res := range results {
sr := searchResult{Result: res}
if res.Provider == "deezer" && hasPreviews(category) {
sr.PreviewRef = res.ProviderID
}
if listID > 0 {
if it, err := s.store.FindItemByProvider(ctx, listID, res.Provider, res.ProviderID); err == nil {
sr.ExistingRank = it.Rank
@@ -478,7 +488,8 @@ func (s *Server) apiSettings(w http.ResponseWriter, r *http.Request) {
"customCss": set[store.SettingCustomCSS],
"previewCount": site.PreviewCount,
"showNotes": site.ShowNotes,
"musicProvider": orDefault(set[store.SettingMusicProvider], "deezer"),
"showPreviews": site.ShowPreviews,
"musicProvider": orDefault(set[store.SettingMusicProvider], "both"),
"tvProvider": orDefault(set[store.SettingTVProvider], "tvdb"),
"secrets": secrets,
"themes": Themes,
@@ -538,7 +549,7 @@ func (s *Server) apiUpdateSettings(w http.ResponseWriter, r *http.Request) {
s.writeErr(w, store.ValidationError{Msg: "Unknown theme"})
return
}
if v, ok := out[store.SettingMusicProvider]; ok && v != "deezer" && v != "musicbrainz" {
if v, ok := out[store.SettingMusicProvider]; ok && v != "both" && v != "deezer" && v != "musicbrainz" {
s.writeErr(w, store.ValidationError{Msg: "Unknown music provider"})
return
}
@@ -554,6 +565,14 @@ func (s *Server) apiUpdateSettings(w http.ResponseWriter, r *http.Request) {
}
out[store.SettingPreviewCount] = strconv.Itoa(int(n))
}
if v, ok := in["showPreviews"]; ok {
b, ok := v.(bool)
if !ok {
s.writeErr(w, store.ValidationError{Msg: "showPreviews must be true or false"})
return
}
out[store.SettingShowPreviews] = strconv.FormatBool(b)
}
if v, ok := in["showNotes"]; ok {
b, ok := v.(bool)
if !ok {