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

+6 -4
View File
@@ -20,7 +20,8 @@ type ItemView struct {
Source string `json:"source"`
Description string `json:"description"`
Note string `json:"note"`
Badge string `json:"badge"` // e.g. theater production
Badge string `json:"badge"` // e.g. theater production
Preview bool `json:"preview"` // has a playable clip at /preview/{id}
Meta []MetaKV `json:"meta"`
}
@@ -52,7 +53,7 @@ var extraFields = []struct{ Key, Label string }{
{"label", "Label"},
}
func itemView(it store.Item, showNotes bool) ItemView {
func itemView(it store.Item, showNotes, previews bool) ItemView {
v := ItemView{
ID: it.ID,
Rank: it.Rank,
@@ -71,6 +72,7 @@ func itemView(it store.Item, showNotes bool) ItemView {
if showNotes {
v.Note = it.Note
}
v.Preview = previews && deezerRef(it) != ""
for _, f := range extraFields {
if val := it.Extra[f.Key]; val != "" {
v.Meta = append(v.Meta, MetaKV{f.Label, val})
@@ -122,7 +124,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
sec := listSection{List: l, More: l.ItemCount - len(items)}
sec.Category = listCategory(l)
for _, it := range items {
sec.Items = append(sec.Items, itemView(it, site.ShowNotes))
sec.Items = append(sec.Items, itemView(it, site.ShowNotes, site.ShowPreviews && hasPreviews(l.Category)))
}
page.Sections = append(page.Sections, sec)
page.TotalItems += l.ItemCount
@@ -188,7 +190,7 @@ func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
return
}
for _, it := range items {
p.Items = append(p.Items, itemView(it, site.ShowNotes))
p.Items = append(p.Items, itemView(it, site.ShowNotes, site.ShowPreviews && hasPreviews(l.Category)))
}
if len(p.Items) > 0 && p.Items[0].Rank == 1 && p.Items[0].Image != "" {
p.Hero = &p.Items[0]