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 {
+194
View File
@@ -0,0 +1,194 @@
package server
import (
"context"
"errors"
"io"
"net/http"
"regexp"
"strconv"
"sync"
"time"
"github.com/cdnutter/canon/internal/providers"
"github.com/cdnutter/canon/internal/store"
)
// Previews are 30-second clips from Deezer, streamed through Canon so
// visitors never connect to Deezer directly. Deezer's clip URLs expire, so
// they're looked up on demand and cached briefly.
var deezerRefRe = regexp.MustCompile(`^(track|album):[0-9]+$`)
// deezerRef is the Deezer track/album an item can preview, or "".
func deezerRef(it store.Item) string {
ref := it.Extra["deezer"]
if it.Provider == "deezer" {
ref = it.ProviderID
}
if !deezerRefRe.MatchString(ref) {
return ""
}
return ref
}
func hasPreviews(category string) bool { return category == "song" || category == "album" }
type previewCache struct {
mu sync.Mutex
m map[string]cachedPreview
}
type cachedPreview struct {
url string
expires time.Time
}
func (c *previewCache) get(ref string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
p, ok := c.m[ref]
if !ok || time.Now().After(p.expires) {
return "", false
}
return p.url, true
}
func (c *previewCache) put(ref, url string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.m == nil || len(c.m) > 2000 {
c.m = map[string]cachedPreview{}
}
c.m[ref] = cachedPreview{url: url, expires: time.Now().Add(10 * time.Minute)}
}
func (c *previewCache) drop(ref string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.m, ref)
}
// handleItemPreview plays the preview for a list item. It follows the
// list's visibility, like the list page does.
func (s *Server) handleItemPreview(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
it, err := s.store.ItemByID(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
l, err := s.store.ListByID(r.Context(), it.ListID)
if err != nil || !hasPreviews(l.Category) || !s.site(r.Context()).ShowPreviews {
http.NotFound(w, r)
return
}
if !l.Visible {
if _, ok := s.currentUser(r); !ok {
http.NotFound(w, r)
return
}
}
ref := deezerRef(it)
if ref == "" {
http.NotFound(w, r)
return
}
s.streamPreview(w, r, ref)
}
// handleAdminPreview plays a search result before it's added. It's a plain
// GET behind the session (an <audio> element can't send the API header).
func (s *Server) handleAdminPreview(w http.ResponseWriter, r *http.Request) {
ref := r.URL.Query().Get("ref")
if !deezerRefRe.MatchString(ref) {
http.NotFound(w, r)
return
}
s.streamPreview(w, r, ref)
}
func (s *Server) streamPreview(w http.ResponseWriter, r *http.Request, ref string) {
for attempt := 0; attempt < 2; attempt++ {
url, cached := s.previews.get(ref)
if !cached {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
var err error
url, err = s.providers.DeezerPreview(ctx, ref)
cancel()
if errors.Is(err, providers.ErrNoPreview) {
http.Error(w, "No preview available", http.StatusNotFound)
return
}
if err != nil {
s.log.Warn("preview lookup failed", "ref", ref, "err", err)
http.Error(w, "Preview unavailable", http.StatusBadGateway)
return
}
s.previews.put(ref, url)
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, url, nil)
if err != nil {
break
}
req.Header.Set("User-Agent", providers.UserAgent)
// Forward ranges so browsers (Safari especially) can stream and seek.
if rg := r.Header.Get("Range"); rg != "" {
req.Header.Set("Range", rg)
}
resp, err := s.previewClient.Do(req)
if err != nil {
s.log.Warn("preview fetch failed", "ref", ref, "err", err)
http.Error(w, "Preview unavailable", http.StatusBadGateway)
return
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
resp.Body.Close()
// Probably an expired clip URL; look it up again once.
s.previews.drop(ref)
continue
}
defer resp.Body.Close()
h := w.Header()
for _, k := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(k); v != "" {
h.Set(k, v)
}
}
if h.Get("Content-Type") == "" {
h.Set("Content-Type", "audio/mpeg")
}
h.Set("Cache-Control", "private, max-age=600")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
return
}
http.Error(w, "Preview unavailable", http.StatusBadGateway)
}
// matchDeezer links a song or album that didn't come from Deezer to its
// Deezer counterpart so it can play a preview. Failures just mean no preview.
func (s *Server) matchDeezer(ctx context.Context, category string, it *store.Item) {
if !hasPreviews(category) || it.Provider == "deezer" {
return
}
if it.Extra == nil {
it.Extra = map[string]string{}
}
mctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
ref, err := s.providers.MatchDeezer(mctx, category, it.Title, it.Subtitle)
if err != nil {
s.log.Warn("matching Deezer preview", "title", it.Title, "err", err)
return
}
if ref == "" {
delete(it.Extra, "deezer")
return
}
it.Extra["deezer"] = ref
}
+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]
+2
View File
@@ -26,6 +26,7 @@ type Site struct {
CSSVersion string
PreviewCount int
ShowNotes bool
ShowPreviews bool
}
type pageData struct {
@@ -51,6 +52,7 @@ func (s *Server) site(ctx context.Context) Site {
Theme: set[store.SettingTheme],
PreviewCount: 10,
ShowNotes: set[store.SettingShowNotes] != "false",
ShowPreviews: set[store.SettingShowPreviews] != "false",
}
if site.Title == "" {
site.Title = DefaultTitle(site.OwnerName)
+7
View File
@@ -43,6 +43,9 @@ type Server struct {
assetVer string
loginLimit *auth.Limiter
setupCode string
previews previewCache
previewClient *http.Client
}
func New(cfg config.Config, st *store.Store, images *imgcache.Cache, log *slog.Logger, version string) (*Server, error) {
@@ -53,6 +56,8 @@ func New(cfg config.Config, st *store.Store, images *imgcache.Cache, log *slog.L
log: log,
assetVer: version,
loginLimit: auth.NewLimiter(10, 15*time.Minute),
// No overall timeout: clips stream for as long as the listener plays.
previewClient: &http.Client{Transport: &http.Transport{ResponseHeaderTimeout: 15 * time.Second}},
}
s.providers = providers.NewRegistry(s.credentials)
static, err := fs.Sub(web.FS, "static")
@@ -99,6 +104,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /{$}", s.handleHome)
mux.HandleFunc("GET /l/{slug}", s.handleList)
mux.HandleFunc("GET /l/{slug}/export", s.handleExport)
mux.HandleFunc("GET /preview/{id}", s.handleItemPreview)
mux.HandleFunc("GET /images/{name}", s.handleImage)
mux.HandleFunc("GET /custom.css", s.handleCustomCSS)
mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler()))
@@ -118,6 +124,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /admin", s.requireUser(s.handleAdminLists))
mux.HandleFunc("GET /admin/lists/{id}", s.requireUser(s.handleAdminList))
mux.HandleFunc("GET /admin/settings", s.requireUser(s.handleAdminSettings))
mux.HandleFunc("GET /admin/preview", s.requireUser(s.handleAdminPreview))
// Admin JSON API
api := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, s.requireAPI(h)) }