From 84c5d711c637b13fb3cac195b1fe9054e71e2ca6 Mon Sep 17 00:00:00 2001
From: dammitjeff <44111923+dammitjeff@users.noreply.github.com>
Date: Thu, 14 May 2026 16:50:40 -0700
Subject: [PATCH 01/12] add support for custom playlists, added
fetchLBPlaylistByMBID, custom_playlists.go for CRUD and JSON storage,
RegisterCustomPlaylistRefresh for custom refreshes, and isValidPlaylistID
for custom IDs
---
.gitignore | 3 +-
src/config/flags.go | 6 +-
src/main/main.go | 73 ++++-
src/web/backend/custom_playlists.go | 289 ++++++++++++++++++
src/web/backend/jobs.go | 35 +++
src/web/backend/playlists.go | 85 ++++--
src/web/backend/server.go | 11 +-
src/web/frontend/src/components/Settings.jsx | 131 ++++++--
.../src/components/ui/ImportModal.jsx | 274 +++++++++++++++++
.../src/components/ui/PlaylistCard.jsx | 131 ++++++--
src/web/frontend/src/lib/api.js | 28 ++
11 files changed, 980 insertions(+), 86 deletions(-)
create mode 100644 src/web/backend/custom_playlists.go
create mode 100644 src/web/frontend/src/components/ui/ImportModal.jsx
diff --git a/.gitignore b/.gitignore
index d62515be..ab0656c9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,5 @@ logs/
explo
src/web/dist/
src/web/frontend/node_modules/
-/cache
\ No newline at end of file
+/cache
+/data/
\ No newline at end of file
diff --git a/src/config/flags.go b/src/config/flags.go
index 890f1993..64b17e79 100644
--- a/src/config/flags.go
+++ b/src/config/flags.go
@@ -38,9 +38,9 @@ func (cfg *Config) GetFlags() error {
persistSet := flag.Lookup("persist").Changed
cfgSet := flag.Lookup("config").Changed
- // Validation for playlist
- if !contains(validPlaylists, playlist) {
- return fmt.Errorf("flag validation error: invalid playlist %s (must be one of: %s)",
+ // Validation for playlist — built-in types or user-imported custom-* IDs
+ if !contains(validPlaylists, playlist) && !strings.HasPrefix(playlist, "custom-") {
+ return fmt.Errorf("flag validation error: invalid playlist %s (must be one of: %s, or a custom-* id)",
playlist, strings.Join(validPlaylists, ", "))
}
diff --git a/src/main/main.go b/src/main/main.go
index 4343bcfc..fb330f69 100644
--- a/src/main/main.go
+++ b/src/main/main.go
@@ -1,12 +1,16 @@
package main
import (
+ "encoding/json"
"explo/src/logging"
"explo/src/models"
"explo/src/web/backend"
+ "fmt"
"log"
"log/slog"
"os"
+ "path/filepath"
+ "strings"
"explo/src/client"
"explo/src/config"
@@ -21,6 +25,60 @@ type Song struct {
Album string
}
+// loadCustomTracks reads a custom playlist's track cache and returns them as
+// models.Track slices, bypassing the LB discovery step entirely.
+func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, error) {
+ type cachedTrack struct {
+ Title string `json:"title"`
+ Artist string `json:"artist"`
+ Release string `json:"release"`
+ CoverURL string `json:"coverUrl"`
+ }
+ type cacheFile struct {
+ Tracks []cachedTrack `json:"tracks"`
+ }
+ type customPlaylist struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ }
+
+ data, err := os.ReadFile(filepath.Join(dataDir, "cache", playlistID+".json"))
+ if err != nil {
+ return nil, "", fmt.Errorf("custom playlist %q not found in cache: %w", playlistID, err)
+ }
+ var c cacheFile
+ if err := json.Unmarshal(data, &c); err != nil {
+ return nil, "", fmt.Errorf("failed to parse custom playlist cache: %w", err)
+ }
+
+ // Look up the human-readable name from metadata
+ name := playlistID
+ if meta, err := os.ReadFile(filepath.Join(dataDir, "custom-playlists.json")); err == nil {
+ var all []customPlaylist
+ if json.Unmarshal(meta, &all) == nil {
+ for _, p := range all {
+ if p.ID == playlistID {
+ name = p.Name
+ break
+ }
+ }
+ }
+ }
+
+ tracks := make([]*models.Track, len(c.Tracks))
+ for i, t := range c.Tracks {
+ tracks[i] = &models.Track{
+ CleanTitle: t.Title,
+ Title: t.Title,
+ Artist: t.Artist,
+ MainArtist: t.Artist,
+ Album: t.Release,
+ CoverURL: t.CoverURL,
+ }
+ }
+ return tracks, name, nil
+}
+
func initHttpClient() *util.HttpClient {
return util.NewHttp(util.HttpClientConfig{
Timeout: 10,
@@ -57,8 +115,19 @@ func main() {
log.Fatal(srv.Start())
}
httpClient := initHttpClient()
- discovery := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient)
- tracks, err := discovery.Discover()
+
+ var tracks []*models.Track
+ var err error
+ if strings.HasPrefix(cfg.Flags.Playlist, "custom-") {
+ var playlistName string
+ tracks, playlistName, err = loadCustomTracks(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist)
+ if err == nil {
+ cfg.ClientCfg.PlaylistName = playlistName
+ }
+ } else {
+ disc := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient)
+ tracks, err = disc.Discover()
+ }
if err != nil {
slog.Error(err.Error(), "notify", true)
os.Exit(1)
diff --git a/src/web/backend/custom_playlists.go b/src/web/backend/custom_playlists.go
new file mode 100644
index 00000000..7bfa0c0b
--- /dev/null
+++ b/src/web/backend/custom_playlists.go
@@ -0,0 +1,289 @@
+package backend
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "math/rand/v2"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// CustomPlaylist holds the metadata for a user-imported ListenBrainz playlist.
+type CustomPlaylist struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ LBMBID string `json:"lb_mbid"`
+ RefreshDays int `json:"refresh_days"`
+ ColorIndex int `json:"color_index"`
+ LastFetched time.Time `json:"last_fetched"`
+}
+
+var lbMBIDRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
+
+// extractLBMBID pulls the playlist UUID out of a ListenBrainz playlist URL or bare MBID string.
+func extractLBMBID(raw string) (string, error) {
+ raw = strings.TrimSpace(raw)
+ m := lbMBIDRe.FindString(raw)
+ if m == "" {
+ return "", fmt.Errorf("no ListenBrainz playlist UUID found in %q", raw)
+ }
+ return m, nil
+}
+
+func customPlaylistsPath(cfgDir string) string {
+ return filepath.Join(cfgDir, "custom-playlists.json")
+}
+
+func loadCustomPlaylists(cfgDir string) []CustomPlaylist {
+ data, err := os.ReadFile(customPlaylistsPath(cfgDir))
+ if err != nil {
+ return nil
+ }
+ var out []CustomPlaylist
+ if err := json.Unmarshal(data, &out); err != nil {
+ slog.Warn("custom-playlists: failed to parse metadata", "err", err)
+ return nil
+ }
+ return out
+}
+
+func saveCustomPlaylists(cfgDir string, playlists []CustomPlaylist) error {
+ raw, err := json.MarshalIndent(playlists, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(customPlaylistsPath(cfgDir), raw, 0644)
+}
+
+// handleGetCustomPlaylists returns all saved custom playlists with a track_count
+// derived from their cache file (if present).
+func (s *Server) handleGetCustomPlaylists(w http.ResponseWriter, r *http.Request) {
+ playlists := loadCustomPlaylists(s.cfg.WebDataDir)
+
+ type respItem struct {
+ CustomPlaylist
+ TrackCount int `json:"track_count"`
+ }
+ items := make([]respItem, 0, len(playlists))
+ for _, p := range playlists {
+ count := customPlaylistTrackCount(s.cfg.WebDataDir, p.ID)
+ items = append(items, respItem{CustomPlaylist: p, TrackCount: count})
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(items); err != nil {
+ slog.Error("custom-playlists: failed to write response", "err", err)
+ }
+}
+
+// handleImportCustomPlaylist imports a ListenBrainz playlist by URL, writes a cache,
+// and returns the playlist name/tracks to the frontend for the import animation.
+func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ LBURL string `json:"lb_url"`
+ RefreshDays int `json:"refresh_days"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ mbid, err := extractLBMBID(body.LBURL)
+ if err != nil {
+ slog.Warn("custom-playlists: invalid URL", "url", body.LBURL, "err", err)
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ slog.Info("custom-playlists: import request", "mbid", mbid, "refresh_days", body.RefreshDays)
+
+ // Reject duplicates
+ existing := loadCustomPlaylists(s.cfg.WebDataDir)
+ for _, p := range existing {
+ if p.LBMBID == mbid {
+ slog.Warn("custom-playlists: duplicate import rejected", "mbid", mbid, "existing_id", p.ID)
+ http.Error(w, "playlist already imported", http.StatusConflict)
+ return
+ }
+ }
+
+ // Fetch playlist data from ListenBrainz
+ slog.Info("custom-playlists: fetching from LB", "mbid", mbid)
+ name, tracks, err := fetchLBPlaylistByMBID(mbid)
+ if err != nil {
+ slog.Error("custom-playlists: LB fetch failed", "mbid", mbid, "err", err)
+ http.Error(w, "failed to fetch playlist: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ if name == "" {
+ name = "Imported Playlist"
+ }
+ slog.Info("custom-playlists: fetched", "name", name, "tracks", len(tracks))
+
+ // Ensure data directories exist before writing anything
+ if err := os.MkdirAll(filepath.Join(s.cfg.WebDataDir, "cache"), 0755); err != nil {
+ slog.Error("custom-playlists: failed to create data dir", "err", err)
+ http.Error(w, "server data directory unavailable: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Generate a short unique ID
+ id := fmt.Sprintf("custom-%x", rand.Uint32())
+
+ // Write cache with remote cover URLs synchronously so the response is fast,
+ // then download local copies of cover art in the background.
+ slog.Info("custom-playlists: writing cache", "id", id)
+ if !writePreliminaryCache(s.cfg.WebDataDir, id, tracks) {
+ http.Error(w, "failed to write playlist cache", http.StatusInternalServerError)
+ return
+ }
+ go downloadAndCacheCovers(s.cfg.WebDataDir, id, tracks)
+
+ // Save metadata
+ cp := CustomPlaylist{
+ ID: id,
+ Name: name,
+ LBMBID: mbid,
+ RefreshDays: body.RefreshDays,
+ ColorIndex: len(existing),
+ LastFetched: time.Now().UTC(),
+ }
+ existing = append(existing, cp)
+ if err := saveCustomPlaylists(s.cfg.WebDataDir, existing); err != nil {
+ slog.Error("custom-playlists: failed to save metadata", "err", err)
+ http.Error(w, "failed to save playlist metadata: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ slog.Info("custom-playlists: import complete", "id", id, "name", name)
+
+ // Collect up to 6 remote cover URLs for the import animation
+ covers := make([]string, 0, 6)
+ for _, t := range tracks {
+ if t[3] != "" {
+ covers = append(covers, t[3])
+ }
+ if len(covers) >= 6 {
+ break
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(map[string]any{
+ "id": id,
+ "name": name,
+ "track_count": len(tracks),
+ "cover_urls": covers,
+ "color_index": cp.ColorIndex,
+ }); err != nil {
+ slog.Error("custom-playlists: failed to write import response", "err", err)
+ }
+}
+
+// handleRefreshCustomPlaylist re-fetches a custom playlist from ListenBrainz and updates the cache.
+// Equivalent to manually triggering the nightly refresh cron job for a single playlist.
+func (s *Server) handleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if !customIDRe.MatchString(id) {
+ http.Error(w, "invalid playlist id", http.StatusBadRequest)
+ return
+ }
+
+ playlists := loadCustomPlaylists(s.cfg.WebDataDir)
+ idx := -1
+ for i, p := range playlists {
+ if p.ID == id {
+ idx = i
+ break
+ }
+ }
+ if idx == -1 {
+ http.Error(w, "playlist not found", http.StatusNotFound)
+ return
+ }
+
+ p := playlists[idx]
+ slog.Info("custom-playlists: manual refresh", "id", id, "mbid", p.LBMBID)
+
+ _, tracks, err := fetchLBPlaylistByMBID(p.LBMBID)
+ if err != nil {
+ slog.Error("custom-playlists: refresh fetch failed", "id", id, "err", err)
+ http.Error(w, "failed to fetch playlist: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+
+ if !writePreliminaryCache(s.cfg.WebDataDir, id, tracks) {
+ http.Error(w, "failed to write playlist cache", http.StatusInternalServerError)
+ return
+ }
+ go downloadAndCacheCovers(s.cfg.WebDataDir, id, tracks)
+
+ playlists[idx].LastFetched = time.Now().UTC()
+ if err := saveCustomPlaylists(s.cfg.WebDataDir, playlists); err != nil {
+ slog.Warn("custom-playlists: failed to update last_fetched after refresh", "err", err)
+ }
+
+ slog.Info("custom-playlists: refresh complete", "id", id, "tracks", len(tracks))
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(map[string]any{"track_count": len(tracks)}); err != nil {
+ slog.Error("custom-playlists: failed to write refresh response", "err", err)
+ }
+}
+
+// handleDeleteCustomPlaylist removes a custom playlist's metadata and cache file.
+func (s *Server) handleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if !customIDRe.MatchString(id) {
+ slog.Warn("custom-playlists: invalid id in delete request", "id", id)
+ http.Error(w, "invalid playlist id", http.StatusBadRequest)
+ return
+ }
+ slog.Info("custom-playlists: delete request", "id", id)
+
+ existing := loadCustomPlaylists(s.cfg.WebDataDir)
+ filtered := existing[:0]
+ found := false
+ for _, p := range existing {
+ if p.ID == id {
+ found = true
+ } else {
+ filtered = append(filtered, p)
+ }
+ }
+ if !found {
+ http.Error(w, "playlist not found", http.StatusNotFound)
+ return
+ }
+
+ if err := saveCustomPlaylists(s.cfg.WebDataDir, filtered); err != nil {
+ http.Error(w, "failed to save: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Remove the cache file; ignore error if already gone
+ cachePath := filepath.Join(s.cfg.WebDataDir, "cache", id+".json")
+ _ = os.Remove(cachePath)
+
+ slog.Info("custom-playlists: deleted", "id", id)
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// customPlaylistTrackCount reads the cached track count for a custom playlist without
+// fully parsing the JSON.
+func customPlaylistTrackCount(cfgDir, id string) int {
+ type mini struct {
+ Tracks []json.RawMessage `json:"tracks"`
+ }
+ data, err := os.ReadFile(filepath.Join(cfgDir, "cache", id+".json"))
+ if err != nil {
+ return 0
+ }
+ var m mini
+ if err := json.Unmarshal(data, &m); err != nil {
+ return 0
+ }
+ return len(m.Tracks)
+}
diff --git a/src/web/backend/jobs.go b/src/web/backend/jobs.go
index 096b7e29..bb242fce 100644
--- a/src/web/backend/jobs.go
+++ b/src/web/backend/jobs.go
@@ -49,6 +49,41 @@ func (j *Jobs) RegisterCoverCleanup(schedule, coversDir string, maxBytes int64)
return err
}
+// RegisterCustomPlaylistRefresh registers a daily job that re-fetches custom playlists
+// whose refresh interval has elapsed.
+func (j *Jobs) RegisterCustomPlaylistRefresh(cfgDir string) error {
+ _, err := j.scheduler.NewJob(
+ gocron.CronJob("0 4 * * *", false),
+ gocron.NewTask(func() {
+ playlists := loadCustomPlaylists(cfgDir)
+ updated := false
+ for i, p := range playlists {
+ if p.RefreshDays <= 0 {
+ continue
+ }
+ if time.Since(p.LastFetched) < time.Duration(p.RefreshDays)*24*time.Hour {
+ continue
+ }
+ slog.Info("custom-playlists: refreshing", "id", p.ID, "name", p.Name)
+ _, tracks, err := fetchLBPlaylistByMBID(p.LBMBID)
+ if err != nil {
+ slog.Warn("custom-playlists: refresh fetch failed", "id", p.ID, "err", err)
+ continue
+ }
+ writePrefetchCache(cfgDir, p.ID, tracks)
+ playlists[i].LastFetched = time.Now().UTC()
+ updated = true
+ }
+ if updated {
+ if err := saveCustomPlaylists(cfgDir, playlists); err != nil {
+ slog.Error("custom-playlists: failed to save after refresh", "err", err)
+ }
+ }
+ }),
+ )
+ return err
+}
+
func trimCacheDir(dataDir string, maxBytes int64) {
var files []fileInfo
diff --git a/src/web/backend/playlists.go b/src/web/backend/playlists.go
index bea5280c..d1ab95e2 100644
--- a/src/web/backend/playlists.go
+++ b/src/web/backend/playlists.go
@@ -14,6 +14,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "regexp"
"strings"
"time"
)
@@ -29,11 +30,18 @@ var validPlaylistTypes = func() map[string]bool {
return m
}()
+var customIDRe = regexp.MustCompile(`^custom-[a-z0-9]+$`)
+
+// isValidPlaylistID accepts built-in playlist types and custom-* IDs (blocks path traversal).
+func isValidPlaylistID(t string) bool {
+ return validPlaylistTypes[t] || customIDRe.MatchString(t)
+}
+
// handleGetPlaylist serves the tracklist cache written by explo during its last run.
// Returns an empty track list if no cache exists yet.
func (s *Server) handleGetPlaylist(w http.ResponseWriter, r *http.Request) {
playlistType := r.URL.Query().Get("type")
- if !validPlaylistTypes[playlistType] {
+ if !isValidPlaylistID(playlistType) {
http.Error(w, "unknown playlist type", http.StatusBadRequest)
return
}
@@ -80,6 +88,7 @@ type lbCreatedForResp struct {
type lbPlaylistResp struct {
Playlist struct {
+ Title string `json:"title"`
Track []struct {
Title string `json:"title"`
Creator string `json:"creator"`
@@ -96,6 +105,30 @@ type lbPlaylistResp struct {
} `json:"playlist"`
}
+// fetchLBPlaylistByMBID fetches a specific ListenBrainz playlist by its MBID and returns
+// the playlist title along with its tracks as [title, artist, album, coverURL] tuples.
+func fetchLBPlaylistByMBID(mbid string) (string, [][4]string, error) {
+ body, err := lbGet(fmt.Sprintf("%s/playlist/%s", lbAPIBase, mbid))
+ if err != nil {
+ return "", nil, fmt.Errorf("playlist fetch: %w", err)
+ }
+ var resp lbPlaylistResp
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return "", nil, fmt.Errorf("playlist parse: %w", err)
+ }
+ out := make([][4]string, 0, len(resp.Playlist.Track))
+ for _, t := range resp.Playlist.Track {
+ meta := t.Extension.JspfTrack.AdditionalMetadata
+ var cover string
+ if meta.CaaReleaseMbid != "" && meta.CaaID != 0 {
+ cover = fmt.Sprintf("https://coverartarchive.org/release/%s/%d-250.jpg",
+ meta.CaaReleaseMbid, meta.CaaID)
+ }
+ out = append(out, [4]string{t.Title, t.Creator, t.Album, cover})
+ }
+ return resp.Playlist.Title, out, nil
+}
+
func fetchOnRepeatTracks(username string) ([][4]string, error) {
body, err := lbGet(fmt.Sprintf("%s/stats/user/%s/recordings?count=30&range=month", lbAPIBase, username))
if err != nil {
@@ -153,24 +186,9 @@ func fetchMostRecentLBPlaylist(username, playlistType string) ([][4]string, time
return nil, time.Time{}, nil
}
- body, err := lbGet(fmt.Sprintf("%s/playlist/%s", lbAPIBase, bestID))
+ _, out, err := fetchLBPlaylistByMBID(bestID)
if err != nil {
- return nil, time.Time{}, fmt.Errorf("playlist fetch: %w", err)
- }
- var resp lbPlaylistResp
- if err := json.Unmarshal(body, &resp); err != nil {
- return nil, time.Time{}, fmt.Errorf("playlist parse: %w", err)
- }
-
- out := make([][4]string, 0, len(resp.Playlist.Track))
- for _, t := range resp.Playlist.Track {
- meta := t.Extension.JspfTrack.AdditionalMetadata
- var cover string
- if meta.CaaReleaseMbid != "" && meta.CaaID != 0 {
- cover = fmt.Sprintf("https://coverartarchive.org/release/%s/%d-250.jpg",
- meta.CaaReleaseMbid, meta.CaaID)
- }
- out = append(out, [4]string{t.Title, t.Creator, t.Album, cover})
+ return nil, time.Time{}, err
}
return out, bestDate, nil
}
@@ -333,37 +351,44 @@ type cachedPrefetchTrack struct {
CoverURL string `json:"coverUrl,omitempty"`
}
-func writePrefetchCache(cfgDir, playlistType string, tracks [][4]string) {
+// writePreliminaryCache writes the track cache with remote cover URLs immediately.
+// Returns false if the write fails.
+func writePreliminaryCache(cfgDir, playlistType string, tracks [][4]string) bool {
ct := make([]cachedPrefetchTrack, len(tracks))
for i, t := range tracks {
- ct[i] = cachedPrefetchTrack{
- Rank: i + 1,
- Title: t[0],
- Artist: t[1],
- Release: t[2],
- CoverURL: t[3],
- }
+ ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t[0], Artist: t[1], Release: t[2], CoverURL: t[3]}
}
-
if !writeTrackCache(cfgDir, playlistType, ct) {
- return
+ return false
}
slog.Info("prefetch: cache written", "playlist", playlistType, "covers", "remote")
+ return true
+}
+// downloadAndCacheCovers downloads cover art and rewrites the cache with local URLs.
+// Safe to call in a goroutine.
+func downloadAndCacheCovers(cfgDir, playlistType string, tracks [][4]string) {
coversDir := filepath.Join(cfgDir, "cache", "covers")
if err := os.MkdirAll(coversDir, 0755); err != nil {
slog.Error("prefetch: failed to create covers dir", "err", err.Error())
return
}
-
+ ct := make([]cachedPrefetchTrack, len(tracks))
for i, t := range tracks {
- ct[i].CoverURL = downloadCover(t[3], coversDir)
+ ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t[0], Artist: t[1], Release: t[2], CoverURL: downloadCover(t[3], coversDir)}
}
if writeTrackCache(cfgDir, playlistType, ct) {
slog.Info("prefetch: cache updated", "playlist", playlistType, "covers", "local")
}
}
+func writePrefetchCache(cfgDir, playlistType string, tracks [][4]string) {
+ if !writePreliminaryCache(cfgDir, playlistType, tracks) {
+ return
+ }
+ downloadAndCacheCovers(cfgDir, playlistType, tracks)
+}
+
// ── Background art ───────────────────────────────────────────────────────────
type sitewideReleasesResp struct {
diff --git a/src/web/backend/server.go b/src/web/backend/server.go
index cd09b8cb..fc9991bf 100644
--- a/src/web/backend/server.go
+++ b/src/web/backend/server.go
@@ -129,9 +129,12 @@ func (s *Server) startJobs() {
coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers")
if err := s.cronJobs.RegisterCoverCleanup(
"0 3 * * *", coversDir, s.cfg.CacheSizeMB<<20); err != nil {
- slog.Warn("failed to register cover cleanup job", "err", err.Error())
- }
+ slog.Warn("failed to register cover cleanup job", "err", err.Error())
+ }
+ if err := s.cronJobs.RegisterCustomPlaylistRefresh(s.cfg.WebDataDir); err != nil {
+ slog.Warn("failed to register custom playlist refresh job", "err", err.Error())
+ }
s.cronJobs.Start()
}
@@ -194,6 +197,10 @@ func (s *Server) registerRoutes() {
s.mux.Handle("GET /api/ui/logs", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetLog)))
s.mux.Handle("GET /api/ui/playlists", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetPlaylist)))
s.mux.Handle("POST /api/ui/playlists/prefetch", s.authStore.RequireAuth(http.HandlerFunc(s.handlePrefetchCovers)))
+ s.mux.Handle("GET /api/ui/custom-playlists", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetCustomPlaylists)))
+ s.mux.Handle("POST /api/ui/custom-playlists", s.authStore.RequireAuth(http.HandlerFunc(s.handleImportCustomPlaylist)))
+ s.mux.Handle("DELETE /api/ui/custom-playlists/{id}", s.authStore.RequireAuth(http.HandlerFunc(s.handleDeleteCustomPlaylist)))
+ s.mux.Handle("POST /api/ui/custom-playlists/{id}/refresh", s.authStore.RequireAuth(http.HandlerFunc(s.handleRefreshCustomPlaylist)))
s.mux.Handle("POST /api/ui/logout", s.authStore.RequireAuth(http.HandlerFunc(s.handleLogout)))
s.mux.HandleFunc("GET /api/ui/csrf", s.csrfHandler)
s.mux.HandleFunc("POST /api/ui/login", s.handleLogin)
diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx
index 8e427684..1f77843a 100644
--- a/src/web/frontend/src/components/Settings.jsx
+++ b/src/web/frontend/src/components/Settings.jsx
@@ -14,6 +14,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import {
fetchConfig, fetchConfigRaw, saveConfig, resetConfig,
saveSchedule, startRun, stopRun, fetchRunStatus, fetchLogs,
+ fetchCustomPlaylists, deleteCustomPlaylist,
} from '../lib/api'
import { parseSlogLine, cronToFields, highlightEnv } from '../lib/utils'
import { fetchPlaylistTracks } from '../lib/listenbrainz'
@@ -21,6 +22,7 @@ import { motion, AnimatePresence } from 'motion/react'
import { Toggle } from './ui/Toggle'
import { Button, SectionLabel, Panel, LogRow } from './ui/common'
import { PlaylistCard, TracklistDropdown } from './ui/PlaylistCard'
+import { ImportModal } from './ui/ImportModal'
const tabBtnCls = active =>
`bg-transparent border-none border-b-2 pb-2 px-3.5 text-[13px] leading-none cursor-pointer transition-colors
@@ -96,6 +98,25 @@ const SCHEDULE_DAYS = [
const selectCls = 'bg-surface border border-ui-border text-white rounded-[6px] px-2.5 py-1.5 text-[13px] cursor-pointer outline-none focus:border-accent'
+function TracklistSlide({ show, slideKey, children }) {
+ return (
+
+ {show && (
+
+ {children}
+
+ )}
+
+ )
+}
+
function initSchedules(config) {
const out = {}
for (const p of PLAYLISTS) {
@@ -113,6 +134,8 @@ function HomeSection() {
const [scheduleSaveStatus, setScheduleSaveStatus] = useState({})
const [lbUser, setLbUser] = useState('')
const [openTracklist, setOpenTracklist] = useState(null)
+ const [customPlaylists, setCustomPlaylists] = useState([])
+ const [showImportModal, setShowImportModal] = useState(false)
const [playlist, setPlaylist] = useState('weekly-exploration')
const [dlmode, setDlmode] = useState('normal')
@@ -131,6 +154,7 @@ function HomeSection() {
setEnvSources(sources || {})
setLbUser(values.LISTENBRAINZ_USER || '')
})
+ fetchCustomPlaylists().then(setCustomPlaylists).catch(() => {})
}, [])
const onLine = useCallback(data => {
@@ -223,7 +247,7 @@ function HomeSection() {
{/* Scheduled Playlists */}
-
Scheduled Playlists
+
Scheduled Playlists
{PLAYLISTS.map((p, i) => {
const s = schedules[p.value]
@@ -261,26 +285,97 @@ function HomeSection() {
)
})}
-
- {openTracklist && (
-
-
-
- )}
-
+
p.value === openTracklist)} slideKey={openTracklist}>
+
+
Schedule changes take effect after restarting the container.
+ {/* Custom Playlists */}
+
+
+
Custom Playlists
+
+
+
+ {customPlaylists.length === 0 ? (
+
+ No custom playlists yet. Import one from ListenBrainz.
+
+ ) : (
+
+ {customPlaylists.map((cp, i) => (
+
0 ? `Every ${cp.refresh_days} day${cp.refresh_days !== 1 ? 's' : ''}` : ''}
+ tracklistOpen={openTracklist === cp.id}
+ onTracklistToggle={() => setOpenTracklist(v => v === cp.id ? null : cp.id)}
+ onDelete={async () => {
+ try {
+ await deleteCustomPlaylist(cp.id)
+ setCustomPlaylists(prev => prev.filter(p => p.id !== cp.id))
+ if (openTracklist === cp.id) setOpenTracklist(null)
+ } catch {
+ // ignore
+ }
+ }}
+ />
+ ))}
+
+ )}
+
+
+ {
+ await startRun(openTracklist, 'normal', true, false)
+ setRunning(true)
+ setStatus('running…')
+ setLogEntries([])
+ connect()
+ }}
+ onDelete={async () => {
+ try {
+ await deleteCustomPlaylist(openTracklist)
+ setCustomPlaylists(prev => prev.filter(p => p.id !== openTracklist))
+ setOpenTracklist(null)
+ } catch { }
+ }}
+ />
+
+
+
+ {/* Import Modal */}
+
+ {showImportModal && (
+ setShowImportModal(false)}
+ onImported={() => {
+ fetchCustomPlaylists().then(setCustomPlaylists).catch(() => {})
+ setShowImportModal(false)
+ }}
+ onSync={async (id) => {
+ await startRun(id, 'normal', true, false)
+ setRunning(true)
+ setStatus('running…')
+ setLogEntries([])
+ connect()
+ }}
+ />
+ )}
+
+
{/* Manual Run */}
Manual run
diff --git a/src/web/frontend/src/components/ui/ImportModal.jsx b/src/web/frontend/src/components/ui/ImportModal.jsx
new file mode 100644
index 00000000..cdef84bd
--- /dev/null
+++ b/src/web/frontend/src/components/ui/ImportModal.jsx
@@ -0,0 +1,274 @@
+import { useState } from 'react'
+import { motion, AnimatePresence } from 'motion/react'
+import { importCustomPlaylist } from '../../lib/api'
+
+const REFRESH_OPTIONS = [
+ { value: 0, label: 'Never' },
+ { value: 1, label: 'Every day' },
+ { value: 7, label: 'Every week' },
+ { value: 14, label: 'Every 2 weeks' },
+ { value: 30, label: 'Every month' },
+]
+
+function CoverThumb({ src, index }) {
+ const [loaded, setLoaded] = useState(false)
+ return (
+
+ setLoaded(true)}
+ initial={false}
+ animate={loaded ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.92 }}
+ transition={{ duration: 1.2, delay: loaded ? index * 0.18 : 0, ease: [0.16, 1, 0.3, 1] }}
+ className="w-full h-full object-cover block"
+ />
+
+ )
+}
+
+export function ImportModal({ onClose, onImported, onSync }) {
+ const [url, setUrl] = useState('')
+ const [refreshDays, setRefreshDays] = useState(0)
+ const [phase, setPhase] = useState('form') // 'form' | 'success' | 'error'
+ const [loading, setLoading] = useState(false)
+ const [result, setResult] = useState(null)
+ const [errorMsg, setErrorMsg] = useState('')
+
+ const handleImport = async () => {
+ if (!url.trim() || loading) return
+ setLoading(true)
+ try {
+ const data = await importCustomPlaylist(url.trim(), refreshDays)
+ setResult(data)
+ setPhase('success')
+ } catch (e) {
+ setErrorMsg(e.message || 'Import failed')
+ setPhase('error')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const canSubmit = url.trim() && !loading
+
+ return (
+
+ e.stopPropagation()}
+ className="w-full max-w-[420px] mx-4 bg-surface border border-ui-border rounded-2xl overflow-hidden"
+ style={{ boxShadow: '0 24px 64px rgba(0,0,0,0.6)' }}
+ >
+
+
+ {/* ── Form ───────────────────────────────────────── */}
+ {phase === 'form' && (
+
+ {/* Header */}
+
+
+ Import Playlist
+
+
+
+
+ {/* Body */}
+
+
+
+ setUrl(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && handleImport()}
+ placeholder="https://listenbrainz.org/playlist/…"
+ autoFocus
+ disabled={loading}
+ className="w-full bg-well border border-ui-border text-white rounded-lg px-3 py-2 text-[13px] outline-none placeholder:text-[#444] focus:border-accent transition-colors disabled:opacity-50"
+ />
+
+
+
+
+
+
+
+
+ {/* Footer */}
+
+
+
+
+
+ )}
+
+ {/* ── Success ────────────────────────────────────── */}
+ {phase === 'success' && result && (
+
+ {/* Header */}
+
+
+
+ {result.name}
+
+
+ {result.track_count} track{result.track_count !== 1 ? 's' : ''} imported
+
+
+
+
+ {/* Cover grid — deduplicated, adaptive column count */}
+ {(() => {
+ const unique = [...new Map((result.cover_urls ?? []).map(u => [u, u])).values()].slice(0, 6)
+ const cols = unique.length <= 1 ? 1 : unique.length <= 4 ? 2 : 3
+ return (
+
+
+ {unique.map((src, i) => (
+
+ ))}
+
+
+ )
+ })()}
+
+ {/* Footer */}
+
+
+ Added to your playlists
+
+
+ {onSync && (
+
+ )}
+
+
+
+
+ )}
+
+ {/* ── Error ──────────────────────────────────────── */}
+ {phase === 'error' && (
+
+ {/* Header */}
+
+
+ Import failed
+
+
{errorMsg}
+
+
+ {/* Footer */}
+
+
+
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx
index 66cab2b4..73caf071 100644
--- a/src/web/frontend/src/components/ui/PlaylistCard.jsx
+++ b/src/web/frontend/src/components/ui/PlaylistCard.jsx
@@ -108,12 +108,14 @@ function nextUpdateLabel(playlistType) {
return `Next update ${nextMonday.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' })}`
}
-export function TracklistDropdown({ playlist, lbUser }) {
+export function TracklistDropdown({ playlist, lbUser, onRun, onDelete }) {
const [tracks, setTracks] = useState([])
const [generatedAt, setGeneratedAt] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [fetching, setFetching] = useState(false)
+ const [running, setRunning] = useState(false)
+ const [runStatus, setRunStatus] = useState('')
const loadTracks = (withRetry = false) => {
let cancelled = false
@@ -154,12 +156,27 @@ export function TracklistDropdown({ playlist, lbUser }) {
.finally(() => setFetching(false))
}
+ const handleRun = async () => {
+ if (!onRun || running) return
+ setRunning(true)
+ setRunStatus('')
+ try {
+ await onRun()
+ setRunStatus('Started')
+ setTimeout(() => setRunStatus(''), 3000)
+ } catch (e) {
+ setRunStatus(e.message || 'Error')
+ } finally {
+ setRunning(false)
+ }
+ }
+
const genDate = generatedAt ? new Date(generatedAt) : null
return (
{/* Header */}
-
+
{!loading && tracks.length ? `${tracks.length} Tracks` : 'Tracks'}
@@ -168,6 +185,42 @@ export function TracklistDropdown({ playlist, lbUser }) {
Generated {genDate.toLocaleDateString([], { month: 'short', day: 'numeric' })}
)}
+ {(onRun || onDelete) && (
+
+ {runStatus && {runStatus}}
+ {onRun && (
+
+ )}
+ {onDelete && (
+
+ )}
+
+ )}
{/* Track list */}
@@ -177,16 +230,13 @@ export function TracklistDropdown({ playlist, lbUser }) {
) : error ? (
{error}
) : tracks.length === 0 ? (
-
+
No playlist found yet. {nextUpdateLabel(playlist)}.
{lbUser && (
@@ -239,6 +289,13 @@ const FALLBACK = {
label: 'PLAYLIST',
}
+// Color pool for user-imported custom playlists (cycled by colorIndex % 3)
+const CUSTOM_PRESETS = [
+ { background: cardGradient('#6366f1', '#8b5cf6', '#a78bfa'), accent: '#a78bfa', label: 'CUSTOM' },
+ { background: cardGradient('#0891b2', '#0e7490', '#67e8f9'), accent: '#67e8f9', label: 'CUSTOM' },
+ { background: cardGradient('#d97706', '#b45309', '#fcd34d'), accent: '#fcd34d', label: 'CUSTOM' },
+]
+
const SCHEDULE_DAYS = [
{ value: -1, label: 'Every day' },
{ value: 0, label: 'Sunday' },
@@ -271,9 +328,20 @@ export function PlaylistCard({
gradient: gradientOverride,
tracklistOpen,
onTracklistToggle,
+ onDelete,
+ trackId,
}) {
const { value, name } = playlist
- const preset = PRESETS[value] ?? FALLBACK
+ // trackFetchId: use real playlist ID (custom playlists) if provided, else fall back to value
+ const trackFetchId = trackId ?? value
+ // Resolve preset: built-in types → PRESETS, custom-N → CUSTOM_PRESETS[N % 3], else FALLBACK
+ let preset
+ if (PRESETS[value]) {
+ preset = PRESETS[value]
+ } else {
+ const customMatch = value.match(/^custom-(\d+)$/)
+ preset = customMatch ? CUSTOM_PRESETS[Number(customMatch[1]) % CUSTOM_PRESETS.length] : FALLBACK
+ }
const bg = gradientOverride ?? preset.background
const { accent, label } = preset
@@ -296,7 +364,7 @@ export function PlaylistCard({
let retry = 0
let retryTimer = null
const load = () => {
- fetchPlaylistTracks(value, { force: retry > 0 })
+ fetchPlaylistTracks(trackFetchId, { force: retry > 0 })
.then(({ tracks }) => {
if (cancelled) return
const covers = tracks.map(t => t.coverUrl).filter(Boolean)
@@ -314,7 +382,7 @@ export function PlaylistCard({
cancelled = true
if (retryTimer) clearTimeout(retryTimer)
}
- }, [value, s.enabled])
+ }, [trackFetchId, s.enabled])
useEffect(() => {
if (bgCovers.length < 2) return
@@ -436,30 +504,33 @@ export function PlaylistCard({
- {/* Toggle — bottom right */}
-
-
- {locked && (
-
ENV
+ {/* Toggle — bottom right (hidden for custom playlists, which use onDelete in the tracklist) */}
+ {!onDelete && (
+ <>
+
+ {locked && (
+
ENV
+ )}
+ >
)}
- {/* Inline schedule editor */}
+ {/* Inline schedule editor — not shown for custom playlists (onDelete present) */}
- {s.editing && s.enabled && !locked && !fixedSchedule && (
+ {!onDelete && s.editing && s.enabled && !locked && !fixedSchedule && (
Date: Thu, 14 May 2026 17:58:22 -0700
Subject: [PATCH 02/12] Add apple music support, apple_music..go that pulls
data from html get
---
go.mod | 2 +-
src/web/backend/apple_music.go | 244 ++++++++++++++++++
src/web/backend/custom_playlists.go | 135 +++++++---
src/web/backend/jobs.go | 11 +-
src/web/frontend/src/components/Settings.jsx | 3 +-
.../src/components/ui/ImportModal.jsx | 79 +++++-
.../src/components/ui/PlaylistCard.jsx | 60 +++--
src/web/frontend/src/lib/api.js | 4 +-
8 files changed, 466 insertions(+), 72 deletions(-)
create mode 100644 src/web/backend/apple_music.go
diff --git a/go.mod b/go.mod
index 5b3bc9e0..98ca6664 100644
--- a/go.mod
+++ b/go.mod
@@ -12,6 +12,7 @@ require (
github.com/u2takey/ffmpeg-go v0.5.0
github.com/wader/goutubedl v0.0.0-20250417150709-083444e4ab87
golang.org/x/crypto v0.46.0
+ golang.org/x/net v0.48.0
golang.org/x/sync v0.19.0
golang.org/x/text v0.32.0
maunium.net/go/mautrix v0.26.0
@@ -43,7 +44,6 @@ require (
github.com/u2takey/go-utils v0.3.1 // indirect
go.mau.fi/util v0.9.3 // indirect
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
- golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
diff --git a/src/web/backend/apple_music.go b/src/web/backend/apple_music.go
new file mode 100644
index 00000000..173a53c6
--- /dev/null
+++ b/src/web/backend/apple_music.go
@@ -0,0 +1,244 @@
+package backend
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "strings"
+
+ "golang.org/x/net/html"
+)
+
+// appleServerData mirrors the top-level shape of the
+// `
+ startIdx := strings.Index(html, cfgStart)
+ if startIdx < 0 {
+ return "", fmt.Errorf("appServerConfig not found in page")
+ }
+ startIdx += len(cfgStart)
+ endIdx := strings.Index(html[startIdx:], cfgEnd)
+ if endIdx < 0 {
+ return "", fmt.Errorf("appServerConfig closing tag not found")
+ }
+
+ cfgJSON, err := base64.StdEncoding.DecodeString(strings.TrimSpace(html[startIdx : startIdx+endIdx]))
+ if err != nil {
+ cfgJSON, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(html[startIdx : startIdx+endIdx]))
+ if err != nil {
+ return "", fmt.Errorf("failed to decode appServerConfig: %w", err)
+ }
+ }
+
+ var serverCfg struct {
+ ClientVersion string `json:"clientVersion"`
+ }
+ if err := json.Unmarshal(cfgJSON, &serverCfg); err != nil {
+ return "", fmt.Errorf("failed to parse appServerConfig: %w", err)
+ }
+ s.clientVersion = serverCfg.ClientVersion
+
+ // Device ID from sp_t cookie
+ u, _ := url.Parse("https://open.spotify.com")
+ for _, c := range s.client.Jar.Cookies(u) {
+ if c.Name == "sp_t" {
+ s.deviceID = c.Value
+ break
+ }
+ }
+
+ // Find the web-player JS bundle
+ var jsPack string
+ for _, link := range extractJSLinks(html) {
+ if strings.Contains(link, "web-player/web-player") && strings.HasSuffix(link, ".js") {
+ jsPack = link
+ break
+ }
+ }
+ if jsPack == "" {
+ return "", fmt.Errorf("web-player JS pack not found in page")
+ }
+
+ return jsPack, nil
+}
+
+// fetchAccessToken obtains a bearer token via TOTP.
+func (s *spotifySession) fetchAccessToken() error {
+ totp, version := generateSpotifyTOTP()
+
+ tokenURL := "https://open.spotify.com/api/token?" + url.Values{
+ "reason": {"init"},
+ "productType": {"web-player"},
+ "totp": {totp},
+ "totpVer": {strconv.Itoa(version)},
+ "totpServer": {totp},
+ }.Encode()
+
+ req, err := http.NewRequest("GET", tokenURL, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("User-Agent", spotifyUA)
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Referer", "https://open.spotify.com/")
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(b))
+ }
+
+ var tok struct {
+ AccessToken string `json:"accessToken"`
+ ClientID string `json:"clientId"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
+ return fmt.Errorf("failed to parse token response: %w", err)
+ }
+ if tok.AccessToken == "" {
+ return fmt.Errorf("received empty access token")
+ }
+
+ s.accessToken = tok.AccessToken
+ s.clientID = tok.ClientID
+ return nil
+}
+
+// fetchClientToken obtains a client token from clienttoken.spotify.com.
+func (s *spotifySession) fetchClientToken() error {
+ payload, err := json.Marshal(map[string]any{
+ "client_data": map[string]any{
+ "client_version": s.clientVersion,
+ "client_id": s.clientID,
+ "js_sdk_data": map[string]any{
+ "device_brand": "unknown",
+ "device_model": "unknown",
+ "os": "windows",
+ "os_version": "NT 10.0",
+ "device_id": s.deviceID,
+ "device_type": "computer",
+ },
+ },
+ })
+ if err != nil {
+ return err
+ }
+
+ req, err := http.NewRequest("POST", "https://clienttoken.spotify.com/v1/clienttoken", bytes.NewReader(payload))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("User-Agent", spotifyUA)
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return fmt.Errorf("client token endpoint returned %d: %s", resp.StatusCode, string(b))
+ }
+
+ var result struct {
+ ResponseType string `json:"response_type"`
+ GrantedToken struct {
+ Token string `json:"token"`
+ } `json:"granted_token"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return fmt.Errorf("failed to parse client token response: %w", err)
+ }
+ if result.ResponseType != "RESPONSE_GRANTED_TOKEN_RESPONSE" {
+ return fmt.Errorf("unexpected client token response type: %s", result.ResponseType)
+ }
+ if result.GrantedToken.Token == "" {
+ return fmt.Errorf("received empty client token")
+ }
+
+ s.clientToken = result.GrantedToken.Token
+ return nil
+}
+
+// extractPlaylistHash fetches the web-player JS bundle and its webpack chunks
+// to find the SHA256 hash for the "fetchPlaylist" persisted GraphQL query.
+func (s *spotifySession) extractPlaylistHash(jsPackURL string) error {
+ req, err := http.NewRequest("GET", jsPackURL, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("User-Agent", spotifyUA)
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ packBody, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
+ if err != nil {
+ return err
+ }
+ jsContent := string(packBody)
+
+ // Check the main pack first
+ if hash := findPartHash(jsContent, "fetchPlaylist"); hash != "" {
+ s.playlistHash = hash
+ return nil
+ }
+
+ // Fall back to scanning webpack chunks
+ nameMap, hashMap, err := extractMappings(jsContent)
+ if err != nil {
+ return fmt.Errorf("failed to extract chunk mappings: %w", err)
+ }
+
+ chunkFiles := combineChunks(nameMap, hashMap)
+ slog.Debug("spotify: scanning JS chunks for hash", "count", len(chunkFiles))
+
+ type chunkResult struct {
+ content string
+ err error
+ }
+
+ sem := make(chan struct{}, 10)
+ results := make(chan chunkResult, len(chunkFiles))
+
+ for _, file := range chunkFiles {
+ chunkURL := "https://open.spotifycdn.com/cdn/build/web-player/" + file
+ go func(u string) {
+ sem <- struct{}{}
+ defer func() { <-sem }()
+
+ r, err := http.NewRequest("GET", u, nil)
+ if err != nil {
+ results <- chunkResult{err: err}
+ return
+ }
+ r.Header.Set("User-Agent", spotifyUA)
+
+ resp, err := s.client.Do(r)
+ if err != nil {
+ results <- chunkResult{err: err}
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ b, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
+ if err != nil {
+ results <- chunkResult{err: err}
+ return
+ }
+ results <- chunkResult{content: string(b)}
+ }(chunkURL)
+ }
+
+ for i := 0; i < len(chunkFiles); i++ {
+ r := <-results
+ if r.err != nil {
+ continue
+ }
+ if hash := findPartHash(r.content, "fetchPlaylist"); hash != "" {
+ s.playlistHash = hash
+ for j := i + 1; j < len(chunkFiles); j++ {
+ <-results
+ }
+ return nil
+ }
+ }
+
+ return fmt.Errorf("fetchPlaylist hash not found in any JS bundle")
+}
+
+// ── Partner API ─────────────────────────────────────────────────────────────
+
+type spotifyImageSource struct {
+ URL string `json:"url"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+}
+
+type partnerPlaylistResp struct {
+ Data struct {
+ PlaylistV2 struct {
+ Name string `json:"name"`
+ Images struct {
+ Items []struct {
+ Sources []spotifyImageSource `json:"sources"`
+ } `json:"items"`
+ } `json:"images"`
+ Content partnerContent `json:"content"`
+ } `json:"playlistV2"`
+ } `json:"data"`
+}
+
+type partnerContent struct {
+ TotalCount int `json:"totalCount"`
+ Items []partnerItem `json:"items"`
+}
+
+type partnerItem struct {
+ ItemV2 struct {
+ Data partnerTrackData `json:"data"`
+ } `json:"itemV2"`
+}
+
+type partnerTrackData struct {
+ Typename string `json:"__typename"`
+ Name string `json:"name"`
+ Artists struct {
+ Items []struct {
+ Profile struct {
+ Name string `json:"name"`
+ } `json:"profile"`
+ } `json:"items"`
+ } `json:"artists"`
+ AlbumOfTrack struct {
+ Name string `json:"name"`
+ CoverArt struct {
+ Sources []spotifyImageSource `json:"sources"`
+ } `json:"coverArt"`
+ } `json:"albumOfTrack"`
+}
+
+// ── Playlist fetching ───────────────────────────────────────────────────────
+
+// fetchSpotifyPlaylist fetches a public Spotify playlist via the internal
+// partner API (api-partner.spotify.com). Retries once with a fresh session
+// on failure. Returns playlist name, artwork URL, and normalized tracks.
+func fetchSpotifyPlaylist(playlistURL string) (string, string, []PlaylistTrack, error) {
+ id, err := extractSpotifyID(playlistURL)
+ if err != nil {
+ return "", "", nil, err
+ }
+
+ name, artwork, tracks, err := fetchPlaylistByID(id)
+ if err != nil {
+ slog.Debug("spotify: retrying with fresh session", "err", err)
+ spSession.invalidate()
+ name, artwork, tracks, err = fetchPlaylistByID(id)
+ }
+ if err != nil {
+ return "", "", nil, err
+ }
+ if len(tracks) == 0 {
+ return "", "", nil, fmt.Errorf("spotify: playlist %q has no tracks", id)
+ }
+
+ slog.Info("spotify: fetched playlist", "name", name, "tracks", len(tracks))
+ return name, artwork, tracks, nil
+}
+
+func fetchPlaylistByID(id string) (string, string, []PlaylistTrack, error) {
+ if err := spSession.ensure(); err != nil {
+ return "", "", nil, err
+ }
+
+ const pageSize = 343
+
+ resp, err := queryPartnerAPI(id, 0, pageSize)
+ if err != nil {
+ return "", "", nil, err
+ }
+
+ pl := resp.Data.PlaylistV2
+ name := pl.Name
+ if name == "" {
+ name = "Spotify Playlist"
+ }
+
+ artworkURL := ""
+ if len(pl.Images.Items) > 0 && len(pl.Images.Items[0].Sources) > 0 {
+ artworkURL = pickBestSource(pl.Images.Items[0].Sources, 300)
+ }
+
+ tracks := extractTracks(pl.Content.Items)
+
+ // Paginate
+ for offset := pageSize; offset < pl.Content.TotalCount; offset += pageSize {
+ page, err := queryPartnerAPI(id, offset, pageSize)
+ if err != nil {
+ slog.Warn("spotify: pagination failed, returning partial results", "offset", offset, "err", err)
+ break
+ }
+ tracks = append(tracks, extractTracks(page.Data.PlaylistV2.Content.Items)...)
+ }
+
+ return name, artworkURL, tracks, nil
+}
+
+// queryPartnerAPI sends a persisted GraphQL query to api-partner.spotify.com.
+func queryPartnerAPI(playlistID string, offset, limit int) (*partnerPlaylistResp, error) {
+ variables, _ := json.Marshal(map[string]any{
+ "uri": "spotify:playlist:" + playlistID,
+ "offset": offset,
+ "limit": limit,
+ "enableWatchFeedEntrypoint": false,
+ })
+ extensions, _ := json.Marshal(map[string]any{
+ "persistedQuery": map[string]any{
+ "version": 1,
+ "sha256Hash": spSession.playlistHash,
+ },
+ })
+
+ reqURL := "https://api-partner.spotify.com/pathfinder/v1/query?" + url.Values{
+ "operationName": {"fetchPlaylist"},
+ "variables": {string(variables)},
+ "extensions": {string(extensions)},
+ }.Encode()
+
+ req, err := http.NewRequest("GET", reqURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+spSession.accessToken)
+ req.Header.Set("Client-Token", spSession.clientToken)
+ req.Header.Set("Spotify-App-Version", spSession.clientVersion)
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Accept-Language", "en")
+ req.Header.Set("User-Agent", spotifyUA)
+
+ resp, err := spSession.client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("partner API request failed: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ return nil, fmt.Errorf("partner API returned %d: %s", resp.StatusCode, string(b))
+ }
+
+ var result partnerPlaylistResp
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("failed to parse partner API response: %w", err)
+ }
+ return &result, nil
+}
+
+// ── Track extraction helpers ────────────────────────────────────────────────
+
+func extractTracks(items []partnerItem) []PlaylistTrack {
+ tracks := make([]PlaylistTrack, 0, len(items))
+ for _, item := range items {
+ t := item.ItemV2.Data
+ if t.Name == "" {
+ continue
+ }
+
+ artists := make([]string, 0, len(t.Artists.Items))
+ for _, a := range t.Artists.Items {
+ if a.Profile.Name != "" {
+ artists = append(artists, a.Profile.Name)
+ }
+ }
+
+ fullArtist := strings.Join(artists, ", ")
+ mainArtist := fullArtist
+ if len(artists) > 0 {
+ mainArtist = artists[0]
+ }
+
+ coverURL := ""
+ if len(t.AlbumOfTrack.CoverArt.Sources) > 0 {
+ coverURL = pickBestSource(t.AlbumOfTrack.CoverArt.Sources, 300)
+ }
+
+ tracks = append(tracks, PlaylistTrack{
+ Title: t.Name,
+ Artist: fullArtist,
+ MainArtist: mainArtist,
+ Album: t.AlbumOfTrack.Name,
+ CoverURL: coverURL,
+ })
+ }
+ return tracks
+}
+
+// pickBestSource returns the image URL closest to targetSize pixels.
+func pickBestSource(sources []spotifyImageSource, targetSize int) string {
+ if len(sources) == 0 {
+ return ""
+ }
+ best := sources[0].URL
+ bestDiff := abs(sources[0].Height - targetSize)
+ for _, s := range sources[1:] {
+ if s.Height == 0 {
+ continue
+ }
+ if diff := abs(s.Height - targetSize); diff < bestDiff {
+ best = s.URL
+ bestDiff = diff
+ }
+ }
+ return best
+}
+
+func abs(n int) int {
+ if n < 0 {
+ return -n
+ }
+ return n
+}
+
+// ── JS bundle parsing ───────────────────────────────────────────────────────
+//
+// Spotify's web player uses webpack. The persisted GraphQL query hashes are
+// embedded in JS chunks. These helpers extract