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

+4 -2
View File
@@ -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. - **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. - **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. - **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`. - **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. - **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. - **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. | | 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. | | 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)). | | 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. | | 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. | | Custom | Manual entry, or [Wikipedia](https://en.wikipedia.org) | No | Nothing to set up. |
**Why these for music and theater?** Neither needs a key. **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. - **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. 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 ```sh
go run ./cmd/canon # http://localhost:4242 go run ./cmd/canon # http://localhost:4242
go test ./... 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. 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.
+64
View File
@@ -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]
}
+10 -1
View File
@@ -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) { func (d *deezer) Details(ctx context.Context, res Result) (Result, error) {
if res.Provider != "deezer" {
return res, nil
}
kind, id, _ := strings.Cut(res.ProviderID, ":") kind, id, _ := strings.Cut(res.ProviderID, ":")
if res.Extra == nil { if res.Extra == nil {
res.Extra = map[string]string{} res.Extra = map[string]string{}
@@ -206,6 +209,7 @@ func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
PrimaryType string `json:"primary-type"` PrimaryType string `json:"primary-type"`
SecondaryTypes []string `json:"secondary-types"`
FirstReleaseDate string `json:"first-release-date"` FirstReleaseDate string `json:"first-release-date"`
ArtistCredit []mbCredit `json:"artist-credit"` ArtistCredit []mbCredit `json:"artist-credit"`
} `json:"release-groups"` } `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, ExternalURL: "https://musicbrainz.org/release-group/" + rg.ID,
Extra: map[string]string{}, Extra: map[string]string{},
} }
// e.g. "Soundtrack", "Live", "EP · Soundtrack"
types := rg.SecondaryTypes
if rg.PrimaryType != "" && rg.PrimaryType != "Album" { 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) out = append(out, r)
} }
+203
View File
@@ -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
}
+60
View File
@@ -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)
}
}
}
+8 -4
View File
@@ -42,7 +42,8 @@ type Searcher interface {
} }
// Detailer is implemented by searchers that can fill in more metadata (a // 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 { type Detailer interface {
Details(ctx context.Context, r Result) (Result, error) Details(ctx context.Context, r Result) (Result, error)
} }
@@ -62,7 +63,7 @@ type Credentials struct {
TVDBPin string TVDBPin string
IGDBClientID string IGDBClientID string
IGDBClientSecret string IGDBClientSecret string
MusicProvider string // "deezer" or "musicbrainz" MusicProvider string // "both" (default), "deezer" or "musicbrainz"
TVProvider string // "tvdb" or "tmdb" 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 return &tvdb{r: r, key: c.TVDBKey, pin: c.TVDBPin}, nil
case "album", "song": 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 &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": case "game":
if c.IGDBClientID == "" || c.IGDBClientSecret == "" { if c.IGDBClientID == "" || c.IGDBClientSecret == "" {
return nil, NotConfiguredError{"IGDB", "Add your Twitch client ID and secret under Settings → Integrations."} return nil, NotConfiguredError{"IGDB", "Add your Twitch client ID and secret under Settings → Integrations."}
+3
View File
@@ -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) { func (t *tmdb) Details(ctx context.Context, res Result) (Result, error) {
if res.Provider != "tmdb" {
return res, nil
}
_, id, ok := strings.Cut(res.ProviderID, ":") _, id, ok := strings.Cut(res.ProviderID, ":")
if !ok { if !ok {
return res, nil return res, nil
+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 it.Image, it.ImageSource = name, res.ImageURL
} }
} }
s.matchDeezer(ctx, list.Category, &it)
saved, err := s.store.AddItem(ctx, it, in.Rank) saved, err := s.store.AddItem(ctx, it, in.Rank)
if err != nil { if err != nil {
s.releaseImage(ctx, it.Image) 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. // and the search result is used as-is.
func (s *Server) fillDetails(ctx context.Context, category string, res providers.Result) providers.Result { func (s *Server) fillDetails(ctx context.Context, category string, res providers.Result) providers.Result {
p, err := s.providers.For(ctx, category) p, err := s.providers.For(ctx, category)
if err != nil || p.Name() != res.Provider { if err != nil {
return res return res
} }
d, ok := p.(providers.Detailer) 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 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) saved, err := s.store.UpdateItem(ctx, it)
if err != nil { if err != nil {
if it.Image != cur.Image { if it.Image != cur.Image {
@@ -381,7 +387,8 @@ func (s *Server) apiDeleteItem(w http.ResponseWriter, r *http.Request) {
type searchResult struct { type searchResult struct {
providers.Result 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) { 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)) out := make([]searchResult, 0, len(results))
for _, res := range results { for _, res := range results {
sr := searchResult{Result: res} sr := searchResult{Result: res}
if res.Provider == "deezer" && hasPreviews(category) {
sr.PreviewRef = res.ProviderID
}
if listID > 0 { if listID > 0 {
if it, err := s.store.FindItemByProvider(ctx, listID, res.Provider, res.ProviderID); err == nil { if it, err := s.store.FindItemByProvider(ctx, listID, res.Provider, res.ProviderID); err == nil {
sr.ExistingRank = it.Rank sr.ExistingRank = it.Rank
@@ -478,7 +488,8 @@ func (s *Server) apiSettings(w http.ResponseWriter, r *http.Request) {
"customCss": set[store.SettingCustomCSS], "customCss": set[store.SettingCustomCSS],
"previewCount": site.PreviewCount, "previewCount": site.PreviewCount,
"showNotes": site.ShowNotes, "showNotes": site.ShowNotes,
"musicProvider": orDefault(set[store.SettingMusicProvider], "deezer"), "showPreviews": site.ShowPreviews,
"musicProvider": orDefault(set[store.SettingMusicProvider], "both"),
"tvProvider": orDefault(set[store.SettingTVProvider], "tvdb"), "tvProvider": orDefault(set[store.SettingTVProvider], "tvdb"),
"secrets": secrets, "secrets": secrets,
"themes": Themes, "themes": Themes,
@@ -538,7 +549,7 @@ func (s *Server) apiUpdateSettings(w http.ResponseWriter, r *http.Request) {
s.writeErr(w, store.ValidationError{Msg: "Unknown theme"}) s.writeErr(w, store.ValidationError{Msg: "Unknown theme"})
return 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"}) s.writeErr(w, store.ValidationError{Msg: "Unknown music provider"})
return return
} }
@@ -554,6 +565,14 @@ func (s *Server) apiUpdateSettings(w http.ResponseWriter, r *http.Request) {
} }
out[store.SettingPreviewCount] = strconv.Itoa(int(n)) 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 { if v, ok := in["showNotes"]; ok {
b, ok := v.(bool) b, ok := v.(bool)
if !ok { 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"` Source string `json:"source"`
Description string `json:"description"` Description string `json:"description"`
Note string `json:"note"` 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"` Meta []MetaKV `json:"meta"`
} }
@@ -52,7 +53,7 @@ var extraFields = []struct{ Key, Label string }{
{"label", "Label"}, {"label", "Label"},
} }
func itemView(it store.Item, showNotes bool) ItemView { func itemView(it store.Item, showNotes, previews bool) ItemView {
v := ItemView{ v := ItemView{
ID: it.ID, ID: it.ID,
Rank: it.Rank, Rank: it.Rank,
@@ -71,6 +72,7 @@ func itemView(it store.Item, showNotes bool) ItemView {
if showNotes { if showNotes {
v.Note = it.Note v.Note = it.Note
} }
v.Preview = previews && deezerRef(it) != ""
for _, f := range extraFields { for _, f := range extraFields {
if val := it.Extra[f.Key]; val != "" { if val := it.Extra[f.Key]; val != "" {
v.Meta = append(v.Meta, MetaKV{f.Label, 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 := listSection{List: l, More: l.ItemCount - len(items)}
sec.Category = listCategory(l) sec.Category = listCategory(l)
for _, it := range items { 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.Sections = append(page.Sections, sec)
page.TotalItems += l.ItemCount page.TotalItems += l.ItemCount
@@ -188,7 +190,7 @@ func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
return return
} }
for _, it := range items { 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 != "" { if len(p.Items) > 0 && p.Items[0].Rank == 1 && p.Items[0].Image != "" {
p.Hero = &p.Items[0] p.Hero = &p.Items[0]
+2
View File
@@ -26,6 +26,7 @@ type Site struct {
CSSVersion string CSSVersion string
PreviewCount int PreviewCount int
ShowNotes bool ShowNotes bool
ShowPreviews bool
} }
type pageData struct { type pageData struct {
@@ -51,6 +52,7 @@ func (s *Server) site(ctx context.Context) Site {
Theme: set[store.SettingTheme], Theme: set[store.SettingTheme],
PreviewCount: 10, PreviewCount: 10,
ShowNotes: set[store.SettingShowNotes] != "false", ShowNotes: set[store.SettingShowNotes] != "false",
ShowPreviews: set[store.SettingShowPreviews] != "false",
} }
if site.Title == "" { if site.Title == "" {
site.Title = DefaultTitle(site.OwnerName) site.Title = DefaultTitle(site.OwnerName)
+7
View File
@@ -43,6 +43,9 @@ type Server struct {
assetVer string assetVer string
loginLimit *auth.Limiter loginLimit *auth.Limiter
setupCode string 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) { 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, log: log,
assetVer: version, assetVer: version,
loginLimit: auth.NewLimiter(10, 15*time.Minute), 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) s.providers = providers.NewRegistry(s.credentials)
static, err := fs.Sub(web.FS, "static") 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 /{$}", s.handleHome)
mux.HandleFunc("GET /l/{slug}", s.handleList) mux.HandleFunc("GET /l/{slug}", s.handleList)
mux.HandleFunc("GET /l/{slug}/export", s.handleExport) 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 /images/{name}", s.handleImage)
mux.HandleFunc("GET /custom.css", s.handleCustomCSS) mux.HandleFunc("GET /custom.css", s.handleCustomCSS)
mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler())) 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", s.requireUser(s.handleAdminLists))
mux.HandleFunc("GET /admin/lists/{id}", s.requireUser(s.handleAdminList)) mux.HandleFunc("GET /admin/lists/{id}", s.requireUser(s.handleAdminList))
mux.HandleFunc("GET /admin/settings", s.requireUser(s.handleAdminSettings)) mux.HandleFunc("GET /admin/settings", s.requireUser(s.handleAdminSettings))
mux.HandleFunc("GET /admin/preview", s.requireUser(s.handleAdminPreview))
// Admin JSON API // Admin JSON API
api := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, s.requireAPI(h)) } api := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, s.requireAPI(h)) }
+1
View File
@@ -17,6 +17,7 @@ const (
SettingCustomCSS = "custom_css" SettingCustomCSS = "custom_css"
SettingPreviewCount = "preview_count" // items shown per list on the home page SettingPreviewCount = "preview_count" // items shown per list on the home page
SettingShowNotes = "show_notes" SettingShowNotes = "show_notes"
SettingShowPreviews = "show_previews"
SettingMusicProvider = "music_provider" SettingMusicProvider = "music_provider"
SettingTVProvider = "tv_provider" SettingTVProvider = "tv_provider"
+40
View File
@@ -210,6 +210,43 @@ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; bac
.item-tags .badge { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; display: inline-block; } .item-tags .badge { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; display: inline-block; }
.item-year { color: var(--muted); font-size: 12.5px; font-variant-numeric: tabular-nums; flex: none; } .item-year { color: var(--muted); font-size: 12.5px; font-variant-numeric: tabular-nums; flex: none; }
/* ---------- preview play buttons ---------- */
.item { position: relative; }
.play-btn {
position: absolute; top: 8px; right: 8px; z-index: 2;
display: grid; place-items: center; width: 36px; height: 36px; padding: 0;
border: 0; border-radius: 50%; cursor: pointer;
background: rgba(10, 10, 14, 0.72); color: #fff;
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: background 0.15s, transform 0.15s;
}
.play-btn:hover { background: var(--accent); color: var(--accent-text); transform: scale(1.06); }
.play-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
.play-btn .icon { width: 15px; height: 15px; fill: currentColor; stroke: none; }
.play-btn .icon:first-child { margin-left: 2px; }
.play-btn .icon:last-child, .play-btn.is-playing .icon:first-child { display: none; }
.play-btn.is-playing .icon:last-child { display: block; }
.play-btn.is-playing, .play-btn.is-loading { background: var(--accent); color: var(--accent-text); }
/* Progress ring around the button while a clip plays. */
.play-btn::before {
content: ""; position: absolute; inset: -3px; border-radius: 50%; pointer-events: none;
background: conic-gradient(var(--accent) calc(var(--p, 0) * 1%), transparent 0);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2.5px));
mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2.5px));
opacity: 0; transition: opacity 0.2s;
}
.play-btn.is-playing::before { opacity: 1; }
.play-btn.is-loading .icon:first-child { animation: pulse 0.9s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: 0.35; } }
.play-btn--inline, .play-btn--mini { position: relative; top: auto; right: auto; flex: none; box-shadow: none; }
.play-btn--inline { width: 32px; height: 32px; }
.play-btn--inline:not(.is-playing):not(.is-loading):not(:hover) { background: var(--surface-2); color: var(--text); }
.play-btn--mini { position: absolute; right: 3px; bottom: 3px; width: 24px; height: 24px; }
.play-btn--mini .icon { width: 11px; height: 11px; }
.result-art { position: relative; }
.result-source { display: inline-block; margin-top: 3px; font-size: 10.5px; font-weight: 650; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); }
/* ---------- ranked items: rows ---------- */ /* ---------- ranked items: rows ---------- */
.items--rows { display: flex; flex-direction: column; border-top: 1px solid var(--border); } .items--rows { display: flex; flex-direction: column; border-top: 1px solid var(--border); }
.items--rows .item { border-bottom: 1px solid var(--border); } .items--rows .item { border-bottom: 1px solid var(--border); }
@@ -230,6 +267,9 @@ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; bac
.items--rows .item--top3 .item-rank { color: var(--bronze); } .items--rows .item--top3 .item-rank { color: var(--bronze); }
.items--rows .item-body { order: 3; flex: 1; } .items--rows .item-body { order: 3; flex: 1; }
.items--rows .item-title { font-size: 16px; -webkit-line-clamp: 1; } .items--rows .item-title { font-size: 16px; -webkit-line-clamp: 1; }
.items--rows .play-btn { top: 50%; right: 10px; transform: translateY(-50%); }
.items--rows .play-btn:hover { transform: translateY(-50%) scale(1.06); }
.items--rows .item:has(.play-btn) .item-btn { padding-right: 60px; }
.items--rows .item-note { color: var(--muted); font-size: 13.5px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 2px; } .items--rows .item-note { color: var(--muted); font-size: 13.5px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 2px; }
@media (min-width: 900px) { @media (min-width: 900px) {
.page-home .items--rows { display: grid; grid-template-columns: 1fr 1fr; column-gap: 32px; border-top: 0; } .page-home .items--rows { display: grid; grid-template-columns: 1fr 1fr; column-gap: 32px; border-top: 0; }
+2
View File
@@ -7,6 +7,8 @@
<symbol id="gamepad" viewBox="0 0 24 24"><path d="M6 12h4M8 10v4M15 13h.01M18 11h.01"/><rect width="20" height="12" x="2" y="6" rx="4"/></symbol> <symbol id="gamepad" viewBox="0 0 24 24"><path d="M6 12h4M8 10v4M15 13h.01M18 11h.01"/><rect width="20" height="12" x="2" y="6" rx="4"/></symbol>
<symbol id="masks" viewBox="0 0 24 24"><path d="M2 4.5c0-.8.6-1.4 1.4-1.3C5 3.4 7 3.4 8.6 3.1c.8-.1 1.4.5 1.4 1.3V9c0 3.3-1.8 6-4 6s-4-2.7-4-6z"/><path d="M4.5 7.5h.01M7.5 7.5h.01M4.8 11.2c.7-.5 1.7-.5 2.4 0"/><path d="M14 9.6c0-.8.6-1.4 1.4-1.3 1.6.3 3.6.3 5.2 0 .8-.1 1.4.5 1.4 1.3V14c0 3.3-1.8 6-4 6s-4-2.7-4-6z"/><path d="M16.5 12.5h.01M19.5 12.5h.01M16.8 15.6c.7.5 1.7.5 2.4 0"/><path d="M10 12.5c.9.7 2.3.8 4 .3"/></symbol> <symbol id="masks" viewBox="0 0 24 24"><path d="M2 4.5c0-.8.6-1.4 1.4-1.3C5 3.4 7 3.4 8.6 3.1c.8-.1 1.4.5 1.4 1.3V9c0 3.3-1.8 6-4 6s-4-2.7-4-6z"/><path d="M4.5 7.5h.01M7.5 7.5h.01M4.8 11.2c.7-.5 1.7-.5 2.4 0"/><path d="M14 9.6c0-.8.6-1.4 1.4-1.3 1.6.3 3.6.3 5.2 0 .8-.1 1.4.5 1.4 1.3V14c0 3.3-1.8 6-4 6s-4-2.7-4-6z"/><path d="M16.5 12.5h.01M19.5 12.5h.01M16.8 15.6c.7.5 1.7.5 2.4 0"/><path d="M10 12.5c.9.7 2.3.8 4 .3"/></symbol>
<symbol id="star" viewBox="0 0 24 24"><path d="M11.5 2.9a.5.5 0 0 1 1 0l2.3 4.7a2 2 0 0 0 1.5 1.1l5.2.8a.5.5 0 0 1 .3.9l-3.8 3.6a2 2 0 0 0-.6 1.8l.9 5.2a.5.5 0 0 1-.8.5l-4.6-2.4a2 2 0 0 0-1.9 0l-4.6 2.4a.5.5 0 0 1-.8-.5l.9-5.2a2 2 0 0 0-.6-1.8L2.3 10.4a.5.5 0 0 1 .3-.9l5.2-.8a2 2 0 0 0 1.5-1.1z"/></symbol> <symbol id="star" viewBox="0 0 24 24"><path d="M11.5 2.9a.5.5 0 0 1 1 0l2.3 4.7a2 2 0 0 0 1.5 1.1l5.2.8a.5.5 0 0 1 .3.9l-3.8 3.6a2 2 0 0 0-.6 1.8l.9 5.2a.5.5 0 0 1-.8.5l-4.6-2.4a2 2 0 0 0-1.9 0l-4.6 2.4a.5.5 0 0 1-.8-.5l.9-5.2a2 2 0 0 0-.6-1.8L2.3 10.4a.5.5 0 0 1 .3-.9l5.2-.8a2 2 0 0 0 1.5-1.1z"/></symbol>
<symbol id="play" viewBox="0 0 24 24"><path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.3-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z"/></symbol>
<symbol id="pause" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></symbol>
<symbol id="grip" viewBox="0 0 24 24"><circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/></symbol> <symbol id="grip" viewBox="0 0 24 24"><circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/></symbol>
<symbol id="pencil" viewBox="0 0 24 24"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></symbol> <symbol id="pencil" viewBox="0 0 24 24"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></symbol>
<symbol id="trash" viewBox="0 0 24 24"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/></symbol> <symbol id="trash" viewBox="0 0 24 24"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/></symbol>

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

+12 -1
View File
@@ -2,6 +2,7 @@
import { h, icon, api, readBoot, toast, confirmDialog, openDialog, dialogHead, debounce, busy, fmt } from "./common.js"; import { h, icon, api, readBoot, toast, confirmDialog, openDialog, dialogHead, debounce, busy, fmt } from "./common.js";
import { sortable } from "./sortable.js"; import { sortable } from "./sortable.js";
import { openListForm } from "./list-form.js"; import { openListForm } from "./list-form.js";
import { previewButton } from "./player.js";
const boot = readBoot(); const boot = readBoot();
const { categories, limits, productions } = boot; const { categories, limits, productions } = boot;
@@ -119,6 +120,7 @@ function row(it) {
), ),
), ),
h("div", { class: "rank-row-actions" }, h("div", { class: "rank-row-actions" },
canPreview(it) && previewButton(`/preview/${it.id}`, `Play a preview of ${it.title}`, "play-btn play-btn--inline hide-sm"),
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move up", title: "Move up", disabled: it.rank === 1, onclick: () => moveTo(it, it.rank - 1) }, icon("chevron-up")), h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move up", title: "Move up", disabled: it.rank === 1, onclick: () => moveTo(it, it.rank - 1) }, icon("chevron-up")),
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move down", title: "Move down", disabled: it.rank === list.itemCount, onclick: () => moveTo(it, it.rank + 1) }, icon("chevron-down")), h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move down", title: "Move down", disabled: it.rank === list.itemCount, onclick: () => moveTo(it, it.rank + 1) }, icon("chevron-down")),
h("button", { type: "button", class: "icon-btn", "aria-label": `Edit ${it.title}`, title: "Edit", onclick: () => openItemForm({ item: it }) }, icon("pencil")), h("button", { type: "button", class: "icon-btn", "aria-label": `Edit ${it.title}`, title: "Edit", onclick: () => openItemForm({ item: it }) }, icon("pencil")),
@@ -127,6 +129,11 @@ function row(it) {
); );
} }
// Songs and albums from Deezer, or matched to Deezer when saved, can play.
function canPreview(it) {
return (cat.key === "song" || cat.key === "album") && (it.provider === "deezer" || !!it.extra?.deezer);
}
function renderItems(flashId) { function renderItems(flashId) {
if (!items.length) { if (!items.length) {
rankList.replaceChildren(h("li", { class: "rank-empty" }, rankList.replaceChildren(h("li", { class: "rank-empty" },
@@ -259,6 +266,7 @@ function renderResults(res) {
const full = isFull(); const full = isFull();
const nodes = res.map((r) => { const nodes = res.map((r) => {
const sub = [r.subtitle, r.year, r.extra?.type].filter(Boolean).join(" · "); const sub = [r.subtitle, r.year, r.extra?.type].filter(Boolean).join(" · ");
const source = { deezer: "Deezer", musicbrainz: "MusicBrainz" }[r.provider];
const action = r.existingRank const action = r.existingRank
? h("span", { class: "result-added" }, icon("check"), `#${r.existingRank}`) ? h("span", { class: "result-added" }, icon("check"), `#${r.existingRank}`)
: h("button", { : h("button", {
@@ -266,11 +274,14 @@ function renderResults(res) {
title: full ? "This list is full" : `Add to #${list.itemCount + 1}`, title: full ? "This list is full" : `Add to #${list.itemCount + 1}`,
onclick: (e) => addResult(r, e.currentTarget), onclick: (e) => addResult(r, e.currentTarget),
}, icon("plus"), "Add"); }, icon("plus"), "Add");
const art = artBox(r.imageUrl, "");
if (r.previewRef) art.append(previewButton(`/admin/preview?ref=${encodeURIComponent(r.previewRef)}`, `Play a preview of ${r.title}`, "play-btn play-btn--mini"));
return h("div", { class: "result", "data-key": resultKey(r) }, return h("div", { class: "result", "data-key": resultKey(r) },
artBox(r.imageUrl, ""), art,
h("div", { style: { minWidth: 0 } }, h("div", { style: { minWidth: 0 } },
h("div", { class: "result-title" }, r.title), h("div", { class: "result-title" }, r.title),
sub && h("div", { class: "result-sub" }, sub), sub && h("div", { class: "result-sub" }, sub),
source && h("span", { class: "result-source" }, source),
(r.overview || r.hint) && h("div", { class: "result-overview" }, r.overview || r.hint), (r.overview || r.hint) && h("div", { class: "result-overview" }, r.overview || r.hint),
), ),
action, action,
+5 -3
View File
@@ -77,6 +77,7 @@ function profileSection() {
const tagline = h("input", { maxlength: 300, value: settings.tagline, placeholder: "A line about you or your taste" }); const tagline = h("input", { maxlength: 300, value: settings.tagline, placeholder: "A line about you or your taste" });
const preview = h("input", { type: "number", min: 0, max: 50, value: settings.previewCount, style: { maxWidth: "120px" } }); const preview = h("input", { type: "number", min: 0, max: 50, value: settings.previewCount, style: { maxWidth: "120px" } });
const notes = h("input", { type: "checkbox", checked: settings.showNotes }); const notes = h("input", { type: "checkbox", checked: settings.showNotes });
const previews = h("input", { type: "checkbox", checked: settings.showPreviews });
const save = h("button", { type: "submit", class: "btn btn--primary" }, "Save profile"); const save = h("button", { type: "submit", class: "btn btn--primary" }, "Save profile");
const form = h("form", { class: "stack" }, const form = h("form", { class: "stack" },
@@ -85,13 +86,14 @@ function profileSection() {
field("Tagline", tagline), field("Tagline", tagline),
field("Entries per list on the home page", preview, "Visitors see the full list on each list's page. 0 shows only the heading."), field("Entries per list on the home page", preview, "Visitors see the full list on each list's page. 0 shows only the heading."),
h("label", { class: "check" }, notes, "Show why each entry is ranked where it is"), h("label", { class: "check" }, notes, "Show why each entry is ranked where it is"),
h("label", { class: "check" }, previews, "Play 30-second previews for songs and albums (from Deezer)"),
h("div", { class: "settings-actions" }, save), h("div", { class: "settings-actions" }, save),
); );
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const ok = await saveSettings({ const ok = await saveSettings({
ownerName: ownerName.value, siteTitle: siteTitle.value, tagline: tagline.value, ownerName: ownerName.value, siteTitle: siteTitle.value, tagline: tagline.value,
previewCount: Number(preview.value), showNotes: notes.checked, previewCount: Number(preview.value), showNotes: notes.checked, showPreviews: previews.checked,
}, save, "Profile saved"); }, save, "Profile saved");
if (ok) { if (ok) {
renderAvatar(); renderAvatar();
@@ -304,8 +306,8 @@ function integrationsSection() {
}), }),
integration({ integration({
title: "Music · Songs & Albums", iconName: "disc", category: "album", title: "Music · Songs & Albums", iconName: "disc", category: "album",
desc: "No key needed. Deezer has the best cover art and fast search; MusicBrainz is fully open data but slower (1 request/second).", desc: "No key needed. Searching both finds the most: Deezer has the best art and preview clips, and MusicBrainz covers albums Deezer's search misses plus game and film soundtracks.",
extra: [providerChoice("musicProvider", [["deezer", "Deezer (recommended)"], ["musicbrainz", "MusicBrainz + Cover Art Archive"]])], extra: [providerChoice("musicProvider", [["both", "Deezer + MusicBrainz (recommended)"], ["deezer", "Deezer only"], ["musicbrainz", "MusicBrainz only"]])],
ready: () => true, ready: () => true,
}), }),
integration({ integration({
+87
View File
@@ -0,0 +1,87 @@
// One shared <audio> for 30-second previews. Any element with
// data-preview-src toggles playback; every button for the same clip shows
// the same state (a card and its detail view, for example).
import { icon, toast } from "./common.js";
const audio = new Audio();
audio.preload = "none";
let currentSrc = null;
function buttons(src) {
return [...document.querySelectorAll("[data-preview-src]")].filter((b) => b.dataset.previewSrc === src);
}
function paintButton(b, state, progress = 0) {
b.classList.toggle("is-playing", state === "playing");
b.classList.toggle("is-loading", state === "loading");
b.setAttribute("aria-pressed", String(state === "playing" || state === "loading"));
b.style.setProperty("--p", String(progress));
}
function paint(src, state, progress = 0) {
for (const b of buttons(src)) paintButton(b, state, progress);
}
function stop() {
if (!currentSrc) return;
audio.pause();
paint(currentSrc, "idle");
currentSrc = null;
}
export function togglePreview(src) {
if (currentSrc === src) {
if (audio.paused) {
audio.play();
} else {
stop();
}
return;
}
stop();
currentSrc = src;
paint(src, "loading");
audio.src = src;
audio.play().catch(() => { /* reported by the error handler */ });
}
audio.addEventListener("playing", () => currentSrc && paint(currentSrc, "playing", progress()));
audio.addEventListener("timeupdate", () => currentSrc && paint(currentSrc, audio.paused ? "idle" : "playing", progress()));
audio.addEventListener("ended", stop);
audio.addEventListener("error", () => {
if (!currentSrc) return;
for (const b of buttons(currentSrc)) {
b.disabled = true;
b.title = "No preview available";
}
paint(currentSrc, "idle");
currentSrc = null;
toast("No preview available for this one.", { error: true });
});
function progress() {
return audio.duration ? Math.round((audio.currentTime / audio.duration) * 100) : 0;
}
/** A round play/pause button for a clip. */
export function previewButton(src, label, className = "play-btn") {
const b = document.createElement("button");
b.type = "button";
b.className = className;
b.dataset.previewSrc = src;
b.setAttribute("aria-label", label);
b.setAttribute("aria-pressed", "false");
b.title = "Play 30-second preview";
b.append(icon("play"), icon("pause"));
// Not in the page yet, so paint this button directly.
if (src === currentSrc) paintButton(b, audio.paused ? "loading" : "playing", progress());
return b;
}
document.addEventListener("click", (e) => {
const b = e.target.closest("[data-preview-src]");
if (!b || b.disabled) return;
e.preventDefault();
e.stopPropagation();
togglePreview(b.dataset.previewSrc);
});
+2
View File
@@ -1,5 +1,6 @@
// Public pages: item detail dialog and grid/list toggle. // Public pages: item detail dialog and grid/list toggle.
import { h, icon, openDialog, storageGet, storageSet } from "./common.js"; import { h, icon, openDialog, storageGet, storageSet } from "./common.js";
import { previewButton } from "./player.js";
// Each ranked list on the page ships its items as JSON next to the markup. // Each ranked list on the page ships its items as JSON next to the markup.
const lists = [...document.querySelectorAll("[data-items]")].map((ol) => { const lists = [...document.querySelectorAll("[data-items]")].map((ol) => {
@@ -45,6 +46,7 @@ function renderDialog(list, idx) {
h("h3", {}, "About"), h("h3", {}, "About"),
h("p", { class: "dlg-desc" }, it.description)), h("p", { class: "dlg-desc" }, it.description)),
h("div", { class: "dlg-actions" }, h("div", { class: "dlg-actions" },
it.preview && previewButton(`/preview/${it.id}`, `Play a preview of ${it.title}`, "play-btn play-btn--inline"),
it.link && h("a", { class: "btn btn--sm", href: it.link, target: "_blank", rel: "noopener noreferrer" }, it.link && h("a", { class: "btn btn--sm", href: it.link, target: "_blank", rel: "noopener noreferrer" },
icon("external"), it.source ? `View on ${it.source}` : "Learn more"), icon("external"), it.source ? `View on ${it.source}` : "Learn more"),
h("div", { class: "dlg-nav" }, prev, next), h("div", { class: "dlg-nav" }, prev, next),
+3
View File
@@ -19,6 +19,9 @@
{{- with .Note}}<span class="item-note">{{.}}</span>{{end}} {{- with .Note}}<span class="item-note">{{.}}</span>{{end}}
</span> </span>
</button> </button>
{{- if .Preview}}
<button type="button" class="play-btn" data-preview-src="/preview/{{.ID}}" aria-pressed="false" aria-label="Play a preview of {{.Title}}" title="Play 30-second preview">{{icon "play"}}{{icon "pause"}}</button>
{{- end}}
</li> </li>
{{- end}} {{- end}}
</ol> </ol>