diff --git a/src/client/emby.go b/src/client/emby.go index 58cb9cbe..07af57b4 100644 --- a/src/client/emby.go +++ b/src/client/emby.go @@ -126,7 +126,7 @@ func (c *Emby) CheckRefreshState() bool { func (c *Emby) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { - reqParam := fmt.Sprintf("/emby/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(track.CleanTitle)) + reqParam := fmt.Sprintf("/emby/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(util.CleanSearchTitle(track.CleanTitle))) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers) if err != nil { @@ -139,7 +139,7 @@ func (c *Emby) SearchSongs(tracks []*models.Track) error { } for _, item := range results.Items { - if strings.EqualFold(track.MainArtist, item.AlbumArtist) && (strings.EqualFold(item.Name, track.CleanTitle) || (track.File != "" && strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)))) { + if strings.EqualFold(track.MainArtist, item.AlbumArtist) && (util.NormalizeTitle(item.Name) == util.NormalizeTitle(track.Title) || (track.File != "" && strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)))) { track.ID = item.ID track.Present = true break diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index e115940a..c268260f 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -141,7 +141,7 @@ func (c *Jellyfin) CheckRefreshState() bool { func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { - reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(track.CleanTitle)) + reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(util.CleanSearchTitle(track.CleanTitle))) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers) if err != nil { @@ -154,7 +154,7 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { } for _, item := range results.Items { - if strings.EqualFold(track.MainArtist, item.AlbumArtist) && (strings.EqualFold(item.Name, track.CleanTitle) || (track.File != "" && strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)))) { + if strings.EqualFold(track.MainArtist, item.AlbumArtist) && (util.NormalizeTitle(item.Name) == util.NormalizeTitle(track.Title) || (track.File != "" && strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)))) { track.ID = item.ID track.Present = true break diff --git a/src/client/plex.go b/src/client/plex.go index 701df83d..f5231c0b 100644 --- a/src/client/plex.go +++ b/src/client/plex.go @@ -367,7 +367,7 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { params := fmt.Sprintf( "/hubs/search?query=%s&limit=10", - url.QueryEscape(track.CleanTitle), + url.QueryEscape(util.CleanSearchTitle(track.CleanTitle)), ) var body []byte @@ -390,13 +390,13 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error { } if err != nil { - slog.Warn("search request failed for '%s': %s", track.Title, err.Error()) + slog.Warn("search request failed", "title", track.Title, "err", err) continue } var hubResults PlexHubSearch if err := util.ParseResp(body, &hubResults); err != nil { - slog.Warn("failed to parse hub response for '%s': %s", track.Title, err.Error()) + slog.Warn("failed to parse hub response", "title", track.Title, "err", err) continue } @@ -412,7 +412,7 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error { key, err := getPlexSong(track, all) if err != nil { - slog.Warn("failed to find match for '%s': %s", track.Title, err.Error()) + slog.Warn("failed to find match", "title", track.Title, "err", err) continue } if key != "" { @@ -550,16 +550,16 @@ func (c *Plex) getServer() error { } func getPlexSong(track *models.Track, metadata []SongMetadata) (string, error) { - loweredArtist := strings.ToLower(track.MainArtist) + normArtist := util.AlnumOnly(strings.ToLower(track.MainArtist)) for _, md := range metadata { if md.Type != "track" { continue } - titleMatch := strings.EqualFold(md.Title, track.Title) || strings.EqualFold(md.Title, track.CleanTitle) - albumMatch := strings.EqualFold(md.ParentTitle, track.Album) - artistMatch := strings.Contains(strings.ToLower(md.OriginalTitle), loweredArtist) || strings.Contains(strings.ToLower(md.GrandparentTitle), loweredArtist) + titleMatch := util.NormalizeTitle(md.Title) == util.NormalizeTitle(track.Title) + albumMatch := util.AlnumOnly(strings.ToLower(md.ParentTitle)) == util.AlnumOnly(strings.ToLower(track.Album)) + artistMatch := strings.Contains(util.AlnumOnly(strings.ToLower(md.OriginalTitle)), normArtist) || strings.Contains(util.AlnumOnly(strings.ToLower(md.GrandparentTitle)), normArtist) if titleMatch && (albumMatch || artistMatch) { slog.Debug(fmt.Sprintf("matched track via metadata: %s by %s", track.Title, track.Artist)) @@ -590,7 +590,7 @@ func (c *Plex) addtoPlaylist(tracks []*models.Track) { params := fmt.Sprintf("/playlists/%s/items?uri=server://%s/com.plexapp.plugins.library%s", c.Cfg.PlaylistID, c.machineID, track.ID) if _, err := c.HttpClient.MakeRequest("PUT", c.Cfg.URL+params, nil, c.Cfg.Creds.Headers); err != nil { - slog.Warn("failed to add %s to playlist: %s", track.Title, err.Error()) + slog.Warn("failed to add to playlist", "title", track.Title, "err", err) } } } diff --git a/src/client/subsonic.go b/src/client/subsonic.go index 10523576..08c17898 100644 --- a/src/client/subsonic.go +++ b/src/client/subsonic.go @@ -125,7 +125,7 @@ func (c *Subsonic) AddLibrary() error { func (c *Subsonic) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { - searchQuery := fmt.Sprintf("%s %s", track.CleanTitle, track.MainArtist) + searchQuery := fmt.Sprintf("%s %s", util.CleanSearchTitle(track.CleanTitle), track.MainArtist) reqParam := fmt.Sprintf("search3?query=%s&f=json", url.QueryEscape(searchQuery)) body, err := c.subsonicRequest(reqParam) @@ -146,7 +146,7 @@ func (c *Subsonic) SearchSongs(tracks []*models.Track) error { for _, song := range songs { artistMatch := strings.Contains(strings.ToLower(song.Artist), strings.ToLower(track.MainArtist)) - titleMatch := strings.EqualFold(song.Title, track.Title) || strings.EqualFold(song.Title, track.CleanTitle) + titleMatch := util.NormalizeTitle(song.Title) == util.NormalizeTitle(track.Title) durationMatch := util.Abs(song.Duration - (track.Duration / 1000)) < 10 pathMatch := strings.Contains(strings.ToLower(song.Path), strings.ToLower(track.File)) diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index 2952b4e2..42946bcc 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -131,7 +131,7 @@ func tracksProcessed(tracks []*models.Track, progressMap map[string]*DownloadMon key := fmt.Sprintf("%s|%s", track.ID, track.File) tracker, exists := progressMap[key] if !track.Present && exists && !tracker.Skipped { - slog.Info("file still present", "file", track.File) + slog.Info("[monitor] track download still in progress", "title", track.CleanTitle, "artist", track.MainArtist, "file", track.File) return false } } diff --git a/src/main/main.go b/src/main/main.go index 60b0d24a..e5d764d6 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -29,10 +29,11 @@ type Song struct { // 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"` + Title string `json:"title"` + Artist string `json:"artist"` + MainArtist string `json:"mainArtist"` + Release string `json:"release"` + CoverURL string `json:"coverUrl"` } type cacheFile struct { Tracks []cachedTrack `json:"tracks"` @@ -67,11 +68,15 @@ func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, erro tracks := make([]*models.Track, len(c.Tracks)) for i, t := range c.Tracks { + mainArtist := t.MainArtist + if mainArtist == "" { + mainArtist = t.Artist + } tracks[i] = &models.Track{ CleanTitle: t.Title, Title: t.Title, Artist: t.Artist, - MainArtist: t.Artist, + MainArtist: mainArtist, Album: t.Release, CoverURL: t.CoverURL, } diff --git a/src/util/http.go b/src/util/http.go index 6e3634da..8c02c7e4 100644 --- a/src/util/http.go +++ b/src/util/http.go @@ -112,15 +112,22 @@ func DownloadFile(url, destPath string) (string, error) { return destPath, nil } -// DownloadCover downloads coverURL into coversDir and returns "/api/covers/.jpg". -// Returns "" if url is empty. +// DownloadCover downloads coverURL into coversDir and returns "/api/covers/.jpg". +// For CoverArtArchive URLs the id is the MusicBrainz release MBID (second-to-last +// path segment). For Spotify CDN URLs (i.scdn.co) the id is the image hash (last +// path segment). Returns "" if url is empty. func DownloadCover(url, coversDir string) string { if url == "" { return "" } parts := strings.Split(strings.TrimRight(url, "/"), "/") - mbid := parts[len(parts)-2] - destPath := filepath.Join(coversDir, mbid+".jpg") + // Spotify CDN: https://i.scdn.co/image/ → use last segment + // CAA: https://coverartarchive.org/release//front-250 → use second-to-last + id := parts[len(parts)-2] + if strings.Contains(url, "scdn.co") || strings.Contains(url, "spotifycdn.com") { + id = parts[len(parts)-1] + } + destPath := filepath.Join(coversDir, id+".jpg") if _, err := os.Stat(destPath); os.IsNotExist(err) { resp, err := http.Get(url) //nolint:noctx if err == nil { @@ -140,5 +147,5 @@ func DownloadCover(url, coversDir string) string { }() } } - return "/api/covers/" + mbid + ".jpg" + return "/api/covers/" + id + ".jpg" } diff --git a/src/util/sanitize.go b/src/util/sanitize.go index f2a94834..a16e9878 100644 --- a/src/util/sanitize.go +++ b/src/util/sanitize.go @@ -1,12 +1,33 @@ package util -import "regexp" +import ( + "regexp" + "strings" +) var ( - filenameRe = regexp.MustCompile(`[^\p{L}\d._,\-]+`) - alnumRe = regexp.MustCompile(`[^\p{L}\d]+`) + filenameRe = regexp.MustCompile(`[^\p{L}\d._,\-]+`) + alnumRe = regexp.MustCompile(`[^\p{L}\d]+`) + featTailRe = regexp.MustCompile(`(?i)\s*[\(\[\{]\s*(feat\.?|featuring|ft\.?|with)\s[^\)\]\}]*[\)\]\}]\s*$`) + remasterTailRe = regexp.MustCompile(`(?i)\s*[-–—]\s*\d{4}\s*remaster(ed)?\s*$`) ) +// CleanSearchTitle strips trailing (feat. …) and "- 2011 Remaster" suffixes +// but keeps the title human-readable for use in search API queries. +func CleanSearchTitle(s string) string { + s = featTailRe.ReplaceAllString(s, "") + s = remasterTailRe.ReplaceAllString(s, "") + return strings.TrimSpace(s) +} + +// NormalizeTitle strips trailing (feat. …) annotations, lowercases, +// and reduces to alphanumeric-only for fuzzy title comparison. +func NormalizeTitle(s string) string { + s = featTailRe.ReplaceAllString(s, "") + s = remasterTailRe.ReplaceAllString(s, "") + return AlnumOnly(strings.ToLower(s)) +} + // FilenameSafe replaces characters unsafe for filenames with '_' func FilenameSafe(s string) string { return filenameRe.ReplaceAllString(s, "_") diff --git a/src/web/backend/apple_music.go b/src/web/backend/apple_music.go index 8810f175..7f7f59ed 100644 --- a/src/web/backend/apple_music.go +++ b/src/web/backend/apple_music.go @@ -53,7 +53,7 @@ func resolveArtworkURL(tpl string) string { // fetchAppleMusicPlaylist scrapes a public Apple Music playlist page and extracts // track info from the embedded server data. // Returns (playlistName, artworkURL, tracks, error) where tracks are [title, artist, album, coverURL]. -func fetchAppleMusicPlaylist(pageURL string) (string, string, [][4]string, error) { +func fetchAppleMusicPlaylist(pageURL string) (string, string, []PlaylistTrack, error) { req, err := http.NewRequest("GET", pageURL, nil) if err != nil { return "", "", nil, fmt.Errorf("apple music: invalid URL: %w", err) @@ -104,7 +104,7 @@ func fetchAppleMusicPlaylist(pageURL string) (string, string, [][4]string, error // extractServerData parses 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