From 5357911054bb6cab0e0e3650527e02b351b6e1e2 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 27 Aug 2024 14:50:25 -0400 Subject: [PATCH] Implement spotify token and refresh flow from configuration * makes headless usage of sldl easier #42 * fallback to outputting login flow URL if opening a browser fails (in headless environment) * output token and refresh token on login complete * parse token and refresh token from config and attempt to use before invoking login flow --- README.md | 26 +++++-- slsk-batchdl/Config.cs | 10 +++ slsk-batchdl/Extractors/Spotify.cs | 108 +++++++++++++++++++++++++---- 3 files changed, 125 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index f5c8f80..350a9c6 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ Usage: sldl [OPTIONS] Spotify --spotify-id spotify client ID --spotify-secret spotify client secret + --spotify-token spotify access token + --spotify-refresh spotify refresh token --remove-from-source Remove downloaded tracks from source playlist ``` ``` @@ -243,10 +245,26 @@ Tip: For playlists containing music videos, it may be better to remove all text ### Spotify A playlist/album url or 'spotify-likes': Download a spotify playlist, album, or your -liked songs. --spotify-id and --spotify-secret are required in addition when downloading -a private playlist or liked music. -The id and secret can be obtained at https://developer.spotify.com/dashboard/applications. -Create an app and add http://localhost:48721/callback as a redirect url in its settings. +liked songs. Credentials are required when downloading a private playlist or liked music. + +#### Using Credential/Application + +Create a [Spotify application](https://developer.spotify.com/dashboard/applications) with a redirect url of `http://localhost:48721/callback`. Obtain an application **ID** and **Secret** from the created application dashboard. + +Start sldl with the obtained credentials and an authorized action to trigger the Spotify app login flow: + +```shell +sldl spotify-likes --number 1 --spotify-id 123456 --spotify-secret 123456 ... +``` +sldl will try to open a browser automatically but will fallback to logging the login flow URL to output. After login flow is complete sldl will output a **Token** and **Refresh Token** and finish running the current command. + +To skip requiring login flow every time `sldl` is used the **Token** and **Refresh Token** can be provided to sldl (hint: use `--config` and store this info in the config file to make commands less verbose): + +```shell +sldl spotify-likes --number 1 --spotify-id 123456 --spotify-secret 123456 --spotify-refresh 123456 --spotify-token 123456 ... +``` + +`spotify-token` access is only valid for 1 hour. `spotify-refresh` will enable sldl to renew access every time it is run (and can be used without including `spotify-token`) ### Bandcamp An bandcamp url: Download a single track, and album, or an artist's entire discography. diff --git a/slsk-batchdl/Config.cs b/slsk-batchdl/Config.cs index bbd84df..36c82f4 100644 --- a/slsk-batchdl/Config.cs +++ b/slsk-batchdl/Config.cs @@ -30,6 +30,8 @@ static class Config public static string defaultFolderName = ""; public static string spotifyId = ""; public static string spotifySecret = ""; + public static string spotifyToken = ""; + public static string spotifyRefresh = ""; public static string ytKey = ""; public static string username = ""; public static string password = ""; @@ -642,6 +644,14 @@ static class Config case "--spotify-secret": spotifySecret = args[++i]; break; + case "--stk": + case "--spotify-token": + spotifyToken = args[++i]; + break; + case "--str": + case "--spotify-refresh": + spotifyRefresh = args[++i]; + break; case "--yk": case "--youtube-key": ytKey = args[++i]; diff --git a/slsk-batchdl/Extractors/Spotify.cs b/slsk-batchdl/Extractors/Spotify.cs index 2958ca0..8cf8028 100644 --- a/slsk-batchdl/Extractors/Spotify.cs +++ b/slsk-batchdl/Extractors/Spotify.cs @@ -4,6 +4,7 @@ using Swan; using Data; using Enums; +using System.Security; namespace Extractors { @@ -34,15 +35,19 @@ namespace Extractors Config.spotifyId = Console.ReadLine(); Console.Write("Spotify client secret:"); Config.spotifySecret = Console.ReadLine(); + Console.Write("Spotify token:"); + Config.spotifyToken = Console.ReadLine(); + Console.Write("Spotify refresh token:"); + Config.spotifyRefresh = Console.ReadLine(); Console.WriteLine(); } - if (needLogin && (Config.spotifyId.Length == 0 || Config.spotifySecret.Length == 0)) + if (needLogin && Config.spotifyToken.Length == 0 && (Config.spotifyId.Length == 0 || Config.spotifySecret.Length == 0)) { readSpotifyCreds(); } - spotifyClient = new Spotify(Config.spotifyId, Config.spotifySecret); + spotifyClient = new Spotify(Config.spotifyId, Config.spotifySecret, Config.spotifyToken, Config.spotifyRefresh); await spotifyClient.Authorize(needLogin, Config.removeTracksFromSource); if (Config.input == "spotify-likes") @@ -132,6 +137,8 @@ namespace Extractors private EmbedIOAuthServer _server; private readonly string _clientId; private readonly string _clientSecret; + private string _clientToken; + private string _clientRefreshToken; private SpotifyClient? _client; private bool loggedIn = false; @@ -140,12 +147,14 @@ namespace Extractors public const string encodedSpotifySecret = "Y2JlM2QxYTE5MzJkNDQ2MmFiOGUy3shTuf4Y2JhY2M3ZDdjYWU="; public bool UsedDefaultCredentials { get; private set; } - public Spotify(string clientId = "", string clientSecret = "") + public Spotify(string clientId = "", string clientSecret = "", string token = "", string refreshToken = "") { _clientId = clientId; _clientSecret = clientSecret; + _clientToken = token; + _clientRefreshToken = refreshToken; - if (_clientId.Length == 0 || _clientSecret.Length == 0) + if (_clientToken.Length == 0 && (_clientId.Length == 0 || _clientSecret.Length == 0)) { _clientId = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encodedSpotifyId.Replace("1bLaH9", ""))); _clientSecret = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encodedSpotifySecret.Replace("3shTuf4", ""))); @@ -172,27 +181,91 @@ namespace Extractors _server = new EmbedIOAuthServer(new Uri("http://localhost:48721/callback"), 48721); await _server.Start(); - _server.AuthorizationCodeReceived += OnAuthorizationCodeReceived; - _server.ErrorReceived += OnErrorReceived; + var existingOk = false; + if (_clientToken.Length != 0 || _clientRefreshToken.Length != 0) + { + existingOk = await this.TryExistingToken(); + loggedIn = true; + //new OAuthClient(config).RequestToken() + } + if (!existingOk) + { + _server.AuthorizationCodeReceived += OnAuthorizationCodeReceived; + _server.ErrorReceived += OnErrorReceived; - var scope = new List { + var scope = new List { Scopes.UserLibraryRead, Scopes.PlaylistReadPrivate, Scopes.PlaylistReadCollaborative }; - if (needModify) - { - scope.Add(Scopes.PlaylistModifyPublic); - scope.Add(Scopes.PlaylistModifyPrivate); + if (needModify) + { + scope.Add(Scopes.PlaylistModifyPublic); + scope.Add(Scopes.PlaylistModifyPrivate); + } + + var request = new LoginRequest(_server.BaseUri, _clientId, LoginRequest.ResponseType.Code) { Scope = scope }; + + try + { + BrowserUtil.Open(request.ToUri()); + } + catch (Exception) + { + Console.WriteLine("Unable to open URL, manually open: {0}", request.ToUri()); + } } - var request = new LoginRequest(_server.BaseUri, _clientId, LoginRequest.ResponseType.Code) { Scope = scope }; - - BrowserUtil.Open(request.ToUri()); - await IsClientReady(); } } + private async Task TryExistingToken() + { + if (_clientToken.Length != 0) + { + Console.WriteLine("Testing Spotify access with existing token..."); + var client = new SpotifyClient(_clientToken); + try + { + var me = await client.UserProfile.Current(); + Console.WriteLine("Spotify access is good!"); + _client = client; + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Could not make an API call with existing token: {ex}"); + } + } + if (_clientRefreshToken.Length != 0) + { + Console.WriteLine("Trying to renew access with refresh token..."); + // var refreshRequest = new TokenSwapRefreshRequest( + // new Uri("http://localhost:48721/refresh"), + // _clientRefreshToken + // ); + var refreshRequest = new AuthorizationCodeRefreshRequest(_clientId, _clientSecret, _clientRefreshToken); + try + { + var oauthClient = new OAuthClient(); + var refreshResponse = await oauthClient.RequestToken(refreshRequest); + Console.WriteLine($"We got a new refreshed access token from server: {refreshResponse.AccessToken}"); + _clientToken = refreshResponse.AccessToken; + _client = new SpotifyClient(_clientToken); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Could not refresh access token with refresh token: {ex}"); + } + } else { + Console.WriteLine("No refresh token present, cannot refresh existing access"); + } + + Console.WriteLine("Not possible to access Spotify API without login! Falling back to login flow..."); + return false; + } + private async Task OnAuthorizationCodeReceived(object sender, AuthorizationCodeResponse response) { await _server.Stop(); @@ -204,6 +277,11 @@ namespace Extractors ) ); + Console.WriteLine("Spotify token: " + tokenResponse.AccessToken); + _clientToken = tokenResponse.AccessToken; + Console.WriteLine("Spotify refresh token: " + tokenResponse.RefreshToken); + _clientRefreshToken = tokenResponse.RefreshToken; + _client = new SpotifyClient(tokenResponse.AccessToken); loggedIn = true; }