diff --git a/README.md b/README.md index b39c3f2..591c794 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Visitors just see your lists. There's no sign-up, no feed and no login button. Y - **Theater by production**: record *which* staging you mean (Broadway, West End, North American Tour, a revival…) plus venue and cast, and use that production's own poster. - **Drag to rank**, or use the arrow buttons or type a new number. Paginated editing keeps huge lists usable. - **Art is cached on your server**, so pages never hotlink and keep working if a source changes. You can also paste an image URL or upload your own. +- **Preview clips** for songs and albums: a play button plays 30 seconds from Deezer (an album plays its most popular track). Clips stream through your server, so visitors never connect to Deezer. You can turn this off in Settings → Profile. - **Export** any list as a titles-only `.csv` or `.txt`. - **Descriptions and your reasons**: each entry keeps the source's description (TMDB overview, IGDB summary, Wikipedia intro), and you can add a "Why it's #N" note explaining its spot. Both show in the detail view. - **12 theme presets** plus a custom CSS box. Every color is a CSS variable. @@ -50,12 +51,12 @@ Or from source (Go 1.26+): `go run ./cmd/canon` stores data in `./data`. | Movies | [TMDB](https://www.themoviedb.org) | Yes (free) | [themoviedb.org/settings/api](https://www.themoviedb.org/settings/api). The v3 key or the v4 Read Access Token both work. | | TV Shows | [TheTVDB](https://thetvdb.com) (default) or TMDB | Yes | [thetvdb.com/api-information](https://thetvdb.com/api-information). Switch TV to TMDB in Settings to reuse your TMDB key. | | Video Games | [IGDB](https://www.igdb.com) | Yes (free, via Twitch) | Register an app at [dev.twitch.tv/console](https://dev.twitch.tv/console/apps) and use its Client ID + Secret ([guide](https://api-docs.igdb.com/#account-creation)). | -| Albums & Songs | [Deezer](https://developers.deezer.com/api) (default) or [MusicBrainz](https://musicbrainz.org) + [Cover Art Archive](https://coverartarchive.org) | No | Nothing to set up. | +| Albums & Songs | [Deezer](https://developers.deezer.com/api) and [MusicBrainz](https://musicbrainz.org) + [Cover Art Archive](https://coverartarchive.org), searched together (or either alone) | No | Nothing to set up. | | Theater | [Wikipedia](https://en.wikipedia.org) | No | Nothing to set up. | | Custom | Manual entry, or [Wikipedia](https://en.wikipedia.org) | No | Nothing to set up. | **Why these for music and theater?** Neither needs a key. -- **Music:** Deezer's public catalogue API has no key, generous limits (about 50 requests per 5 seconds) and 1000×1000 covers for both tracks and albums. MusicBrainz is the fully open alternative, but it's limited to one request per second and art coverage is patchier. Spotify, Apple Music and Last.fm all need keys or OAuth and have stricter terms. +- **Music:** Canon searches Deezer and MusicBrainz together and merges the results. Deezer's public API has no key, generous limits, 1000×1000 covers and 30-second preview clips, but its search misses some albums it carries (Death Magnetic only turns up for an exact artist + album query) and has few game soundtracks. MusicBrainz is fully open, finds those, and tags soundtracks, but allows only one request per second. Entries added from MusicBrainz or by hand are matched to Deezer when saved, so they can play previews too. Spotify, Apple Music and Last.fm all need keys or OAuth and have stricter terms. For game music, [VGMdb](https://vgmdb.net) has the deepest catalogue but no public API, so add anything missing by hand. - **Theater:** there's no open theater database with an API (IBDB and Playbill don't offer one). Wikipedia covers nearly every musical, play and opera, usually with poster or logo art. Canon filters search to pages that use the musical, play or opera infobox. Wikipedia describes the *work*, so you pick the production yourself and can swap in that production's poster. Keys entered in the UI are stored in the SQLite database on your server. Keys set as environment variables take precedence and show as locked in the UI. @@ -141,6 +142,7 @@ docker compose exec canon sh -c 'cd /data && tar c canon.db* images' > canon-bac ```sh go run ./cmd/canon # http://localhost:4242 go test ./... +go test -tags live ./internal/providers # hits the real music APIs ``` Templates and static assets in `web/` are embedded into the binary, so rebuild after editing them. The front end is plain ES modules with no build step. diff --git a/internal/providers/live_test.go b/internal/providers/live_test.go new file mode 100644 index 0000000..665c162 --- /dev/null +++ b/internal/providers/live_test.go @@ -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] +} diff --git a/internal/providers/music.go b/internal/providers/music.go index 1fb7700..7f3763e 100644 --- a/internal/providers/music.go +++ b/internal/providers/music.go @@ -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) } diff --git a/internal/providers/music_both.go b/internal/providers/music_both.go new file mode 100644 index 0000000..cad135e --- /dev/null +++ b/internal/providers/music_both.go @@ -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 +} diff --git a/internal/providers/music_both_test.go b/internal/providers/music_both_test.go new file mode 100644 index 0000000..857e7f1 --- /dev/null +++ b/internal/providers/music_both_test.go @@ -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) + } + } +} diff --git a/internal/providers/providers.go b/internal/providers/providers.go index b586ca6..c561bb4 100644 --- a/internal/providers/providers.go +++ b/internal/providers/providers.go @@ -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."} diff --git a/internal/providers/tmdb.go b/internal/providers/tmdb.go index 1fbae08..e68e13e 100644 --- a/internal/providers/tmdb.go +++ b/internal/providers/tmdb.go @@ -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 diff --git a/internal/server/api.go b/internal/server/api.go index d7dc1c2..c4ef130 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -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 { diff --git a/internal/server/preview.go b/internal/server/preview.go new file mode 100644 index 0000000..3f0a027 --- /dev/null +++ b/internal/server/preview.go @@ -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