bd8d5d07c0
* fix(playlist): synchronize Playlist for concurrent plugin/UI access Lua plugin callbacks run on goroutines (hooks, timers, keybinds) and read playlist state through StateProvider closures, while the Bubbletea loop mutates the same *Playlist. The struct had no synchronization, so reads of p.tracks/p.order/p.pos raced with Next/Prev/Add/Remove/shuffle, and Current()'s unguarded p.tracks[idx] could panic on a stale index. Add a sync.Mutex and guard every exported method. Reads and writes are now serialized; no exported method calls another, so defer-unlock cannot deadlock. Fixes the high-severity track/player getter race and the playlist-mutation data race. * fix(luaplugin): block SSRF in cliamp.http doHTTP passed plugin-supplied URLs straight to the HTTP client with no validation, letting a plugin reach loopback, RFC1918, and link-local 169.254.169.254 (cloud metadata), and follow redirects into those targets. Add an ssrfGuard on the dialer Control hook so every connection attempt (including redirects and DNS-rebound hosts) is checked against the resolved IP and rejected if non-public, and reject non-http(s) schemes up front. A per-plugin network permission gate remains a possible follow-up; it is omitted here to avoid breaking existing plugins that legitimately use http. * fix(pluginmgr): sandbox plugin VM during metadata extraction extractMetadata ran DoFile on the entire plugin file with full stdlib, so any top-level os.execute/io call ran unsandboxed the moment a user listed installed plugins. Apply the same luaplugin.Sandbox used by the runtime before executing the file. Exports luaplugin.Sandbox for reuse. * fix(luaplugin): error on oversized fs.read instead of silent truncation fs.read used os.ReadFile (whole file into memory) then sliced to 1MB, returning a silently truncated value as success and not bounding memory for huge files. Stream through io.LimitReader(maxSize+1) and return an explicit error when the file exceeds the limit. * fix(luaplugin): make write allowlist symlink-safe isWriteAllowed validated paths lexically: the strings.Contains(abs, "..") check was dead (filepath.Abs already cleans "..", and it false-positived on filenames like "..bar"), and a symlink planted inside an allowed dir could redirect a write outside it. Resolve symlinks on the deepest existing ancestor of the target and on the allow dirs themselves before the prefix check. Also fixes the same gap in the exec cwd check, which reuses this function. * fix(luaplugin): bound timer callbacks with hookTimeout timer.after/every called CallByParam directly with no context timeout, so a runaway callback held the plugin mutex forever, blocking every other hook, keybind, and visualizer for that plugin. Add a shared callBounded helper that applies hookTimeout (mirroring invokeHook) and use it from both timer paths. * fix(luaplugin): bound visualizer render/init/destroy with hookTimeout RenderVis runs on the UI render loop and called CallByParam with no timeout while holding the plugin mutex; a hanging render() froze the UI with no escape. InitVis/DestroyVis had the same gap. Route all three through callBounded so a misbehaving visualizer times out and render falls back to the previous frame. * fix(luaplugin): wait for async hook goroutines before closing LStates Emit spawned untracked goroutines that call CallByParam on a plugin LState. Close stopped timers/execs then closed every LState without waiting, so a UI event dispatched just before shutdown could run against an already-closed LState (p.L.Close does not take the plugin mutex). Track Emit goroutines in a WaitGroup, set a closing flag under mu to reject late dispatch, and Wait before closing the LStates. * fix(ui): guard spectrum visualizers against zero-width panel renderFirework/renderBubbles/renderSakura compute dotCols = PanelWidth*2 and then mod by dotCols/dotRows/len(bands); on a narrow terminal PanelWidth can be 0, so seed % uint64(dotCols) panicked and crashed the TUI. Add the same dotRows<4 || dotCols<4 early-out the braille-grid renderers already use. * fix(history): do not clobber history on a transient load error Record discarded loadLocked's error and proceeded as if history were empty, so a one-off read failure (permission/I-O glitch) made saveLocked atomically rewrite the file with only the new entry, destroying all prior rows. Propagate the error instead, leaving the on-disk file untouched. * fix(history): detect write errors before committing history file saveLocked ignored errors from writeEntry's formatted writes, so a failure mid-write (e.g. ENOSPC) could still rename a truncated temp file over the real history. Thread writes through an errWriter and abort the rename when a write fails. * fix(lyrics): stop cleanQuery from erasing titles with label words The noise regex matched official/lyric/audio/video as bare substrings followed by .*, so 'Videotape', 'Audioslave', and 'Video Games' were cleaned to empty or truncated queries, breaking lyric lookups. Restrict bare-label stripping to genuine trailing labels (dash suffix, or 'official video/audio' and 'lyric(s) video' phrases). Add regression test cases. * fix(jellyfin,emby): synchronize client token/userID/album cache Client.token, userID, and albumCache were lazily read and written from concurrent tea.Cmd goroutines (Playlists, album browse, SearchTracks all run in their own goroutines) with no synchronization, a data race on the lazy auth writes and the cached slice. Add a mutex guarding those fields, taken only around field access and never across network I/O, with double-checked auth so at most a redundant (not racy) auth can occur. * fix(ipc): close in-flight connections on shutdown handleConn's read loop only observed the 60s per-request read deadline, never s.done, and Close closed only the listener. A still-connected client (e.g. a vis-bands polling client) kept its handler goroutine alive, so Close blocked on wg.Wait for up to 60s. Track live connections and close them in Close so their scanner.Scan unblocks immediately; addConn shares the conn mutex with the done check to close the accept-during-shutdown race. * refactor(ipc): route all reply-waits through waitReply The reply/timeout/shutdown select was open-coded in load/theme/vis/bands/ status and waitReply was used elsewhere with a generic 'timeout' message. Parameterize waitReply with a label and timeout and use it everywhere, which also gives the previously-generic commands specific timeout messages. * fix(ipc): handle accept errors instead of spinning silently acceptLoop swallowed every non-shutdown Accept error and retried at 10/s, treating a permanently closed listener as transient. Return on net.ErrClosed and log other errors before backing off. * fix(ui): notify Lua plugins on jump-to-time seek The jump enter handler called notifyPlayback (MPRIS only) plus notifier.Seeked, skipping plugin notification. Use finishSeek, which calls notifyAll, so a jump seek emits the playback-state event to plugins like every other completed seek. Seek stays synchronous (immediate seek), matching the existing test. * fix(resolve): bound startup feed sniff with a short timeout Args runs on the startup path and called sniffFeedURL, which did a HEAD on the 30s feed/M3U client, so one slow CDN could stall cliamp startup for up to 30s during pure URL classification. Give the sniff a dedicated 5s client. * fix(pluginmgr): reject empty download body An empty 200 response left download returning a non-nil empty slice, so Install wrote a 0-byte plugin file. Treat an empty body as a download error so the next candidate URL is tried (or the install fails cleanly). * fix(pluginmgr): reject raw URLs with no usable filename A raw URL ending in '/' made path.Base yield '.' or '/', producing a degenerate plugin filename. Return a clear error instead. * fix(config): let --low-power=false override config-enabled low power The override only set LowPower when the flag was true, so --low-power=false could not disable a config.toml low_power=true. Assign the flag value directly, matching the other boolean overrides. * fix(main): log MPRIS/NowPlaying init failure instead of dropping it wireMediaCtl's error was silently discarded in TUI mode, so a dbus/MPRIS setup failure left media-control silently disabled with no trace. Log it to the app log (not stderr, which would corrupt the TUI). * docs(plugins): remove unimplemented player getters cliamp.player.eq_preset(), visualizer(), and theme() were documented but never registered in the player API (calling them errors). Implementing them would require exposing model state to plugin goroutines, which would add a data race; remove them from the docs so the reference matches the API. * fix(luaplugin): reject partial bands table in set_eq_preset A bands table missing entries silently zeroed the unset bands. Require all 10 values and raise an arg error otherwise so partial input can't corrupt the EQ curve. * fix(luaplugin): track EmitKey goroutines and gate on shutdown EmitKey spawned fire-and-forget goroutines per keypress that were untracked and not gated on shutdown, so a keypress during Close could call into a closed LState. Track them in the manager WaitGroup and skip dispatch once closing, matching Emit. * fix(mediactl): apply MPRIS volume changes in order Each volume Change spawned its own goroutine to send SetVolumeMsg, so rapid changes could be delivered out of order and an older volume win. send (prog.Send) is goroutine-safe and non-blocking, so call it directly. * fix(mediactl): reject stale-track MPRIS SetPosition trackid was a fixed constant for every track, so SetPosition ignored its trackID argument and would seek whatever track became current after the client read its position. Assign a unique track object path per track change and ignore SetPosition when its trackID does not match the current track. * fix(radio): write favorites atomically save() truncated the real favorites file with os.Create and ignored every write error, so a partial write could lose all favorites. Build the content in memory, write a temp file, and rename so a failed write can't corrupt the existing file and the error surfaces to the caller. * fix(radio): propagate save errors from ToggleFavorite ToggleFavorite discarded the error from Add/Remove (which persist favorites), so a failed save was silently swallowed. Return it to the caller. * refactor(spotify): drop pointless lock around local slice append The mutex around appending the Your Music entry only touched the function-local 'all' slice, guarding nothing shared (the other locks in the loop legitimately guard trackCache). * fix(spotify): return cloned playlist list, not the cache slice Playlists returned the cached slice directly (both on cache hit and after building it), so a caller mutating the result corrupted the cache. Return a clone on both paths. * fix(spotify): return cloned cached tracks Tracks handed out the cached track slice directly, so a caller mutating it corrupted the per-playlist cache. Clone on both the cache-hit and freshly built return paths. (The snapshot-based invalidation within the playlist-list TTL is an intentional caching tradeoff and is left as-is.) * fix(spotify): don't sleep before giving up on rate limit On the final retry the loop slept the full backoff (up to 128s) and then returned the rate-limited error without making another request. Break out immediately on the last attempt. * fix(jellyfin): guard AlbumList against negative offset A negative offset passed the offset>=len check and then panicked at out[offset:end]. Clamp offset to 0 first, matching the Emby client. * docs(playlist): correct MoveQueue comment MoveQueue swaps any two positions, not only adjacent ones. * refactor(ui): simplify dead math.Min in flame tier mapping math.Min(0.65, 0.55) is a constant 0.55; replace with the literal and drop the now-unused math import. * refactor(netease): compare app code against its own constant resp.Code is NetEase's application-level status, not an HTTP status; it was compared against http.StatusOK which only coincidentally shares the value 200. Introduce neteaseCodeOK and use it at all four comparison sites. * fix(resolve): strip audio extension from derived title humanizeBasename left a trailing extension (e.g. .mp3) in the title derived from a URL basename. Drop a recognized audio extension first, leaving non-media dotted suffixes untouched. * fix(resolve): bound RSS feed read size resolveFeed decoded the response body with no size limit. Wrap it in an io.LimitReader (32 MB) so an oversized or malicious feed can't exhaust memory. * fix(navidrome): return cloned cache slices Playlists and Tracks handed out the internal cached slices, so a caller mutating the result corrupted the cache. Return slices.Clone on the cache-hit and freshly built return paths. * fix(player): include ffmpeg stderr in decode error decodeFFmpeg wrapped the exec error but dropped ffmpeg's stderr, where the actual failure reason is written. Surface ExitError.Stderr in the message. * refactor(player): forward speedStreamer.Err directly beep.Streamer already declares Err() error, so the local errorer type assertion was redundant. Call ss.s.Err() directly, matching the eq and volume streamers. * fix(ui): don't drop queued seek/lyric cmds on reconnect When the scheduled stream reconnect fired, the tick handler returned early with only playTrack + tick, discarding the seekCmd/lyricCmd computed earlier in the same tick. Fold them into the reconnect batch. * fix(ui): nav list footer counts reflect committed filter The footer only showed the filtered count while the search input bar was open (searching); once a filter was committed with Enter it fell back to the full count. Add navFilteredTotal and use the filtered count whenever a query is active, across the artist, album, and track footers. * refactor: remove dead internal/control package internal/control duplicated the message types in internal/playback and had no importers (only its own test); the live code uses internal/playback. Remove it. * fix(providers): implement Refresh for self-hosted providers Navidrome, Plex, Jellyfin, and Emby cache playlist/track (and album) data but none implemented playlist.Refresher, so the UI refresh key was a silent no-op for them. Add Refresh to clear the caches (including the jellyfin/emby client album cache) so the next fetch hits the server. * refactor: share minimal-TOML section parser The [[section]] parse loop was duplicated three times (radio loadStations, radio loadFavoriteStations, local loadTOML). Extract tomlutil.ParseSections and rewrite all three against it. Behavior is preserved, including the single-section-type assumption of the original parsers. * refactor: extract shared Emby/Jellyfin client into internal/embyapi The emby and jellyfin clients were ~95% identical (~700 duplicated lines) and had already drifted (jellyfin lacked the negative-offset guard and error wrapping). Extract one shared Client into internal/embyapi, isolating the real differences behind a dialect: auth header scheme (Emby Authorization vs Jellyfin X-Emby-Authorization), ping endpoint, user-id discovery, error prefix, and metadata key. emby and jellyfin become thin wrappers (NewClient, IsStreamURL, type aliases) plus their providers. Client tests are consolidated in embyapi: shared behavior once, dialect specifics per dialect. The client now uses a per- instance http.Client (SetHTTPClient) instead of a package global, which is what makes it testable from the provider packages.
804 lines
24 KiB
Go
804 lines
24 KiB
Go
//go:build !windows
|
|
|
|
package spotify
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
librespot "github.com/devgianlu/go-librespot"
|
|
"github.com/devgianlu/go-librespot/audio"
|
|
"github.com/gopxl/beep/v2"
|
|
|
|
"cliamp/applog"
|
|
"cliamp/playlist"
|
|
"cliamp/provider"
|
|
)
|
|
|
|
// Compile-time interface checks.
|
|
var (
|
|
_ provider.Searcher = (*SpotifyProvider)(nil)
|
|
_ provider.PlaylistWriter = (*SpotifyProvider)(nil)
|
|
_ provider.PlaylistCreator = (*SpotifyProvider)(nil)
|
|
_ provider.CustomStreamer = (*SpotifyProvider)(nil)
|
|
_ provider.Closer = (*SpotifyProvider)(nil)
|
|
)
|
|
|
|
// maxResponseBody limits JSON API responses to 10 MB.
|
|
const maxResponseBody = 10 << 20
|
|
|
|
// Pagination limits for the Spotify Web API.
|
|
const (
|
|
spotifyPlaylistPageSize = 50
|
|
// spotifyTrackPageSize is capped at 50 because /v1/playlists/{id}/items
|
|
// silently truncates larger limits; requesting more would cause the loop
|
|
// to skip items when offset advances by the requested limit.
|
|
spotifyTrackPageSize = 50
|
|
)
|
|
|
|
// spotifyPlaylistItem is the raw playlist object returned by /v1/me/playlists.
|
|
type spotifyPlaylistItem struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
SnapshotID string `json:"snapshot_id"`
|
|
Collaborative bool `json:"collaborative"`
|
|
Owner struct {
|
|
ID string `json:"id"`
|
|
} `json:"owner"`
|
|
Items *struct {
|
|
Total int `json:"total"`
|
|
} `json:"items"`
|
|
}
|
|
|
|
// playlistAccessible reports whether the playlist should be shown to the user.
|
|
// Playlists saved from other users (not owned, not collaborative) are excluded
|
|
// because the Spotify API returns 403 when listing their tracks.
|
|
// When userID is empty (fetch failed), all playlists are included as a fallback.
|
|
func playlistAccessible(item spotifyPlaylistItem, userID string) bool {
|
|
if userID == "" {
|
|
return true
|
|
}
|
|
return item.Owner.ID == userID || item.Collaborative
|
|
}
|
|
|
|
// SpotifyProvider implements playlist.Provider using the Spotify Web API
|
|
// for playlist/track metadata and go-librespot for audio streaming.
|
|
// playlistCache holds a snapshot_id and the fetched tracks for a playlist,
|
|
// allowing us to skip re-fetching playlists that haven't changed.
|
|
type playlistCache struct {
|
|
snapshotID string
|
|
tracks []playlist.Track
|
|
}
|
|
|
|
type SpotifyProvider struct {
|
|
session *Session
|
|
clientID string
|
|
bitrate int
|
|
userID string // Spotify user ID, fetched lazily on first Playlists() call
|
|
meFetched bool // /v1/me has been attempted this session; suppresses retry on failure
|
|
mu sync.Mutex
|
|
trackCache map[string]*playlistCache // playlist ID → cache entry
|
|
authCancel context.CancelFunc // cancels any in-progress OAuth flow
|
|
|
|
// Playlist list cache to avoid redundant API calls on provider switch.
|
|
listCache []playlist.PlaylistInfo
|
|
listCacheAt time.Time
|
|
}
|
|
|
|
const playlistListCacheTTL = 5 * time.Minute
|
|
|
|
// New creates a SpotifyProvider. If session is nil, authentication is
|
|
// deferred until the user first selects the Spotify provider.
|
|
// bitrate sets the preferred Spotify stream quality in kbps (96, 160, or 320).
|
|
func New(session *Session, clientID string, bitrate int) *SpotifyProvider {
|
|
return &SpotifyProvider{
|
|
session: session,
|
|
clientID: clientID,
|
|
bitrate: bitrate,
|
|
trackCache: make(map[string]*playlistCache),
|
|
}
|
|
}
|
|
|
|
// ensureSession tries to create a session using stored credentials only
|
|
// (no browser). Returns playlist.ErrNeedsAuth if interactive sign-in is needed.
|
|
func (p *SpotifyProvider) ensureSession() error {
|
|
p.mu.Lock()
|
|
if p.session != nil {
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
clientID := p.clientID
|
|
p.mu.Unlock()
|
|
|
|
if clientID == "" {
|
|
return fmt.Errorf("spotify: no client ID available")
|
|
}
|
|
sess, err := NewSessionSilent(context.Background(), clientID)
|
|
if err != nil {
|
|
return playlist.ErrNeedsAuth
|
|
}
|
|
p.mu.Lock()
|
|
p.session = sess
|
|
p.resetSessionScopedStateLocked()
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Authenticate runs the interactive sign-in flow (opens browser, waits for callback).
|
|
// Any previous in-progress OAuth flow is cancelled first to free the callback port.
|
|
func (p *SpotifyProvider) Authenticate() error {
|
|
p.mu.Lock()
|
|
if p.session != nil {
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
if p.authCancel != nil {
|
|
p.authCancel()
|
|
p.authCancel = nil
|
|
}
|
|
clientID := p.clientID
|
|
p.mu.Unlock()
|
|
|
|
if clientID == "" {
|
|
return fmt.Errorf("spotify: no client ID available")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
p.mu.Lock()
|
|
p.authCancel = cancel
|
|
p.mu.Unlock()
|
|
|
|
sess, err := NewSession(ctx, clientID)
|
|
|
|
p.mu.Lock()
|
|
p.authCancel = nil
|
|
p.mu.Unlock()
|
|
cancel()
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.mu.Lock()
|
|
p.session = sess
|
|
p.resetSessionScopedStateLocked()
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Close releases the session if one was created.
|
|
func (p *SpotifyProvider) Close() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.authCancel != nil {
|
|
p.authCancel()
|
|
p.authCancel = nil
|
|
}
|
|
if p.session != nil {
|
|
p.session.Close()
|
|
p.session = nil
|
|
p.resetSessionScopedStateLocked()
|
|
}
|
|
}
|
|
|
|
// resetSessionScopedStateLocked clears /v1/me-derived caches when the session
|
|
// changes. p.mu must be held.
|
|
func (p *SpotifyProvider) resetSessionScopedStateLocked() {
|
|
p.userID = ""
|
|
p.meFetched = false
|
|
}
|
|
|
|
func (p *SpotifyProvider) Name() string { return "Spotify" }
|
|
|
|
// currentUserID returns the authenticated user's Spotify ID, fetched from
|
|
// /v1/me at most once per session. Failures are remembered so a network blip
|
|
// during the first call doesn't trigger a request on every later use.
|
|
// userID is used by playlistAccessible to filter playlists the user doesn't
|
|
// own (which 403 on Tracks() for dev-mode apps).
|
|
func (p *SpotifyProvider) currentUserID(ctx context.Context) string {
|
|
p.mu.Lock()
|
|
if p.meFetched {
|
|
id := p.userID
|
|
p.mu.Unlock()
|
|
return id
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
var me struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if resp, err := p.webAPI(ctx, "GET", "/v1/me", nil); err == nil {
|
|
_ = decodeBody(resp, &me)
|
|
}
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.userID = me.ID
|
|
p.meFetched = true
|
|
return p.userID
|
|
}
|
|
|
|
// Playlists returns the authenticated user's Spotify playlists.
|
|
// Only playlists owned by the user or marked as collaborative are returned;
|
|
// playlists saved from other users are excluded because the Spotify API
|
|
// returns 403 when trying to list their tracks.
|
|
func (p *SpotifyProvider) Playlists() ([]playlist.PlaylistInfo, error) {
|
|
if err := p.ensureSession(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
p.mu.Lock()
|
|
if p.listCache != nil && time.Since(p.listCacheAt) < playlistListCacheTTL {
|
|
cached := slices.Clone(p.listCache)
|
|
p.mu.Unlock()
|
|
return cached, nil
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
userID := p.currentUserID(ctx) // empty string if fetch fails → no filtering
|
|
|
|
var all []playlist.PlaylistInfo
|
|
offset := 0
|
|
limit := spotifyPlaylistPageSize
|
|
|
|
// List of Playlists only includes created playlists by the User.
|
|
// This doesn't include the 'Liked Songs' playlist.
|
|
resp, err := p.webAPI(ctx, "GET", "/v1/me/tracks", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("spotify: your music: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
Total int `json:"total"`
|
|
}
|
|
if err := decodeBody(resp, &result); err != nil {
|
|
return nil, fmt.Errorf("spotify: parse playlists: %w", err)
|
|
}
|
|
|
|
// Unfortunately, the Spotify API doesn't expose the localized display name.
|
|
// i.e. 'Liked Songs' or 'Lieblingssongs' etc.
|
|
// For the moment, "Your Music" must sufficice without adding a localization
|
|
// map.
|
|
all = append(all, playlist.PlaylistInfo{
|
|
ID: "YOUR MUSIC",
|
|
Name: "Your Music",
|
|
TrackCount: result.Total,
|
|
Section: "Library",
|
|
})
|
|
|
|
for {
|
|
query := url.Values{
|
|
"limit": {fmt.Sprintf("%d", limit)},
|
|
"offset": {fmt.Sprintf("%d", offset)},
|
|
// Include owner.id and collaborative to filter inaccessible playlists.
|
|
"fields": {"items(id,name,snapshot_id,collaborative,owner(id),items.total),total"},
|
|
}
|
|
|
|
resp, err := p.webAPI(ctx, "GET", "/v1/me/playlists", query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("spotify: list playlists: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
Items []spotifyPlaylistItem `json:"items"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := decodeBody(resp, &result); err != nil {
|
|
return nil, fmt.Errorf("spotify: parse playlists: %w", err)
|
|
}
|
|
|
|
p.mu.Lock()
|
|
for _, item := range result.Items {
|
|
if !playlistAccessible(item, userID) {
|
|
continue
|
|
}
|
|
count := 0
|
|
if item.Items != nil {
|
|
count = item.Items.Total
|
|
}
|
|
section := "Followed playlists"
|
|
if userID != "" && item.Owner.ID == userID {
|
|
section = "Your playlists"
|
|
}
|
|
all = append(all, playlist.PlaylistInfo{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
TrackCount: count,
|
|
Section: section,
|
|
})
|
|
// Update snapshot_id in cache; if it changed, invalidate cached tracks.
|
|
if cached, ok := p.trackCache[item.ID]; ok {
|
|
if cached.snapshotID != item.SnapshotID {
|
|
delete(p.trackCache, item.ID)
|
|
}
|
|
}
|
|
// Store snapshot_id for later cache checks in Tracks().
|
|
if _, ok := p.trackCache[item.ID]; !ok && item.SnapshotID != "" {
|
|
p.trackCache[item.ID] = &playlistCache{snapshotID: item.SnapshotID}
|
|
}
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
if offset+limit >= result.Total {
|
|
break
|
|
}
|
|
offset += limit
|
|
}
|
|
|
|
// Group playlists by section so the UI can emit one header per group.
|
|
// Library first, then owned, then followed; preserve API order within.
|
|
sectionOrder := map[string]int{
|
|
"Library": 0,
|
|
"Your playlists": 1,
|
|
"Followed playlists": 2,
|
|
}
|
|
sort.SliceStable(all, func(i, j int) bool {
|
|
return sectionOrder[all[i].Section] < sectionOrder[all[j].Section]
|
|
})
|
|
|
|
p.mu.Lock()
|
|
p.listCache = all
|
|
p.listCacheAt = time.Now()
|
|
p.mu.Unlock()
|
|
|
|
return slices.Clone(all), nil
|
|
}
|
|
|
|
// Tracks returns all tracks for the given Spotify playlist ID.
|
|
// Track.Path is set to a spotify:track:<id> URI for the player to resolve.
|
|
// Results are cached by snapshot_id; unchanged playlists skip the API call.
|
|
func (p *SpotifyProvider) Tracks(playlistID string) ([]playlist.Track, error) {
|
|
if err := p.ensureSession(); err != nil {
|
|
return nil, err
|
|
}
|
|
// Check cache — if we have tracks and the snapshot_id hasn't changed, return cached.
|
|
p.mu.Lock()
|
|
if cached, ok := p.trackCache[playlistID]; ok && cached.tracks != nil {
|
|
tracks := slices.Clone(cached.tracks)
|
|
p.mu.Unlock()
|
|
return tracks, nil
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
type trackObj struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Artists []struct {
|
|
Name string `json:"name"`
|
|
} `json:"artists"`
|
|
Album struct {
|
|
Name string `json:"name"`
|
|
ReleaseDate string `json:"release_date"`
|
|
} `json:"album"`
|
|
DurationMs int `json:"duration_ms"`
|
|
TrackNumber int `json:"track_number"`
|
|
IsPlayable *bool `json:"is_playable"`
|
|
Restrictions struct {
|
|
Reason string `json:"reason"`
|
|
} `json:"restrictions"`
|
|
}
|
|
|
|
var all []playlist.Track
|
|
offset := 0
|
|
limit := spotifyTrackPageSize
|
|
|
|
for {
|
|
var (
|
|
resp *http.Response
|
|
err error
|
|
)
|
|
|
|
if playlistID == "YOUR MUSIC" {
|
|
query := url.Values{
|
|
"limit": {fmt.Sprintf("%d", limit)},
|
|
"offset": {fmt.Sprintf("%d", offset)},
|
|
}
|
|
resp, err = p.webAPI(ctx, "GET", "/v1/me/tracks", query)
|
|
} else {
|
|
query := url.Values{
|
|
"limit": {fmt.Sprintf("%d", limit)},
|
|
"offset": {fmt.Sprintf("%d", offset)},
|
|
"fields": {"items(item(id,name,artists(name),album(name,release_date),duration_ms,track_number,is_playable,restrictions(reason))),total"},
|
|
}
|
|
path := fmt.Sprintf("/v1/playlists/%s/items", playlistID)
|
|
resp, err = p.webAPI(ctx, "GET", path, query)
|
|
}
|
|
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "403") {
|
|
return nil, fmt.Errorf("spotify: playlist not accessible: only playlists you own or collaborate on can be loaded")
|
|
}
|
|
return nil, fmt.Errorf("spotify: list tracks: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
Items []struct {
|
|
Item *trackObj `json:"item"`
|
|
Track *trackObj `json:"track"`
|
|
} `json:"items"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := decodeBody(resp, &result); err != nil {
|
|
return nil, fmt.Errorf("spotify: parse tracks: %w", err)
|
|
}
|
|
|
|
for _, item := range result.Items {
|
|
t := item.Item
|
|
if t == nil {
|
|
t = item.Track
|
|
}
|
|
if t == nil || t.ID == "" {
|
|
continue // skip local/unavailable tracks
|
|
}
|
|
|
|
artists := make([]string, len(t.Artists))
|
|
for i, a := range t.Artists {
|
|
artists[i] = a.Name
|
|
}
|
|
|
|
var year int
|
|
if len(t.Album.ReleaseDate) >= 4 {
|
|
if y, err := strconv.Atoi(t.Album.ReleaseDate[:4]); err == nil {
|
|
year = y
|
|
}
|
|
}
|
|
|
|
all = append(all, playlist.Track{
|
|
Path: fmt.Sprintf("spotify:track:%s", t.ID),
|
|
Title: t.Name,
|
|
Artist: strings.Join(artists, ", "),
|
|
Album: t.Album.Name,
|
|
Year: year,
|
|
Stream: false, // must be false: true causes togglePlayPause to stop+restart instead of pause/resume
|
|
DurationSecs: t.DurationMs / 1000,
|
|
TrackNumber: t.TrackNumber,
|
|
Unplayable: (t.IsPlayable != nil && !*t.IsPlayable) || t.Restrictions.Reason != "",
|
|
})
|
|
}
|
|
|
|
if offset+limit >= result.Total {
|
|
break
|
|
}
|
|
offset += limit
|
|
}
|
|
|
|
// Cache the fetched tracks.
|
|
p.mu.Lock()
|
|
if cached, ok := p.trackCache[playlistID]; ok {
|
|
cached.tracks = all
|
|
} else {
|
|
p.trackCache[playlistID] = &playlistCache{tracks: all}
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
return slices.Clone(all), nil
|
|
}
|
|
|
|
// isAuthError returns true if the error is an authentication/session-related
|
|
// failure that can be resolved by re-authenticating.
|
|
func isAuthError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
// context.DeadlineExceeded and context.Canceled are NOT auth errors.
|
|
// They commonly fire during rapid track skipping when a previous NewStream's
|
|
// network fetch is interrupted, and previously caused spurious re-auth
|
|
// attempts (which then escalated to opening a browser tab mid-skip).
|
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
|
return false
|
|
}
|
|
var keyErr *audio.KeyProviderError
|
|
if errors.As(err, &keyErr) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// URISchemes returns the URI prefixes handled by this provider.
|
|
// Implements provider.CustomStreamer.
|
|
func (p *SpotifyProvider) URISchemes() []string { return []string{"spotify:"} }
|
|
|
|
// NewStreamer creates a SpotifyStreamer for the given spotify:track:xxx URI.
|
|
// If the stream fails due to an auth error (e.g. expired session, AES key
|
|
// rejection), the player tries a silent reconnect from cached credentials.
|
|
// If that fails — or the retry still hits an auth error — the streamer
|
|
// surfaces playlist.ErrNeedsAuth so the UI can prompt the user to sign in.
|
|
// We deliberately do NOT auto-launch a browser-based OAuth flow from this
|
|
// path: rapid track skipping can produce transient stream errors and a
|
|
// browser tab popping up mid-skip.
|
|
//
|
|
// Implements provider.CustomStreamer.
|
|
func (p *SpotifyProvider) NewStreamer(uri string) (beep.StreamSeekCloser, beep.Format, time.Duration, error) {
|
|
if err := p.ensureSession(); err != nil {
|
|
return nil, beep.Format{}, 0, err
|
|
}
|
|
spotID, err := librespot.SpotifyIdFromUri(uri)
|
|
if err != nil {
|
|
return nil, beep.Format{}, 0, fmt.Errorf("spotify: invalid URI %q: %w", uri, err)
|
|
}
|
|
|
|
tryStream := func() (*spotifyStreamer, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
stream, err := p.session.NewStream(ctx, *spotID, p.bitrate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return newSpotifyStreamer(stream), nil
|
|
}
|
|
|
|
s, err := tryStream()
|
|
if err == nil {
|
|
return s, s.Format(), s.Duration(), nil
|
|
}
|
|
if !isAuthError(err) {
|
|
return nil, beep.Format{}, 0, fmt.Errorf("spotify: new stream: %w", err)
|
|
}
|
|
|
|
// Auth error — try a silent reconnect from cached credentials.
|
|
applog.UserWarn("spotify: stream auth error (%v), attempting silent reconnect...", err)
|
|
|
|
reconnCtx, reconnCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
reconnErr := p.session.Reconnect(reconnCtx)
|
|
reconnCancel()
|
|
|
|
if reconnErr != nil {
|
|
applog.UserWarn("spotify: silent reconnect failed (%v); sign-in required", reconnErr)
|
|
return nil, beep.Format{}, 0, fmt.Errorf("spotify: stream auth error, silent reconnect failed: %w", playlist.ErrNeedsAuth)
|
|
}
|
|
|
|
s, err = tryStream()
|
|
if err == nil {
|
|
return s, s.Format(), s.Duration(), nil
|
|
}
|
|
if !isAuthError(err) {
|
|
return nil, beep.Format{}, 0, fmt.Errorf("spotify: new stream after silent reconnect: %w", err)
|
|
}
|
|
|
|
// Still failing after a silent reconnect — surface ErrNeedsAuth so the
|
|
// UI can prompt the user to sign in. Do NOT open a browser from here.
|
|
applog.UserWarn("spotify: stream still failing after silent reconnect (%v); sign-in required", err)
|
|
return nil, beep.Format{}, 0, fmt.Errorf("spotify: stream auth error after silent reconnect: %w", playlist.ErrNeedsAuth)
|
|
}
|
|
|
|
// webAPI calls the Spotify Web API via the session with retry on 429.
|
|
func (p *SpotifyProvider) webAPI(ctx context.Context, method, path string, query url.Values) (*http.Response, error) {
|
|
return p.webAPIWithBody(ctx, method, path, query, nil, "", http.StatusOK)
|
|
}
|
|
|
|
// webAPIWithBody is like webAPI but accepts an optional request body, content type,
|
|
// and a set of acceptable HTTP status codes (e.g. 200, 201). Retries 429 with
|
|
// exponential backoff (honoring Retry-After when present).
|
|
func (p *SpotifyProvider) webAPIWithBody(ctx context.Context, method, path string, query url.Values, body io.Reader, contentType string, acceptStatus ...int) (*http.Response, error) {
|
|
const maxRetries = 8
|
|
|
|
// Buffer the body so it can be replayed on retry.
|
|
var bodyBytes []byte
|
|
if body != nil {
|
|
var err error
|
|
bodyBytes, err = io.ReadAll(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read request body: %w", err)
|
|
}
|
|
}
|
|
|
|
for attempt := range maxRetries {
|
|
var reqBody io.Reader
|
|
if bodyBytes != nil {
|
|
reqBody = bytes.NewReader(bodyBytes)
|
|
}
|
|
|
|
resp, err := p.session.webApiWithBody(ctx, method, path, query, reqBody, contentType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
resp.Body.Close()
|
|
// On the last attempt there's no retry after the wait, so don't
|
|
// sleep (up to 128s) just to give up; fail now.
|
|
if attempt == maxRetries-1 {
|
|
break
|
|
}
|
|
wait := time.Duration(1<<uint(attempt)) * time.Second
|
|
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
|
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
|
|
wait = time.Duration(secs) * time.Second
|
|
}
|
|
}
|
|
applog.UserWarn("spotify: web api rate-limited on %s, retrying in %v (attempt %d/%d)", path, wait, attempt+1, maxRetries)
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(wait):
|
|
continue
|
|
}
|
|
}
|
|
|
|
ok := slices.Contains(acceptStatus, resp.StatusCode)
|
|
if !ok {
|
|
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
resp.Body.Close()
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("http status %s (failed to read body: %v)", resp.Status, readErr)
|
|
}
|
|
return nil, fmt.Errorf("http status %s: %s", resp.Status, string(respBody))
|
|
}
|
|
return resp, nil
|
|
}
|
|
return nil, fmt.Errorf("spotify: web api rate-limited on %s after %d retries (try re-authenticating)", path, maxRetries)
|
|
}
|
|
|
|
// friendlySearchError rewrites Spotify's misleading 400 "Invalid limit" reply
|
|
// from /v1/search into something a user can act on. Since Nov 27, 2024 Spotify
|
|
// returns this error for developer apps registered in Development Mode — the
|
|
// rest of the API (playback, playlists, library) keeps working, but the
|
|
// catalog endpoints (/v1/search etc.) are blocked. The limit value is fine.
|
|
func friendlySearchError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
msg := err.Error()
|
|
if strings.Contains(msg, "400") && strings.Contains(msg, "Invalid limit") {
|
|
return fmt.Errorf("spotify: search blocked — your client_id is too new. Spotify's Nov 27 2024 change blocks /v1/search for apps in Development Mode (the rest of cliamp still works on your app). Remove client_id from [spotify] in config.toml to use the built-in fallback for search, or apply for Extended Quota Mode")
|
|
}
|
|
return fmt.Errorf("spotify: search: %w", err)
|
|
}
|
|
|
|
// SearchTracks searches for tracks on Spotify and returns up to limit results.
|
|
// limit is clamped to Spotify's accepted range of 1..50.
|
|
func (p *SpotifyProvider) SearchTracks(ctx context.Context, query string, limit int) ([]playlist.Track, error) {
|
|
if err := p.ensureSession(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if limit < 1 {
|
|
limit = 1
|
|
} else if limit > 50 {
|
|
limit = 50
|
|
}
|
|
|
|
// No market parameter: when the request carries a user OAuth token, Spotify
|
|
// implicitly scopes results to the account's country.
|
|
q := url.Values{
|
|
"q": {query},
|
|
"type": {"track"},
|
|
"limit": {fmt.Sprintf("%d", limit)},
|
|
}
|
|
|
|
resp, err := p.webAPI(ctx, "GET", "/v1/search", q)
|
|
if err != nil {
|
|
return nil, friendlySearchError(err)
|
|
}
|
|
|
|
var result struct {
|
|
Tracks struct {
|
|
Items []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Artists []struct {
|
|
Name string `json:"name"`
|
|
} `json:"artists"`
|
|
Album struct {
|
|
Name string `json:"name"`
|
|
ReleaseDate string `json:"release_date"`
|
|
} `json:"album"`
|
|
DurationMs int `json:"duration_ms"`
|
|
} `json:"items"`
|
|
} `json:"tracks"`
|
|
}
|
|
if err := decodeBody(resp, &result); err != nil {
|
|
return nil, fmt.Errorf("spotify: parse search: %w", err)
|
|
}
|
|
|
|
var tracks []playlist.Track
|
|
for _, t := range result.Tracks.Items {
|
|
if t.ID == "" {
|
|
continue
|
|
}
|
|
artists := make([]string, len(t.Artists))
|
|
for i, a := range t.Artists {
|
|
artists[i] = a.Name
|
|
}
|
|
var year int
|
|
if len(t.Album.ReleaseDate) >= 4 {
|
|
if y, err := strconv.Atoi(t.Album.ReleaseDate[:4]); err == nil {
|
|
year = y
|
|
}
|
|
}
|
|
tracks = append(tracks, playlist.Track{
|
|
Path: fmt.Sprintf("spotify:track:%s", t.ID),
|
|
Title: t.Name,
|
|
Artist: strings.Join(artists, ", "),
|
|
Album: t.Album.Name,
|
|
Year: year,
|
|
DurationSecs: t.DurationMs / 1000,
|
|
})
|
|
}
|
|
return tracks, nil
|
|
}
|
|
|
|
// AddTrackToPlaylist adds a track to an existing Spotify playlist.
|
|
// The track's Path is used as the Spotify URI (e.g. "spotify:track:xxx").
|
|
// Implements provider.PlaylistWriter.
|
|
func (p *SpotifyProvider) AddTrackToPlaylist(ctx context.Context, playlistID string, track playlist.Track) error {
|
|
trackURI := track.Path
|
|
if err := p.ensureSession(); err != nil {
|
|
return err
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]any{"uris": []string{trackURI}})
|
|
path := fmt.Sprintf("/v1/playlists/%s/tracks", playlistID)
|
|
|
|
resp, err := p.webAPIWithBody(ctx, "POST", path, nil, bytes.NewReader(body), "application/json", http.StatusOK, http.StatusCreated)
|
|
if err != nil {
|
|
return fmt.Errorf("spotify: add track: %w", err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// Invalidate caches for this playlist.
|
|
p.mu.Lock()
|
|
delete(p.trackCache, playlistID)
|
|
p.listCache = nil
|
|
p.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreatePlaylist creates a new private Spotify playlist and returns its ID.
|
|
func (p *SpotifyProvider) CreatePlaylist(ctx context.Context, name string) (string, error) {
|
|
if err := p.ensureSession(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
userID := p.currentUserID(ctx)
|
|
if userID == "" {
|
|
return "", fmt.Errorf("spotify: could not determine user ID")
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]any{"name": name, "public": false})
|
|
path := fmt.Sprintf("/v1/users/%s/playlists", userID)
|
|
|
|
resp, err := p.webAPIWithBody(ctx, "POST", path, nil, bytes.NewReader(body), "application/json", http.StatusOK, http.StatusCreated)
|
|
if err != nil {
|
|
return "", fmt.Errorf("spotify: create playlist: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := decodeBody(resp, &result); err != nil {
|
|
return "", fmt.Errorf("spotify: parse created playlist: %w", err)
|
|
}
|
|
|
|
// Invalidate playlist list cache.
|
|
p.mu.Lock()
|
|
p.listCache = nil
|
|
p.mu.Unlock()
|
|
|
|
return result.ID, nil
|
|
}
|
|
|
|
// decodeBody reads and decodes a JSON response body, then closes it.
|
|
func decodeBody(resp *http.Response, v any) error {
|
|
defer resp.Body.Close()
|
|
return json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(v)
|
|
}
|