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
@@ -0,0 +1,64 @@
|
||||
//go:build live
|
||||
|
||||
// Live checks against the real music services: go test -tags live ./internal/providers
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLiveMusic(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
reg := NewRegistry(func(context.Context) Credentials { return Credentials{} })
|
||||
albums, _ := reg.For(ctx, "album")
|
||||
|
||||
for _, c := range []struct{ query, want string }{
|
||||
{"death magnetic", "Death Magnetic"},
|
||||
{"super mario galaxy soundtrack", "Super Mario Galaxy Original Soundtrack"},
|
||||
{"chrono trigger original soundtrack", "Chrono Trigger Original Soundtrack"},
|
||||
} {
|
||||
res, err := albums.Search(ctx, c.query)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.query, err)
|
||||
}
|
||||
found := -1
|
||||
for i, r := range res {
|
||||
if r.Title == c.want {
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
t.Logf("%q → %q at #%d (%s, type %q)", c.query, c.want, found+1, pick(res, found).Provider, pick(res, found).Extra["type"])
|
||||
if found < 0 || found > 2 {
|
||||
t.Errorf("%q: want %q in the top 3", c.query, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
ref, err := reg.MatchDeezer(ctx, "album", "Death Magnetic", "Metallica")
|
||||
t.Logf("Death Magnetic → Deezer %q", ref)
|
||||
if err != nil || !strings.HasPrefix(ref, "album:") {
|
||||
t.Fatalf("match: %q %v", ref, err)
|
||||
}
|
||||
u, err := reg.DeezerPreview(ctx, ref)
|
||||
if err != nil || !strings.HasPrefix(u, "https://") {
|
||||
t.Fatalf("preview: %q %v", u, err)
|
||||
}
|
||||
t.Logf("preview URL host: %s", strings.SplitN(strings.TrimPrefix(u, "https://"), "/", 2)[0])
|
||||
|
||||
song, err := reg.MatchDeezer(ctx, "song", "Dreams", "Fleetwood Mac")
|
||||
t.Logf("Dreams → Deezer %q", song)
|
||||
if err != nil || song == "" {
|
||||
t.Errorf("song match: %q %v", song, err)
|
||||
}
|
||||
}
|
||||
|
||||
func pick(res []Result, i int) Result {
|
||||
if i < 0 {
|
||||
return Result{}
|
||||
}
|
||||
return res[i]
|
||||
}
|
||||
@@ -134,6 +134,9 @@ func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
}
|
||||
|
||||
func (d *deezer) Details(ctx context.Context, res Result) (Result, error) {
|
||||
if res.Provider != "deezer" {
|
||||
return res, nil
|
||||
}
|
||||
kind, id, _ := strings.Cut(res.ProviderID, ":")
|
||||
if res.Extra == nil {
|
||||
res.Extra = map[string]string{}
|
||||
@@ -206,6 +209,7 @@ func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PrimaryType string `json:"primary-type"`
|
||||
SecondaryTypes []string `json:"secondary-types"`
|
||||
FirstReleaseDate string `json:"first-release-date"`
|
||||
ArtistCredit []mbCredit `json:"artist-credit"`
|
||||
} `json:"release-groups"`
|
||||
@@ -225,8 +229,13 @@ func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error
|
||||
ExternalURL: "https://musicbrainz.org/release-group/" + rg.ID,
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
// e.g. "Soundtrack", "Live", "EP · Soundtrack"
|
||||
types := rg.SecondaryTypes
|
||||
if rg.PrimaryType != "" && rg.PrimaryType != "Album" {
|
||||
r.Extra["type"] = rg.PrimaryType
|
||||
types = append([]string{rg.PrimaryType}, types...)
|
||||
}
|
||||
if len(types) > 0 {
|
||||
r.Extra["type"] = strings.Join(types, " · ")
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// bothMusic searches Deezer and MusicBrainz together. Deezer has the best
|
||||
// art and 30-second previews, but its search misses albums it carries
|
||||
// (Death Magnetic only turns up for an exact artist+album query) and has
|
||||
// few game soundtracks. MusicBrainz fills those gaps.
|
||||
type bothMusic struct {
|
||||
dz *deezer
|
||||
mb *musicbrainz
|
||||
}
|
||||
|
||||
func (b *bothMusic) Name() string { return "deezer+musicbrainz" }
|
||||
func (b *bothMusic) Label() string { return "Deezer + MusicBrainz" }
|
||||
|
||||
func (b *bothMusic) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
dzRes, mbRes []Result
|
||||
dzErr, mbErr error
|
||||
)
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); dzRes, dzErr = b.dz.Search(ctx, query) }()
|
||||
go func() { defer wg.Done(); mbRes, mbErr = b.mb.Search(ctx, query) }()
|
||||
wg.Wait()
|
||||
if dzErr != nil && mbErr != nil {
|
||||
return nil, errors.Join(dzErr, mbErr)
|
||||
}
|
||||
return mergeResults(query, dzRes, mbRes), nil
|
||||
}
|
||||
|
||||
func (b *bothMusic) Details(ctx context.Context, res Result) (Result, error) {
|
||||
return b.dz.Details(ctx, res)
|
||||
}
|
||||
|
||||
// mergeResults interleaves two result lists, drops secondary results that
|
||||
// duplicate a primary one (same title and artist), and then orders by how
|
||||
// many of the query's words each result contains, keeping the interleaved
|
||||
// order for ties.
|
||||
func mergeResults(query string, primary, secondary []Result) []Result {
|
||||
seen := map[string]bool{}
|
||||
for _, r := range primary {
|
||||
seen[normalize(r.Title)+"|"+normalize(r.Subtitle)] = true
|
||||
}
|
||||
var merged []Result
|
||||
for i := 0; i < len(primary) || i < len(secondary); i++ {
|
||||
if i < len(primary) {
|
||||
merged = append(merged, primary[i])
|
||||
}
|
||||
if i < len(secondary) && !seen[normalize(secondary[i].Title)+"|"+normalize(secondary[i].Subtitle)] {
|
||||
merged = append(merged, secondary[i])
|
||||
}
|
||||
}
|
||||
words := strings.Fields(normalize(query))
|
||||
score := func(r Result) int {
|
||||
hay := " " + normalize(r.Title+" "+r.Subtitle+" "+r.Extra["album"]) + " "
|
||||
n := 0
|
||||
for _, w := range words {
|
||||
if strings.Contains(hay, " "+w+" ") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
sort.SliceStable(merged, func(i, j int) bool { return score(merged[i]) > score(merged[j]) })
|
||||
return merged
|
||||
}
|
||||
|
||||
// normalize lowercases and keeps only letters and digits, separated by
|
||||
// single spaces.
|
||||
func normalize(s string) string {
|
||||
return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
}), " ")
|
||||
}
|
||||
|
||||
// baseTitle drops bracketed qualifiers: "Dreams (2004 Remaster)" → "dreams".
|
||||
func baseTitle(s string) string {
|
||||
var b strings.Builder
|
||||
depth := 0
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '(', '[', '{':
|
||||
depth++
|
||||
case ')', ']', '}':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
default:
|
||||
if depth == 0 {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalize(b.String())
|
||||
}
|
||||
|
||||
// sameArtist matches whole words, so "Frank Ocean" matches "Frank Ocean
|
||||
// feat. André 3000" but "Queen" doesn't match "Queens of the Stone Age".
|
||||
func sameArtist(a, b string) bool {
|
||||
a, b = normalize(a), normalize(b)
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
pa, pb := " "+a+" ", " "+b+" "
|
||||
return strings.Contains(pa, pb) || strings.Contains(pb, pa)
|
||||
}
|
||||
|
||||
func sameTitle(a, b string) bool {
|
||||
a, b = baseTitle(a), baseTitle(b)
|
||||
return a != "" && b != "" && (a == b || strings.HasPrefix(a, b+" ") || strings.HasPrefix(b, a+" "))
|
||||
}
|
||||
|
||||
// MatchDeezer finds the Deezer track or album for a title and artist, so
|
||||
// entries added from MusicBrainz or by hand can still play a preview. It
|
||||
// returns "" when there's no confident match.
|
||||
func (r *Registry) MatchDeezer(ctx context.Context, category, title, artist string) (string, error) {
|
||||
if strings.TrimSpace(title) == "" || strings.TrimSpace(artist) == "" {
|
||||
return "", nil
|
||||
}
|
||||
d := &deezer{r: r, kind: category}
|
||||
field := "track"
|
||||
if category == "album" {
|
||||
field = "album"
|
||||
}
|
||||
clean := strings.NewReplacer(`"`, "").Replace
|
||||
queries := []string{
|
||||
`artist:"` + clean(artist) + `" ` + field + `:"` + clean(title) + `"`,
|
||||
artist + " " + title,
|
||||
}
|
||||
for _, q := range queries {
|
||||
results, err := d.Search(ctx, q)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, res := range results {
|
||||
if sameTitle(res.Title, title) && sameArtist(res.Subtitle, artist) {
|
||||
return res.ProviderID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ErrNoPreview means Deezer has no preview clip for the item.
|
||||
var ErrNoPreview = errors.New("no preview available")
|
||||
|
||||
// DeezerPreview returns a fresh URL for a 30-second preview clip. ref is a
|
||||
// Deezer id as stored on items: "track:123" or "album:456". For albums it
|
||||
// picks the album's most popular track. Deezer's clip URLs expire, so they
|
||||
// must be looked up close to when they're played.
|
||||
func (r *Registry) DeezerPreview(ctx context.Context, ref string) (string, error) {
|
||||
kind, id, _ := strings.Cut(ref, ":")
|
||||
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
|
||||
return "", ErrNoPreview
|
||||
}
|
||||
d := &deezer{r: r}
|
||||
switch kind {
|
||||
case "track":
|
||||
var t struct {
|
||||
deezerErr
|
||||
Preview string `json:"preview"`
|
||||
}
|
||||
if err := d.get(ctx, "/track/"+id, &t, &t.deezerErr); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if t.Preview == "" {
|
||||
return "", ErrNoPreview
|
||||
}
|
||||
return t.Preview, nil
|
||||
case "album":
|
||||
var resp struct {
|
||||
deezerErr
|
||||
Data []struct {
|
||||
Preview string `json:"preview"`
|
||||
Rank int64 `json:"rank"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := d.get(ctx, "/album/"+id+"/tracks?limit=200", &resp, &resp.deezerErr); err != nil {
|
||||
return "", err
|
||||
}
|
||||
best, bestRank := "", int64(-1)
|
||||
for _, t := range resp.Data {
|
||||
if t.Preview != "" && t.Rank > bestRank {
|
||||
best, bestRank = t.Preview, t.Rank
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return "", ErrNoPreview
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
return "", ErrNoPreview
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package providers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMergeResults(t *testing.T) {
|
||||
dz := []Result{
|
||||
{Provider: "deezer", Title: "Death Race", Subtitle: "Magnetico"},
|
||||
{Provider: "deezer", Title: "Celeste (Original Soundtrack)", Subtitle: "Lena Raine"},
|
||||
}
|
||||
mb := []Result{
|
||||
{Provider: "musicbrainz", Title: "Death Magnetic", Subtitle: "Metallica"},
|
||||
{Provider: "musicbrainz", Title: "Death Race", Subtitle: "Magnetico"}, // duplicate of a Deezer hit
|
||||
}
|
||||
got := mergeResults("death magnetic", dz, mb)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("want duplicates dropped, got %d results: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Title != "Death Magnetic" {
|
||||
t.Errorf("best match should come first, got %q", got[0].Title)
|
||||
}
|
||||
for _, r := range got {
|
||||
if r.Title == "Death Race" && r.Provider != "deezer" {
|
||||
t.Errorf("duplicate should keep the Deezer copy (it has previews)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatching(t *testing.T) {
|
||||
titles := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"Dreams", "Dreams (2004 Remaster)", true},
|
||||
{"Death Magnetic", "Death Magnetic", true},
|
||||
{"Celeste: Original Soundtrack", "Celeste (Original Soundtrack)", true},
|
||||
{"Hey Ya!", "Hey Ya", true},
|
||||
{"Dreams", "Sweet Dreams", false},
|
||||
{"Nights", "Nights in White Satin", true},
|
||||
{"Blonde", "Blond", false},
|
||||
}
|
||||
for _, c := range titles {
|
||||
if got := sameTitle(c.a, c.b); got != c.want {
|
||||
t.Errorf("sameTitle(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
artists := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"Metallica", "METALLICA", true},
|
||||
{"Frank Ocean", "Frank Ocean feat. André 3000", true},
|
||||
{"Queen", "Queens of the Stone Age", false},
|
||||
{"", "Queen", false},
|
||||
}
|
||||
for _, c := range artists {
|
||||
if got := sameArtist(c.a, c.b); got != c.want {
|
||||
t.Errorf("sameArtist(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,8 @@ type Searcher interface {
|
||||
}
|
||||
|
||||
// Detailer is implemented by searchers that can fill in more metadata (a
|
||||
// director, a release year) once the owner picks a result.
|
||||
// director, a release year) once the owner picks a result. Results from
|
||||
// another provider are returned unchanged.
|
||||
type Detailer interface {
|
||||
Details(ctx context.Context, r Result) (Result, error)
|
||||
}
|
||||
@@ -62,7 +63,7 @@ type Credentials struct {
|
||||
TVDBPin string
|
||||
IGDBClientID string
|
||||
IGDBClientSecret string
|
||||
MusicProvider string // "deezer" or "musicbrainz"
|
||||
MusicProvider string // "both" (default), "deezer" or "musicbrainz"
|
||||
TVProvider string // "tvdb" or "tmdb"
|
||||
}
|
||||
|
||||
@@ -106,10 +107,13 @@ func (r *Registry) For(ctx context.Context, category string) (Searcher, error) {
|
||||
}
|
||||
return &tvdb{r: r, key: c.TVDBKey, pin: c.TVDBPin}, nil
|
||||
case "album", "song":
|
||||
if c.MusicProvider == "musicbrainz" {
|
||||
switch c.MusicProvider {
|
||||
case "deezer":
|
||||
return &deezer{r: r, kind: category}, nil
|
||||
case "musicbrainz":
|
||||
return &musicbrainz{r: r, kind: category}, nil
|
||||
}
|
||||
return &deezer{r: r, kind: category}, nil
|
||||
return &bothMusic{dz: &deezer{r: r, kind: category}, mb: &musicbrainz{r: r, kind: category}}, nil
|
||||
case "game":
|
||||
if c.IGDBClientID == "" || c.IGDBClientSecret == "" {
|
||||
return nil, NotConfiguredError{"IGDB", "Add your Twitch client ID and secret under Settings → Integrations."}
|
||||
|
||||
@@ -82,6 +82,9 @@ func (t *tmdb) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
}
|
||||
|
||||
func (t *tmdb) Details(ctx context.Context, res Result) (Result, error) {
|
||||
if res.Provider != "tmdb" {
|
||||
return res, nil
|
||||
}
|
||||
_, id, ok := strings.Cut(res.ProviderID, ":")
|
||||
if !ok {
|
||||
return res, nil
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)) }
|
||||
|
||||
@@ -17,6 +17,7 @@ const (
|
||||
SettingCustomCSS = "custom_css"
|
||||
SettingPreviewCount = "preview_count" // items shown per list on the home page
|
||||
SettingShowNotes = "show_notes"
|
||||
SettingShowPreviews = "show_previews"
|
||||
SettingMusicProvider = "music_provider"
|
||||
SettingTVProvider = "tv_provider"
|
||||
|
||||
|
||||
Reference in new issue
Block a user