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