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>
195 lines
5.0 KiB
Go
195 lines
5.0 KiB
Go
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
|
|
}
|