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
+23
-4
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user