560 lines
18 KiB
Go
560 lines
18 KiB
Go
//go:build !windows
|
|
|
|
package spotify
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"github.com/bjarneo/cliamp/applog"
|
|
"github.com/bjarneo/cliamp/internal/browser"
|
|
"github.com/bjarneo/cliamp/playlist"
|
|
|
|
librespot "github.com/devgianlu/go-librespot"
|
|
librespotPlayer "github.com/devgianlu/go-librespot/player"
|
|
devicespb "github.com/devgianlu/go-librespot/proto/spotify/connectstate/devices"
|
|
"github.com/devgianlu/go-librespot/session"
|
|
"golang.org/x/oauth2"
|
|
spotifyoauth2 "golang.org/x/oauth2/spotify"
|
|
)
|
|
|
|
// storedCreds holds persisted Spotify credentials for re-authentication.
|
|
type storedCreds struct {
|
|
Username string `json:"username"`
|
|
Data []byte `json:"data"`
|
|
DeviceID string `json:"device_id"`
|
|
RefreshToken string `json:"refresh_token,omitempty"` // OAuth2 refresh token for silent re-auth
|
|
}
|
|
|
|
// CallbackPort is the fixed port for the OAuth2 callback server.
|
|
// Must match the redirect URI registered in the Spotify Developer app.
|
|
const CallbackPort = 19872
|
|
|
|
// authURLObserver is invoked with the OAuth URL when interactive auth begins.
|
|
// Set via SetAuthURLObserver. Used by the TUI to show the URL when the
|
|
// launched browser doesn't reach the user (containers, headless envs).
|
|
var authURLObserver atomic.Pointer[func(string)]
|
|
|
|
// SetAuthURLObserver registers a callback invoked once with the OAuth URL at
|
|
// the start of an interactive sign-in. Pass nil to remove.
|
|
func SetAuthURLObserver(fn func(string)) {
|
|
if fn == nil {
|
|
authURLObserver.Store(nil)
|
|
return
|
|
}
|
|
authURLObserver.Store(&fn)
|
|
}
|
|
|
|
func notifyAuthURL(u string) {
|
|
applog.Info("spotify: sign-in URL: %s", u)
|
|
if p := authURLObserver.Load(); p != nil {
|
|
(*p)(u)
|
|
}
|
|
}
|
|
|
|
// Session manages a go-librespot session and player for Spotify integration.
|
|
type Session struct {
|
|
mu sync.RWMutex
|
|
sess *session.Session
|
|
player *librespotPlayer.Player
|
|
devID string
|
|
clientID string // Spotify Developer app client ID
|
|
tokenSource oauth2.TokenSource // auto-refreshing OAuth2 token source
|
|
}
|
|
|
|
// NewSession creates a go-librespot session, using stored credentials if
|
|
// available, otherwise starting an interactive OAuth2 flow.
|
|
// clientID is the Spotify Developer app client ID for Web API access.
|
|
func NewSession(ctx context.Context, clientID string) (*Session, error) {
|
|
creds, err := loadCreds()
|
|
if err == nil && creds.Username != "" && len(creds.Data) > 0 {
|
|
s, err := newSessionFromStored(ctx, clientID, creds, false)
|
|
if err == nil {
|
|
return s, nil
|
|
}
|
|
// Stored credentials failed (expired/revoked), fall through to interactive.
|
|
}
|
|
return newInteractiveSession(ctx, clientID)
|
|
}
|
|
|
|
// NewSessionSilent is like NewSession but only uses stored credentials.
|
|
// Returns an error if interactive auth is required.
|
|
func NewSessionSilent(ctx context.Context, clientID string) (*Session, error) {
|
|
creds, err := loadCreds()
|
|
if err != nil || creds.Username == "" || len(creds.Data) == 0 {
|
|
return nil, fmt.Errorf("no stored credentials")
|
|
}
|
|
return newSessionFromStored(ctx, clientID, creds, true)
|
|
}
|
|
|
|
// newSessionFromStored creates a session from stored credentials.
|
|
// When silentOnly is true, it will not fall back to browser-based auth
|
|
// if the silent token refresh fails.
|
|
func newSessionFromStored(ctx context.Context, clientID string, creds *storedCreds, silentOnly bool) (*Session, error) {
|
|
devID := creds.DeviceID
|
|
if devID == "" {
|
|
devID = generateDeviceID()
|
|
}
|
|
|
|
sess, err := session.NewSessionFromOptions(ctx, &session.Options{
|
|
Log: &librespot.NullLogger{},
|
|
DeviceType: devicespb.DeviceType_COMPUTER,
|
|
DeviceId: devID,
|
|
Credentials: session.StoredCredentials{
|
|
Username: creds.Username,
|
|
Data: creds.Data,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("spotify: stored auth: %w", err)
|
|
}
|
|
|
|
// For stored credentials, we need a fresh Web API token via OAuth2.
|
|
// The spclient's login5 token is NOT suitable for Web API calls.
|
|
// Try silent refresh first (no browser), fall back to interactive.
|
|
var oauthToken *oauth2.Token
|
|
var refreshErr error
|
|
if creds.RefreshToken != "" {
|
|
token, err := silentTokenRefresh(clientID, creds.RefreshToken)
|
|
if err == nil {
|
|
oauthToken = token
|
|
} else {
|
|
refreshErr = err
|
|
}
|
|
}
|
|
// Dead refresh tokens (invalid_grant) never recover — clear so we don't
|
|
// repeat the same failure on every launch.
|
|
if isInvalidGrant(refreshErr) {
|
|
applog.UserError("spotify: stored refresh token is invalid; clearing credentials, please sign in again")
|
|
if _, err := DeleteCreds(); err != nil {
|
|
applog.Warn("spotify: failed to clear stored credentials: %v", err)
|
|
}
|
|
sess.Close()
|
|
return nil, fmt.Errorf("spotify: %w", playlist.ErrNeedsAuth)
|
|
}
|
|
if oauthToken == nil {
|
|
if silentOnly {
|
|
// Continue without a token source — already-loaded tracks still stream
|
|
// via spclient; new Web API calls will return ErrNeedsAuth.
|
|
applog.UserError("spotify: stored auth no longer valid; run 'cliamp spotify reset' or sign in again to fix")
|
|
s := &Session{sess: sess, devID: devID, clientID: clientID}
|
|
if err := saveCreds(&storedCreds{
|
|
Username: sess.Username(),
|
|
Data: sess.StoredCredentials(),
|
|
DeviceID: devID,
|
|
RefreshToken: creds.RefreshToken, // preserve for next attempt
|
|
}); err != nil {
|
|
applog.UserError("spotify: failed to save credentials: %v", err)
|
|
}
|
|
if err := s.initPlayer(); err != nil {
|
|
sess.Close()
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
token, err := doWebAPIAuth(ctx, clientID)
|
|
if err != nil {
|
|
sess.Close()
|
|
return nil, fmt.Errorf("stored session needs fresh Web API token: %w", err)
|
|
}
|
|
oauthToken = token
|
|
}
|
|
|
|
// Create an auto-refreshing token source — handles expiry transparently.
|
|
conf := spotifyOAuthConfig(clientID)
|
|
ts := conf.TokenSource(context.Background(), oauthToken)
|
|
|
|
s := &Session{sess: sess, devID: devID, clientID: clientID, tokenSource: ts}
|
|
|
|
// Re-save credentials (including refresh token for next launch).
|
|
if err := saveCreds(&storedCreds{
|
|
Username: sess.Username(),
|
|
Data: sess.StoredCredentials(),
|
|
DeviceID: devID,
|
|
RefreshToken: oauthToken.RefreshToken,
|
|
}); err != nil {
|
|
applog.UserError("spotify: failed to save credentials: %v", err)
|
|
}
|
|
|
|
if err := s.initPlayer(); err != nil {
|
|
sess.Close()
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// oauthScopes are the Spotify Web API scopes needed for cliamp.
|
|
// See: https://developer.spotify.com/documentation/web-api/concepts/scopes
|
|
var oauthScopes = []string{
|
|
// Playlist browsing
|
|
"playlist-read-collaborative",
|
|
"playlist-read-private",
|
|
// Playlist modification (save queue, create playlists)
|
|
"playlist-modify-public",
|
|
"playlist-modify-private",
|
|
// Streaming audio
|
|
"streaming",
|
|
// Library (liked songs, saved albums)
|
|
"user-library-read",
|
|
"user-library-modify",
|
|
// User profile
|
|
"user-read-private",
|
|
// Playback state (current track, queue)
|
|
"user-read-playback-state",
|
|
"user-modify-playback-state",
|
|
"user-read-currently-playing",
|
|
// Recently played / top tracks
|
|
"user-read-recently-played",
|
|
"user-top-read",
|
|
// Following (artists, users)
|
|
"user-follow-read",
|
|
"user-follow-modify",
|
|
}
|
|
|
|
// spotifyOAuthConfig returns the OAuth2 config for the given client ID.
|
|
func spotifyOAuthConfig(clientID string) *oauth2.Config {
|
|
return &oauth2.Config{
|
|
ClientID: clientID,
|
|
RedirectURL: fmt.Sprintf("http://127.0.0.1:%d/login", CallbackPort),
|
|
Scopes: oauthScopes,
|
|
Endpoint: spotifyoauth2.Endpoint,
|
|
}
|
|
}
|
|
|
|
// silentTokenRefresh uses a stored refresh token to get a new access token
|
|
// without opening a browser.
|
|
func silentTokenRefresh(clientID, refreshToken string) (*oauth2.Token, error) {
|
|
conf := spotifyOAuthConfig(clientID)
|
|
src := conf.TokenSource(context.Background(), &oauth2.Token{RefreshToken: refreshToken})
|
|
return src.Token()
|
|
}
|
|
|
|
// isInvalidGrant reports whether err is an OAuth2 invalid_grant response
|
|
// from the token endpoint, indicating the refresh token is dead and
|
|
// retrying with the same token will not succeed.
|
|
func isInvalidGrant(err error) bool {
|
|
var rerr *oauth2.RetrieveError
|
|
if !errors.As(err, &rerr) {
|
|
return false
|
|
}
|
|
return rerr.ErrorCode == "invalid_grant"
|
|
}
|
|
|
|
// oauthCallbackHTML is the response sent to the browser after a successful OAuth2 callback.
|
|
const oauthCallbackHTML = `<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8"><title>cliamp</title></head>
|
|
<body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#e0e0e0">
|
|
<div style="text-align:center">
|
|
<h2>✅ Authenticated!</h2>
|
|
<p>You can close this tab now.</p>
|
|
<script>setTimeout(function(){window.close()},1500)</script>
|
|
</div></body></html>`
|
|
|
|
// performOAuth2PKCE runs an OAuth2 PKCE flow: opens a browser for user consent,
|
|
// waits for the callback, and exchanges the code for a token.
|
|
func performOAuth2PKCE(ctx context.Context, clientID string) (*oauth2.Token, error) {
|
|
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", CallbackPort))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listen on port %d: %w", CallbackPort, err)
|
|
}
|
|
defer lis.Close() // always release the port
|
|
|
|
oauthConf := spotifyOAuthConfig(clientID)
|
|
|
|
verifier := oauth2.GenerateVerifier()
|
|
authURL := oauthConf.AuthCodeURL("", oauth2.S256ChallengeOption(verifier))
|
|
|
|
notifyAuthURL(authURL)
|
|
|
|
codeCh := make(chan string, 1)
|
|
go func() {
|
|
if err := http.Serve(lis, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
code := r.URL.Query().Get("code")
|
|
if code != "" {
|
|
codeCh <- code
|
|
}
|
|
w.Header().Set("Content-Type", "text/html")
|
|
_, _ = w.Write([]byte(oauthCallbackHTML))
|
|
})); err != nil && !errors.Is(err, net.ErrClosed) {
|
|
applog.UserError("spotify: auth callback server error: %v", err)
|
|
}
|
|
}()
|
|
|
|
_ = browser.Open(authURL) // best-effort — user can open the URL manually if this fails
|
|
|
|
var code string
|
|
select {
|
|
case code = <-codeCh:
|
|
case <-ctx.Done():
|
|
return nil, fmt.Errorf("authentication cancelled: %w", ctx.Err())
|
|
}
|
|
|
|
token, err := oauthConf.Exchange(ctx, code, oauth2.VerifierOption(verifier))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("token exchange: %w", err)
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
// doWebAPIAuth performs an OAuth2 PKCE flow to get a fresh Web API access token.
|
|
// Opens a browser for user consent, returns the full token (including refresh token).
|
|
func doWebAPIAuth(ctx context.Context, clientID string) (*oauth2.Token, error) {
|
|
token, err := performOAuth2PKCE(ctx, clientID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fmt.Println("Spotify: Web API token refreshed.")
|
|
return token, nil
|
|
}
|
|
|
|
func newInteractiveSession(ctx context.Context, clientID string) (*Session, error) {
|
|
devID := generateDeviceID()
|
|
|
|
token, err := performOAuth2PKCE(ctx, clientID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("spotify: %w", err)
|
|
}
|
|
|
|
username, _ := token.Extra("username").(string)
|
|
accessToken := token.AccessToken
|
|
|
|
// Create go-librespot session using the OAuth2 token.
|
|
sess, err := session.NewSessionFromOptions(ctx, &session.Options{
|
|
Log: &librespot.NullLogger{},
|
|
DeviceType: devicespb.DeviceType_COMPUTER,
|
|
DeviceId: devID,
|
|
Credentials: session.SpotifyTokenCredentials{
|
|
Username: username,
|
|
Token: accessToken,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("spotify: session from token: %w", err)
|
|
}
|
|
|
|
// Persist stored credentials + refresh token for future sessions.
|
|
if err := saveCreds(&storedCreds{
|
|
Username: sess.Username(),
|
|
Data: sess.StoredCredentials(),
|
|
DeviceID: devID,
|
|
RefreshToken: token.RefreshToken,
|
|
}); err != nil {
|
|
applog.UserError("spotify: failed to save credentials: %v", err)
|
|
}
|
|
|
|
// Create an auto-refreshing token source for Web API calls.
|
|
conf := spotifyOAuthConfig(clientID)
|
|
ts := conf.TokenSource(context.Background(), token)
|
|
|
|
s := &Session{sess: sess, devID: devID, clientID: clientID, tokenSource: ts}
|
|
if err := s.initPlayer(); err != nil {
|
|
sess.Close()
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// initPlayer creates the go-librespot player. We only use NewStream() for
|
|
// decoded AudioSources — audio output is routed through cliamp's Beep pipeline,
|
|
// not go-librespot's output backend.
|
|
func (s *Session) initPlayer() error {
|
|
// go-librespot uses this for media restriction checks but Premium
|
|
// accounts can play all tracks regardless.
|
|
countryCode := "US"
|
|
p, err := librespotPlayer.NewPlayer(&librespotPlayer.Options{
|
|
Spclient: s.sess.Spclient(),
|
|
AudioKey: s.sess.AudioKey(),
|
|
Events: s.sess.Events(),
|
|
Log: &librespot.NullLogger{},
|
|
CountryCode: &countryCode,
|
|
NormalisationEnabled: true,
|
|
AudioBackend: "pipe",
|
|
AudioOutputPipe: os.DevNull,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("spotify: player init: %w", err)
|
|
}
|
|
s.player = p
|
|
return nil
|
|
}
|
|
|
|
// NewStream creates a decoded audio stream for the given Spotify track ID.
|
|
//
|
|
// Holds s.mu.RLock() across the librespot network call. Multiple concurrent
|
|
// NewStream / webApi callers can run in parallel (RLock is shared), so rapid
|
|
// track skipping does not serialize. reconnect() and Close() take the full
|
|
// Lock and will wait for in-flight callers to finish before tearing down the
|
|
// player — without this, the swap could call oldPlayer.Close() while we are
|
|
// still reading from it.
|
|
func (s *Session) NewStream(ctx context.Context, spotID librespot.SpotifyId, bitrate int) (*librespotPlayer.Stream, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.player == nil {
|
|
return nil, fmt.Errorf("spotify: session closed")
|
|
}
|
|
return s.player.NewStream(ctx, http.DefaultClient, spotID, bitrate, 0)
|
|
}
|
|
|
|
// webApiWithBody calls the Spotify Web API using the OAuth2 access token.
|
|
//
|
|
// The spclient/login5 token from librespot is NOT accepted by the Web API
|
|
// for endpoints like /v1/search and /v1/me/playlists — Spotify returns
|
|
// misleading errors ("Invalid limit", 429) instead of a clear auth failure.
|
|
// So if there is no OAuth2 token source, fail loudly with ErrNeedsAuth
|
|
// rather than attempting the call with the wrong token.
|
|
func (s *Session) webApiWithBody(ctx context.Context, method, path string, query url.Values, body io.Reader, contentType string) (*http.Response, error) {
|
|
s.mu.RLock()
|
|
ts := s.tokenSource
|
|
s.mu.RUnlock()
|
|
|
|
if ts == nil {
|
|
return nil, fmt.Errorf("spotify: web api token unavailable, run 'cliamp spotify reset' and sign in again: %w", playlist.ErrNeedsAuth)
|
|
}
|
|
tok, err := ts.Token()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("refresh access token: %w", err)
|
|
}
|
|
token := tok.AccessToken
|
|
|
|
u, _ := url.Parse("https://api.spotify.com")
|
|
u = u.JoinPath(path)
|
|
if query != nil {
|
|
u.RawQuery = query.Encode()
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Accept", "application/json")
|
|
if contentType != "" {
|
|
req.Header.Set("Content-Type", contentType)
|
|
}
|
|
|
|
return http.DefaultClient.Do(req)
|
|
}
|
|
|
|
// Close releases all session and player resources.
|
|
func (s *Session) Close() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.player != nil {
|
|
s.player.Close()
|
|
}
|
|
if s.sess != nil {
|
|
s.sess.Close()
|
|
}
|
|
}
|
|
|
|
// Reconnect rebuilds the session from stored credentials (no browser).
|
|
// Returns an error if stored credentials are missing or the refresh fails.
|
|
func (s *Session) Reconnect(ctx context.Context) error {
|
|
return s.reconnect(ctx, NewSessionSilent)
|
|
}
|
|
|
|
// ReconnectInteractive forces a fresh browser-based OAuth2 flow.
|
|
// Stored credentials are preserved until the new session succeeds —
|
|
// newInteractiveSession overwrites them via saveCreds on success.
|
|
func (s *Session) ReconnectInteractive(ctx context.Context) error {
|
|
return s.reconnect(ctx, newInteractiveSession)
|
|
}
|
|
|
|
// reconnect replaces the live session using the provided builder function.
|
|
// The new session is established before tearing down the old one to avoid a
|
|
// window where s.sess/s.player are nil (which would crash concurrent callers).
|
|
//
|
|
// The swap-and-teardown phase is done under s.mu (full Lock), which waits for
|
|
// any in-flight NewStream / webApi RLockers to drain. This guarantees that
|
|
// oldPlayer.Close() is never called while a NewStream is still using the
|
|
// old player pointer.
|
|
func (s *Session) reconnect(ctx context.Context, build func(context.Context, string) (*Session, error)) error {
|
|
s.mu.RLock()
|
|
clientID := s.clientID
|
|
s.mu.RUnlock()
|
|
|
|
newSess, err := build(ctx, clientID)
|
|
if err != nil {
|
|
return fmt.Errorf("spotify: reconnect: %w", err)
|
|
}
|
|
|
|
// Swap and tear down the old session under a single write lock so
|
|
// in-flight NewStream / webApi calls finish before oldPlayer.Close()
|
|
// runs. The expensive build() above happened lock-free.
|
|
s.mu.Lock()
|
|
oldPlayer := s.player
|
|
oldSess := s.sess
|
|
s.sess = newSess.sess
|
|
s.player = newSess.player
|
|
s.devID = newSess.devID
|
|
s.tokenSource = newSess.tokenSource
|
|
if oldPlayer != nil {
|
|
oldPlayer.Close()
|
|
}
|
|
if oldSess != nil {
|
|
oldSess.Close()
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
// Prevent newSess.Close() from tearing down the resources we just adopted.
|
|
newSess.mu.Lock()
|
|
newSess.sess = nil
|
|
newSess.player = nil
|
|
newSess.mu.Unlock()
|
|
|
|
const reauthMsg = "spotify: re-authenticated successfully"
|
|
applog.Info(reauthMsg)
|
|
applog.Status(reauthMsg)
|
|
return nil
|
|
}
|
|
|
|
func generateDeviceID() string {
|
|
b := make([]byte, 20)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func loadCreds() (*storedCreds, error) {
|
|
path, err := CredsPath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var creds storedCreds
|
|
if err := json.Unmarshal(data, &creds); err != nil {
|
|
return nil, err
|
|
}
|
|
return &creds, nil
|
|
}
|
|
|
|
func saveCreds(creds *storedCreds) error {
|
|
path, err := CredsPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.Marshal(creds)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, data, 0o600)
|
|
}
|