From 19234ec800a84c850bdca7d5df67593464c8892a Mon Sep 17 00:00:00 2001 From: Nikolas Mavropoulos Date: Tue, 2 Feb 2021 01:32:28 +0200 Subject: [PATCH] Implemented Several Features & Bug Fixes - Fixed #76, #73, #70 - Implemented #75, #74, #72, #71, #69 --- .../SpotNetCore/Endpoints/AlbumEndpoint.cs | 28 ++ .../SpotNetCore/Endpoints/ArtistEndpoint.cs | 27 ++ .../SpotNetCore/Endpoints/PlayerEndpoint.cs | 63 +++++ .../SpotNetCore/Endpoints/PlaylistEndpoint.cs | 22 ++ .../SpotNetCore/Endpoints/SearchEndpoint.cs | 46 +++ .../Implementation/AppConstants.cs | 37 +++ .../Implementation/AuthenticationManager.cs | 185 +++++++------ .../Implementation/CommandHandler.cs | 261 ++++++++++++------ .../SpotNetCore/Implementation/Commands.cs | 1 + .../Implementation/CyclicLimitedList.cs | 84 ++++++ .../Implementation/SpotifyCallbackListener.cs | 131 +++++++++ .../Implementation/SpotifyHttpClient.cs | 25 ++ .../SpotifyHttpClientHandler.cs | 36 +++ .../SpotNetCore/Implementation/Terminal.cs | 6 + SpotNetCore/SpotNetCore/Models/AppSettings.cs | 77 ++++++ .../SpotNetCore/Models/SettingOption.cs | 7 + .../SpotNetCore/Models/SpotifyAccessToken.cs | 99 ++++++- .../SpotNetCore/Models/SpotifyAlbum.cs | 1 - .../SpotNetCore/Models/SpotifyCommand.cs | 3 +- .../SpotNetCore/Models/SpotifySearchResult.cs | 28 ++ SpotNetCore/SpotNetCore/Program.cs | 38 ++- .../SpotNetCore/Services/AlbumService.cs | 47 +--- .../SpotNetCore/Services/ArtistService.cs | 59 +--- .../SpotNetCore/Services/PlayerService.cs | 62 +---- .../SpotNetCore/Services/PlaylistService.cs | 49 +--- .../SpotNetCore/Services/SearchService.cs | 175 ++++-------- SpotNetCore/SpotNetCore/SpotNetCore.csproj | 18 +- .../SpotifyHttpClientJsonExtensions.cs | 19 ++ SpotNetCore/SpotNetCore/Startup.cs | 34 --- SpotNetCore/SpotNetCore/StringExtensions.cs | 16 ++ SpotNetCore/SpotNetCore/appsettings.json | 1 + 31 files changed, 1148 insertions(+), 537 deletions(-) create mode 100644 SpotNetCore/SpotNetCore/Endpoints/AlbumEndpoint.cs create mode 100644 SpotNetCore/SpotNetCore/Endpoints/ArtistEndpoint.cs create mode 100644 SpotNetCore/SpotNetCore/Endpoints/PlayerEndpoint.cs create mode 100644 SpotNetCore/SpotNetCore/Endpoints/PlaylistEndpoint.cs create mode 100644 SpotNetCore/SpotNetCore/Endpoints/SearchEndpoint.cs create mode 100644 SpotNetCore/SpotNetCore/Implementation/AppConstants.cs create mode 100644 SpotNetCore/SpotNetCore/Implementation/CyclicLimitedList.cs create mode 100644 SpotNetCore/SpotNetCore/Implementation/SpotifyCallbackListener.cs create mode 100644 SpotNetCore/SpotNetCore/Implementation/SpotifyHttpClient.cs create mode 100644 SpotNetCore/SpotNetCore/Implementation/SpotifyHttpClientHandler.cs create mode 100644 SpotNetCore/SpotNetCore/Models/AppSettings.cs create mode 100644 SpotNetCore/SpotNetCore/Models/SettingOption.cs create mode 100644 SpotNetCore/SpotNetCore/Models/SpotifySearchResult.cs create mode 100644 SpotNetCore/SpotNetCore/SpotifyHttpClientJsonExtensions.cs delete mode 100644 SpotNetCore/SpotNetCore/Startup.cs create mode 100644 SpotNetCore/SpotNetCore/StringExtensions.cs diff --git a/SpotNetCore/SpotNetCore/Endpoints/AlbumEndpoint.cs b/SpotNetCore/SpotNetCore/Endpoints/AlbumEndpoint.cs new file mode 100644 index 0000000..fc54a66 --- /dev/null +++ b/SpotNetCore/SpotNetCore/Endpoints/AlbumEndpoint.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using SpotNetCore.Models; + +namespace SpotNetCore.Endpoints +{ + public class AlbumEndpoint + { + private HttpClient _httpClient; + + internal AlbumEndpoint(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task> GetAlbumTracks(String albumId) + { + return await _httpClient.GetFromSpotifyJsonAsync>($"/v1/albums/{albumId}/tracks"); + } + + public async Task> GetAlbumTracks(SpotifyAlbum album) + { + return await GetAlbumTracks(album.Id); + } + } +} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Endpoints/ArtistEndpoint.cs b/SpotNetCore/SpotNetCore/Endpoints/ArtistEndpoint.cs new file mode 100644 index 0000000..3833431 --- /dev/null +++ b/SpotNetCore/SpotNetCore/Endpoints/ArtistEndpoint.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using SpotNetCore.Models; + +namespace SpotNetCore.Endpoints +{ + public class ArtistEndpoint + { + private HttpClient _httpClient; + + public ArtistEndpoint(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task> GetArtistTopTracks(string artistId) + { + return await _httpClient.GetFromSpotifyJsonAsync>($"/v1/artists/{artistId}/top-tracks"); + } + + public async Task> GetArtistAlbums(string artistId) + { + return await _httpClient.GetFromSpotifyJsonAsync>($"/v1/artists/{artistId}/albums?include_groups=album"); + } + } +} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Endpoints/PlayerEndpoint.cs b/SpotNetCore/SpotNetCore/Endpoints/PlayerEndpoint.cs new file mode 100644 index 0000000..a70ffda --- /dev/null +++ b/SpotNetCore/SpotNetCore/Endpoints/PlayerEndpoint.cs @@ -0,0 +1,63 @@ +using System.Net.Http; +using System.Threading.Tasks; +using SpotNetCore.Models; + +namespace SpotNetCore.Endpoints +{ + public class PlayerEndpoint + { + private readonly HttpClient _httpClient; + + internal PlayerEndpoint(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task Play() + { + var response = await _httpClient.PutAsync("/v1/me/player/play", null); + return response; + } + + public async Task Pause() + { + var response = await _httpClient.PutAsync("/v1/me/player/pause", null); + return response; + } + + public async Task Next() + { + var response = await _httpClient.PostAsync("/v1/me/player/next", null); + return response; + } + + public async Task Previous() + { + var response = await _httpClient.PostAsync("/v1/me/player/previous", null); + return response; + } + + public async Task Seek(int milliseconds) + { + var response = await _httpClient.PutAsync($"/v1/me/player/seek?position_ms={milliseconds}", null); + return response; + } + + public async Task Shuffle(bool state) + { + var response = await _httpClient.PutAsync($"/v1/me/player/shuffle?state={state}", null); + return response; + } + + public async Task Queue(string trackUri) + { + var response = await _httpClient.PostAsync($"/v1/me/player/queue?uri={trackUri}", null); + return response; + } + + public async Task Player() + { + return await _httpClient.GetFromSpotifyJsonAsync("/v1/me/player"); + } + } +} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Endpoints/PlaylistEndpoint.cs b/SpotNetCore/SpotNetCore/Endpoints/PlaylistEndpoint.cs new file mode 100644 index 0000000..9d24418 --- /dev/null +++ b/SpotNetCore/SpotNetCore/Endpoints/PlaylistEndpoint.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using SpotNetCore.Models; + +namespace SpotNetCore.Endpoints +{ + public class PlaylistEndpoint + { + private HttpClient _httpClient; + + public PlaylistEndpoint(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public Task> GetPlaylistTracks(string playlistId) + { + return _httpClient.GetFromSpotifyJsonAsync>($"/v1/playlists/{playlistId}/tracks"); + } + } +} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Endpoints/SearchEndpoint.cs b/SpotNetCore/SpotNetCore/Endpoints/SearchEndpoint.cs new file mode 100644 index 0000000..f9ff683 --- /dev/null +++ b/SpotNetCore/SpotNetCore/Endpoints/SearchEndpoint.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using SpotNetCore.Models; + +namespace SpotNetCore.Endpoints +{ + public class SearchEndpoint + { + private HttpClient _httpClient; + + public SearchEndpoint(HttpClient httpClient) + { + _httpClient = httpClient; + } + + private async Task Search(string query, string type, int limit = 10, int offset = 0) + { + return await _httpClient.GetFromSpotifyJsonAsync($"/v1/search?q={query}&type={type}&limit={limit}&offset={offset}"); + } + + public async Task> SearchTracks(string query, int limit = 10, int offset = 0) + { + var data = await Search(query, "track", limit, offset); + return data.Tracks.Items; + } + + public async Task> SearchAlbums(string query, int limit = 10, int offset = 0) + { + var data = await Search(query, "album", limit, offset); + return data.Albums.Items; + } + + public async Task> SearchPlaylists(string query, int limit = 10, int offset = 0) + { + var data = await Search(query, "playlist", limit, offset); + return data.Playlists.Items; + } + + public async Task> SearchArtists(string query, int limit = 10, int offset = 0) + { + var data = await Search(query, "artist", limit, offset); + return data.Artists.Items; + } + } +} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Implementation/AppConstants.cs b/SpotNetCore/SpotNetCore/Implementation/AppConstants.cs new file mode 100644 index 0000000..e9bff0f --- /dev/null +++ b/SpotNetCore/SpotNetCore/Implementation/AppConstants.cs @@ -0,0 +1,37 @@ +namespace SpotNetCore.Implementation +{ + public class AppConstants + { + public static readonly int CallbackPort = 5000; + public static readonly string Prompt = "Spotify> "; + public static readonly string SuccessHtml = @" + + SpotNetCore - Spotify | Authentication Succeed + +
+

SpotNetCore

+
+
+

Authentication complete

+
You can return to the application. Feel free to close this browser tab.
+
+ + "; + + public static readonly string ErrorHtml = @" + + SpotNetCore - Spotify | Authentication Failed + +
+

SpotNetCore

+
+
+

Authentication failed

+
Error details: error {0} error_description: {1}
+
+
You can return to the application. Feel free to close this browser tab.
+
+ + "; + } +} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Implementation/AuthenticationManager.cs b/SpotNetCore/SpotNetCore/Implementation/AuthenticationManager.cs index f80bab9..281b82f 100644 --- a/SpotNetCore/SpotNetCore/Implementation/AuthenticationManager.cs +++ b/SpotNetCore/SpotNetCore/Implementation/AuthenticationManager.cs @@ -2,115 +2,157 @@ using System.Collections.Generic; using System.Net.Http; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; +using System.Web; using SpotNetCore.Models; namespace SpotNetCore.Implementation { public class AuthenticationManager : IDisposable { - private readonly IConfigurationRoot _config; - public static bool IsAuthenticated; - private static string _codeVerifier; - public SpotifyAccessToken Token; + public bool IsAuthenticated { get; private set; } + public SpotifyAccessToken Token => _appSettings?.SpotifyTokens; + + private readonly AppSettings _appSettings; + private static string _codeVerifier; private readonly HttpClient _httpClient; - public AuthenticationManager(IConfigurationRoot config) + public AuthenticationManager(AppSettings settingsConfig) { - _config = config; + _appSettings = settingsConfig; _codeVerifier = AuthorisationCodeDetails.CreateCodeVerifier(); _httpClient = new HttpClient(); } - ~AuthenticationManager() - { - Dispose(false); - } - - public bool IsTokenAboutToExpire() => Token.ExpiresAt <= DateTime.Now.AddSeconds(20); - - private async Task GetAuthToken() + public bool IsTokenAboutToExpire() => _appSettings.SpotifyTokens.ExpiresAt <= DateTime.Now.AddSeconds(20); + + private async Task ListenForCallbackAndGetResult(CancellationToken token) { - await Task.Run(() => + var success = false; + using var listener = new SpotifyCallbackListener(5000); + await listener.ListenToSingleRequestAndRespondAsync(async uri => { - WebHost.CreateDefaultBuilder(null) - .Configure(y => + var query = HttpUtility.ParseQueryString(HttpUtility.HtmlDecode(uri.Query)); + var response = await _httpClient.PostAsync("https://accounts.spotify.com/api/token", + new FormUrlEncodedContent(new Dictionary { - y.UseRouting(); - y.UseEndpoints(endpoints => - { - endpoints.MapGet("/", async context => - { - await context.Response.CompleteAsync(); - - var response = await _httpClient.PostAsync("https://accounts.spotify.com/api/token", - new FormUrlEncodedContent(new Dictionary - { - {"code", context.Request.Query["code"].ToString()}, - {"client_id", _config.GetSection("clientId").Value}, - {"grant_type", "authorization_code"}, - {"redirect_uri", "http://localhost:5000/"}, - {"code_verifier", _codeVerifier} - })); - - response.EnsureSuccessStatusCode(); - - Token = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); - - Token.ExpiresAt = DateTime.Now.AddSeconds(Token.ExpiresInSeconds); - IsAuthenticated = true; - }); - }); - }).Build().RunAsync(); - }); + {"code", query["code"]}, + {"client_id", _appSettings.ClientId}, + {"grant_type", "authorization_code"}, + {"redirect_uri", "http://localhost:5000/"}, + {"code_verifier", _codeVerifier} + }), token); + + if (!response.IsSuccessStatusCode) + { + return string.Format(AppConstants.ErrorHtml, response.StatusCode, await response.Content.ReadAsStringAsync(token)); + } + _appSettings.SpotifyTokens = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(token)); + + _appSettings.SpotifyTokens!.ExpiresAt = DateTime.Now.AddSeconds(_appSettings.SpotifyTokens.ExpiresInSeconds); + IsAuthenticated = true; + success = true; + return AppConstants.SuccessHtml; + }, token); + return success; } - public async Task RequestRefreshedAccessToken() + private async Task GetAuthToken() + { + using var listener = new SpotifyCallbackListener(5000); + var cts = new CancellationTokenSource(); + var result = await ListenForCallbackAndGetResult(cts.Token); + cts.Cancel(); + return result; + } + + public async Task RequestRefreshedAccessToken() { var response = await _httpClient.PostAsync("https://accounts.spotify.com/api/token", new FormUrlEncodedContent(new Dictionary { {"grant_type", "refresh_token"}, - {"refresh_token", Token.RefreshToken}, - {"client_id", _config.GetSection("clientId").Value} + {"refresh_token", _appSettings.SpotifyTokens.RefreshToken}, + {"client_id", _appSettings.ClientId} })); - response.EnsureSuccessStatusCode(); + if (!response.IsSuccessStatusCode) + return false; - Token = JsonSerializer.Deserialize( + _appSettings.SpotifyTokens = JsonSerializer.Deserialize( await response.Content.ReadAsStringAsync()); - if (Token != null) + if (_appSettings.SpotifyTokens != null) { - Token.ExpiresAt = DateTime.Now.AddSeconds(Token.ExpiresInSeconds); + _appSettings.SpotifyTokens.ExpiresAt = DateTime.Now.AddSeconds(_appSettings.SpotifyTokens.ExpiresInSeconds); } + + IsAuthenticated = true; + return true; } public async Task Authenticate() { - await GetAuthToken(); + async Task RequestNewSession() + { + Terminal.WriteLine("Please authorise this application to use Spotify on your behalf"); + SpotifyUrlHelper.RunUrl(GetAuthorisationUrl(_codeVerifier)); + if (!await GetAuthToken()) + throw new NotAuthenticatedException(); + } - Terminal.WriteLine("Please authorise this application to use Spotify on your behalf"); - SpotifyUrlHelper.RunUrl(GetAuthorisationUrl(_codeVerifier)); - Terminal.ReadLine(); + if (_appSettings.SpotifyTokens?.ExpiresAt != null) + { + if (_appSettings.SpotifyTokens?.ExpiresAt < DateTime.Now) + { + if (!await RequestRefreshedAccessToken()) + { + await RequestNewSession(); + } + } + else if (await AreCachedCredentialsStillValid()) + { + IsAuthenticated = true; + } + } + else + { + RequestNewSession(); + } + } + + private async Task AreCachedCredentialsStillValid() + { + using var httpClient = new HttpClient + { + DefaultRequestHeaders = + { + Authorization = new("Bearer", _appSettings.SpotifyTokens.AccessToken) + } + }; + var responseMessage = await httpClient.GetAsync("https://api.spotify.com/v1/me"); + return responseMessage.IsSuccessStatusCode; } private string GetAuthorisationUrl(string codeVerifier) { var details = new AuthorisationCodeDetails(codeVerifier, "http://localhost:5000/"); - var scopes = _config.GetSection("requiredScopes").Get>(); - details.AuthorisationUri = BuildAuthorisationUri(_config.GetSection("clientId").Value, details.RedirectUri, details.CodeChallenge, "fh82hfosdf8h", string.Join("%20", scopes)); - + var scopes = _appSettings.RequiredScopes; + details.AuthorisationUri = BuildAuthorisationUri( + _appSettings.ClientId, + details.RedirectUri, + details.CodeChallenge, + "fh82hfosdf8h", + string.Join("%20", scopes) + ); + return details.AuthorisationUri; } private string BuildAuthorisationUri(string clientId, string redirectUri, string codeChallenge, string state, string scopes) { - return new UriBuilder() + return new UriBuilder { Scheme = "https", Host = "accounts.spotify.com", @@ -119,23 +161,12 @@ private string BuildAuthorisationUri(string clientId, string redirectUri, string }.Uri.ToString(); } - private static string BuildAuthorisationQuery(string clientId, string redirectUri, string codeChallenge, string state, string scopes) - { - return "?client_id=" + clientId + "&response_type=code" - + "&redirect_uri=" + redirectUri + "&code_challenge_method=S256" - + "&code_challenge=" + codeChallenge + "&state=" + state + "&scope=" + Uri.EscapeUriString(scopes); - } - - private void Dispose(bool disposing) - { - if (!disposing) return; + private static string BuildAuthorisationQuery(string clientId, string redirectUri, string codeChallenge, string state, string scopes) => + $"?client_id={clientId}&response_type=code&redirect_uri={redirectUri}&code_challenge_method=S256&code_challenge={codeChallenge}&state={state}&scope={Uri.EscapeUriString(scopes)}"; - _httpClient?.Dispose(); - } - public void Dispose() { - Dispose(true); + _httpClient?.Dispose(); GC.SuppressFinalize(this); } } diff --git a/SpotNetCore/SpotNetCore/Implementation/CommandHandler.cs b/SpotNetCore/SpotNetCore/Implementation/CommandHandler.cs index 129ecab..111bee3 100644 --- a/SpotNetCore/SpotNetCore/Implementation/CommandHandler.cs +++ b/SpotNetCore/SpotNetCore/Implementation/CommandHandler.cs @@ -1,11 +1,10 @@ using System; -using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; using SpotNetCore.Models; using SpotNetCore.Services; @@ -14,31 +13,33 @@ namespace SpotNetCore.Implementation /// /// Main application loop, parses and executes commands /// - public class CommandHandler : IDisposable + public class CommandHandler { private readonly AuthenticationManager _authenticationManager; private readonly PlayerService _playerService; private readonly SearchService _searchService; - private static readonly List _listOfCommands = Commands.GetCommandsList(); + private readonly AppSettings _appSettings; + private static readonly List ListOfCommands = Commands.GetCommandsList(); + private readonly CyclicLimitedList _commandHistory; + - public CommandHandler(AuthenticationManager authenticationManager, PlayerService playerService, SearchService searchService) + public CommandHandler(AuthenticationManager authenticationManager, AppSettings appSettings, PlayerService playerService, SearchService searchService) { + _appSettings = appSettings; _authenticationManager = authenticationManager; _playerService = playerService; _searchService = searchService; - } - - ~CommandHandler() - { - Dispose(false); + _commandHistory = new(); } public async Task HandleCommands() { - var exit = false; - while (!exit) + while (true) { - var command = ParseCommand(GetUserInput()); + ClearCurrentLine(); + var input = GetUserInput(); + var command = ParseCommand(input); + _commandHistory.Add(input); var spotifyCommand = command.Command.Trim().ToLower() switch { @@ -61,6 +62,7 @@ public async Task HandleCommands() "queue" => SpotifyCommand.Queue, "current" => SpotifyCommand.Current, "clear" => SpotifyCommand.ClearQueue, + "settings" => SpotifyCommand.Settings, _ => SpotifyCommand.Invalid }; @@ -72,7 +74,6 @@ public async Task HandleCommands() if (spotifyCommand == SpotifyCommand.Exit) { - exit = true; break; } @@ -82,7 +83,7 @@ public async Task HandleCommands() HelpManager.DisplayHelp(); } - if (!AuthenticationManager.IsAuthenticated) + if (!_authenticationManager.IsAuthenticated) { throw new NotAuthenticatedException(); } @@ -134,6 +135,30 @@ public async Task HandleCommands() await _playerService.ShuffleToggle(toggle); } + if (spotifyCommand == SpotifyCommand.Settings) + { + var option = command.Parameters.FirstOrDefault(x => x.Parameter.ToLower() != "settings"); + + if (option == null || !Enum.TryParse(option.Parameter.ToLower().FirstCharToUpper(), out SettingOption settingOption)) + { + HelpManager.DisplayHelp(); + continue; + } + + switch (settingOption) + { + case SettingOption.Market: + if (!option.Query.IsNullOrEmpty()) + { + _appSettings.Market = option.Query; + Terminal.WriteLine($"Market set to: {_appSettings.Market}"); + } + else + Terminal.WriteLine($"Market: {_appSettings.Market}"); + break; + } + } + if (spotifyCommand == SpotifyCommand.Queue) { //At least one parameter is required @@ -169,9 +194,9 @@ public async Task HandleCommands() try { - album = await _searchService.SearchForAlbum(parameter.Query); + album = (await _searchService.SearchForAlbum(parameter.Query))?.FirstOrDefault(); } - catch (NoSearchResultException e) + catch (NoSearchResultException) { Terminal.WriteRed($"Could not find album {parameter.Query}"); break; @@ -201,9 +226,9 @@ public async Task HandleCommands() try { - playlist = await _searchService.SearchForPlaylist(parameter.Query); + playlist = (await _searchService.SearchForPlaylist(parameter.Query,1))?.FirstOrDefault(); } - catch (NoSearchResultException e) + catch (NoSearchResultException) { Terminal.WriteYellow($"Could not find playlist {parameter.Query}"); break; @@ -224,20 +249,20 @@ public async Task HandleCommands() var option = command.Parameters.FirstOrDefault(x => x.Parameter.ToLower() != "artist"); var artistOption = option?.Query switch { - "d" => ArtistOption.Discography, + "d" => ArtistOption.Discography, "discography" => ArtistOption.Discography, - "p" => ArtistOption.Popular, - "popular" => ArtistOption.Popular, - "e" => ArtistOption.Essential, - "essential" => ArtistOption.Essential, - _ => ArtistOption.Essential + "p" => ArtistOption.Popular, + "popular" => ArtistOption.Popular, + "e" => ArtistOption.Essential, + "essential" => ArtistOption.Essential, + _ => ArtistOption.Essential }; SpotifyArtist artist; try { - artist = await _searchService.SearchForArtist(parameter.Query, artistOption); + artist = (await _searchService.SearchForArtist(parameter.Query, artistOption, 1))?.FirstOrDefault(); } catch (NoSearchResultException e) { @@ -274,7 +299,7 @@ public async Task HandleCommands() } } - private static string GetUserInput() + private string GetUserInput() { return GetInputWithAutoComplete(); } @@ -283,110 +308,133 @@ private static string GetUserInput() /// Gets User Input By Suggesting the Autocomplete /// /// User input - private static string GetInputWithAutoComplete() + private string GetInputWithAutoComplete() { // to store the user input till now - StringBuilder stringBuilder = new StringBuilder(); + ConsoleInput consoleInput = new ConsoleInput(); // to store the current key entered by the user - var userInput = Console.ReadKey(intercept: true); - - // Process input until user presses Enter - while (ConsoleKey.Enter != userInput.Key) + ConsoleKeyInfo userInput; + do { + userInput = Console.ReadKey(intercept: true); // If Tab if (ConsoleKey.Tab == userInput.Key) { - HandleTabInput(stringBuilder); + HandleTabInput(consoleInput); + } + else if (ConsoleKey.Enter == userInput.Key) + { + break; } - // Non Tab Key else { - HandleKeyInput(stringBuilder, userInput); + HandleKeyInput(consoleInput, userInput); } - // read next key entered by user - userInput = Console.ReadKey(intercept: true); - } - + } while (ConsoleKey.Enter != userInput.Key); // when user presses enter, move the cursor to the next line Console.Write("\n"); // return the user input - return stringBuilder.ToString(); + return consoleInput; } /// /// Processes the user input which is not a tab /// - /// string builder which stores the user input till now + /// Console Input which stores the user input till now /// the current user input - private static void HandleKeyInput(StringBuilder stringBuilder, ConsoleKeyInfo userInput) + private void HandleKeyInput(ConsoleInput consoleInput, ConsoleKeyInfo userInput) { // current input - string currentInput = stringBuilder.ToString(); + string currentInput = consoleInput; // Handle backspace - if(ConsoleKey.Backspace == userInput.Key ) + if(ConsoleKey.Backspace == userInput.Key) { // if user has pressed backspace, remove the last character from the console output and current input - if(currentInput.Length > 0) + if(currentInput.Length > 0 && consoleInput.CurrentIndex > 0) { - // remove from current input - stringBuilder.Remove(stringBuilder.Length - 1, 1); + var number = consoleInput.CurrentIndex > 0 ? consoleInput.CurrentIndex - 1 : 0; + consoleInput.Remove(number, 1); // clear the line ClearCurrentLine(); // remove from string and print on console - currentInput = currentInput.Remove(currentInput.Length - 1); + currentInput = consoleInput; Console.Write(currentInput); + Terminal.SetCursorPosition(number,Console.CursorTop); } } - - // if key is space bar, add " " to the input - else if(ConsoleKey.Spacebar == userInput.Key) + else if (ConsoleKey.UpArrow == userInput.Key) { - stringBuilder.Append(" "); - Console.Write(" "); + ClearCurrentLine(); + consoleInput.Clear(); + currentInput = _commandHistory.GetPrevious(); + consoleInput.Append(currentInput); + + Console.Write($"{currentInput}"); + } + else if (ConsoleKey.DownArrow == userInput.Key) + { + ClearCurrentLine(); + consoleInput.Clear(); + currentInput = _commandHistory.GetNext(); + consoleInput.Append(currentInput); + + Console.Write($"{currentInput}"); + } + else if (ConsoleKey.LeftArrow == userInput.Key) + { + var number = consoleInput.CurrentIndex > 0 ? --consoleInput.CurrentIndex : 0; + + Terminal.SetCursorPosition(number,Console.CursorTop); + } + else if (ConsoleKey.RightArrow == userInput.Key) + { + var number = consoleInput.CurrentIndex < consoleInput.Length? ++consoleInput.CurrentIndex : consoleInput.Length; + Terminal.SetCursorPosition(number,Console.CursorTop); } - // all other keys else { // To Lower is done because when we read a key using Console.ReadKey(), // the uppercase is returned irrespective of the case of the user input. - var key = userInput.Key; - stringBuilder.Append(key.ToString().ToLower()); - Console.Write(key.ToString().ToLower()); + var key = userInput.KeyChar; + consoleInput.Insert(key); + ClearCurrentLine(); + Console.Write(consoleInput); + Terminal.SetCursorPosition(consoleInput.CurrentIndex,Console.CursorTop); } } /// /// Handle Tab Input /// - /// String Builder - private static void HandleTabInput(StringBuilder stringBuilder) + /// Console Input + private void HandleTabInput(ConsoleInput consoleInput) { // current input - string currentInput = stringBuilder.ToString(); + string currentInput = consoleInput; // check if input is already a part of the commands list - int indexOfInput = _listOfCommands.IndexOf(currentInput); + int indexOfInput = ListOfCommands.IndexOf(currentInput); // match - string match = ""; + string match; // if input is a part of the commands list : // then display the next command in alphabetical order if(-1 != indexOfInput) { - match = indexOfInput + 1 < _listOfCommands.Count() ? _listOfCommands[indexOfInput + 1] : ""; + match = indexOfInput + 1 < ListOfCommands.Count() ? ListOfCommands[indexOfInput + 1] : ""; } // if input isnt in the commands list, find the first match else { - match = _listOfCommands.FirstOrDefault(m => m.StartsWith(currentInput, true, CultureInfo.InvariantCulture)); + match = ListOfCommands.FirstOrDefault(m => m.StartsWith(currentInput, true, CultureInfo.InvariantCulture)); } // no match @@ -397,11 +445,11 @@ private static void HandleTabInput(StringBuilder stringBuilder) // clear line and current input ClearCurrentLine(); - stringBuilder.Clear(); + consoleInput.Clear(); // set line and current input to the current match Console.Write(match); - stringBuilder.Append(match); + consoleInput.Append(match); } /// @@ -413,6 +461,7 @@ private static void ClearCurrentLine() Console.SetCursorPosition(0, Console.CursorTop); Console.Write(new string(' ', Console.WindowWidth)); Console.SetCursorPosition(0, currentLine); + Console.Write(AppConstants.Prompt); } private static ParsedCommand ParseCommand(string input) @@ -423,29 +472,79 @@ private static ParsedCommand ParseCommand(string input) } var split = input.Split(new[] { "-", "--" }, StringSplitOptions.RemoveEmptyEntries); - - return new ParsedCommand + //var split = input.Split(" "); + var p =new ParsedCommand { Command = split[0].Trim(), - Parameters = split.Skip(1).Select(x => new ParsedParameter() + Parameters = split.Skip(1).Select(x => { - Parameter = x.Substring(0, x.IndexOf(' ') + 1).Trim(), - Query = x.Substring(x.IndexOf(' ') + 1).Trim() + var match = Regex.Match(x.Trim(), "^(?'Key'[A-z0-9]+)(?:[= ](?'Value'.+))?$"); + return new ParsedParameter() + { + Parameter = match.Groups["Key"].Value.Trim(), + Query = match.Groups["Value"].Value.Trim() + }; }) }; - } - - private void Dispose(bool disposing) - { - if (!disposing) return; - _playerService?.Dispose(); + return p; } - public void Dispose() + public class ConsoleInput { - Dispose(true); - GC.SuppressFinalize(this); + private StringBuilder Buffer { get; } = new StringBuilder(); + public int CurrentIndex { get; set; } + + public ConsoleInput Append(String str) + { + Buffer.Append(str); + CurrentIndex += str.Length; + return this; + } + public ConsoleInput Append(Char c) + { + Buffer.Append(c); + CurrentIndex++; + return this; + } + + public ConsoleInput Insert(char c, int index = -1) => Insert(c.ToString(), index); + + public ConsoleInput Insert(String str, int index = -1) + { + if (index == -1) + { + index = CurrentIndex; + } + Buffer.Insert(index, str); + CurrentIndex = index + str.Length; + return this; + } + + public ConsoleInput Clear() + { + Buffer.Clear(); + CurrentIndex = 0; + return this; + } + + public ConsoleInput Remove(int offset, int count) + { + Buffer.Remove(offset, count); + CurrentIndex = offset - count + 1; + return this; + } + + public override string ToString() + { + return Buffer.ToString(); + } + + public static implicit operator string(ConsoleInput consoleInput) + { + return consoleInput.ToString();} + + public int Length => Buffer.Length; } } } \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Implementation/Commands.cs b/SpotNetCore/SpotNetCore/Implementation/Commands.cs index 464dde4..39c02fb 100644 --- a/SpotNetCore/SpotNetCore/Implementation/Commands.cs +++ b/SpotNetCore/SpotNetCore/Implementation/Commands.cs @@ -20,6 +20,7 @@ public static Dictionary GetCommandsWithDescriptionList() {"shuffle off", "disables the shuffle mode."}, {"shuffle false", "disables the shuffle mode."}, {"shuffle true", "enables the shuffle mode."}, + {"settings", "sets or gets global settings"}, {"help", "show this help menu."}, {"exit", "closes the application."}, {"close", "closes the application."}, diff --git a/SpotNetCore/SpotNetCore/Implementation/CyclicLimitedList.cs b/SpotNetCore/SpotNetCore/Implementation/CyclicLimitedList.cs new file mode 100644 index 0000000..04b830c --- /dev/null +++ b/SpotNetCore/SpotNetCore/Implementation/CyclicLimitedList.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; + +namespace SpotNetCore.Implementation +{ + public sealed class CyclicLimitedList : ObservableCollection + { + private int _activeIndex = -1; + private readonly int _limit; + + public int ActiveIndex => _activeIndex; + + public CyclicLimitedList(int limit = 10) + { + _limit = limit; + CollectionChanged += OnCollectionChanged; + } + + public CyclicLimitedList(IEnumerable collection, int limit = 10) : base(collection) + { + _limit = limit; + CollectionChanged += OnCollectionChanged; + } + + private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add || e.Action == NotifyCollectionChangedAction.Replace) + { + if (e.NewItems == null) + return; + + var count = e.NewStartingIndex; + foreach (var eNewItem in e.NewItems) + { + var persistedItems = this.Where(x => x.Equals((T) eNewItem)); + if (persistedItems.Count() > 1) + { + RemoveAt(count); + MoveItem(IndexOf((T) eNewItem), Count - 1); + } + + count++; + } + + while (Count > _limit) + RemoveAt(0); + } + } + + public T GetCurrent() + { + if (Count == 0) throw new InvalidOperationException("List is empty"); + + return base[_activeIndex < Count - 1 ? _activeIndex >= 0 ? _activeIndex : 0 : Count - 1]; + } + + public T GetNext() + { + if (Count == 0) throw new InvalidOperationException("List is empty"); + + return this[_activeIndex + 1 < Count ? _activeIndex + 1 : 0]; + } + + public T GetPrevious() + { + if (Count == 0) throw new InvalidOperationException("List is empty"); + + return this[_activeIndex - 1 >= 0 ? _activeIndex - 1 : Count - 1]; + } + + private new T this[int index] + { + get + { + var value = base[index]; + _activeIndex = index; + return value; + } + } + } +} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Implementation/SpotifyCallbackListener.cs b/SpotNetCore/SpotNetCore/Implementation/SpotifyCallbackListener.cs new file mode 100644 index 0000000..ae0a6ad --- /dev/null +++ b/SpotNetCore/SpotNetCore/Implementation/SpotifyCallbackListener.cs @@ -0,0 +1,131 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace SpotNetCore.Implementation +{ + internal class SpotifyCallbackListener : IDisposable + { + private readonly int _port; + private readonly TcpListener _tcpListener; + + public SpotifyCallbackListener(int port) + { + if (port < 1 || port == 80) + { + throw new ArgumentOutOfRangeException(nameof(port),"Expected a valid port number, > 0, not 80"); + } + + _port = port; + _tcpListener = new TcpListener(IPAddress.Parse("127.0.0.1"), _port); + } + + public async Task ListenToSingleRequestAndRespondAsync(Func> responseProducer, CancellationToken cancellationToken) + { + cancellationToken.Register(() => _tcpListener.Stop()); + _tcpListener.Start(); + + TcpClient tcpClient = null; + try + { + tcpClient = await AcceptTcpClientAsync(cancellationToken) + .ConfigureAwait(false); + + await ExtractUriAndRespondAsync(tcpClient, responseProducer, cancellationToken).ConfigureAwait(false); + + } + finally + { + tcpClient?.Close(); + } + } + + private async Task AcceptTcpClientAsync(CancellationToken token) + { + try + { + return await _tcpListener.AcceptTcpClientAsync().ConfigureAwait(false); + } + catch (Exception ex) when (token.IsCancellationRequested) + { + throw new OperationCanceledException("Cancellation was requested while awaiting TCP client connection.", ex); + } + } + + private async Task ExtractUriAndRespondAsync( + TcpClient tcpClient, + Func> responseProducer, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var httpRequest = await GetTcpResponseAsync(tcpClient, cancellationToken).ConfigureAwait(false); + var uri = ExtractUriFromHttpRequest(httpRequest); + + // write an "OK, please close the browser message" + await WriteResponseAsync(await responseProducer(uri), tcpClient.GetStream(), cancellationToken) + .ConfigureAwait(false); + } + + + private Uri ExtractUriFromHttpRequest(string httpRequest) + { + const string regexp = @"GET \/\?(.*) HTTP"; + var r1 = new Regex(regexp); + var match = r1.Match(httpRequest); + if (!match.Success) + { + throw new InvalidOperationException("Not a GET query"); + } + + var getQuery = match.Groups[1].Value; + var uriBuilder = new UriBuilder + { + Query = getQuery, + Port = _port + }; + + return uriBuilder.Uri; + } + + private static async Task GetTcpResponseAsync(TcpClient client, CancellationToken cancellationToken) + { + var networkStream = client.GetStream(); + + var readBuffer = new byte[1024]; + var stringBuilder = new StringBuilder(); + + // Incoming message may be larger than the buffer size. + do + { + var numberOfBytesRead = await networkStream.ReadAsync(readBuffer.AsMemory(0, readBuffer.Length), cancellationToken) + .ConfigureAwait(false); + + var s = Encoding.ASCII.GetString(readBuffer, 0, numberOfBytesRead); + stringBuilder.Append(s); + + } + while (networkStream.DataAvailable); + + return stringBuilder.ToString(); + } + + private async Task WriteResponseAsync(string message, Stream stream, CancellationToken cancellationToken) + { + var fullResponse = $"HTTP/1.1 200 OK\r\n\r\n{message}"; + var response = Encoding.ASCII.GetBytes(fullResponse); + await stream.WriteAsync(response.AsMemory(0, response.Length), cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + public void Dispose() + { + _tcpListener?.Stop(); + } + } +} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Implementation/SpotifyHttpClient.cs b/SpotNetCore/SpotNetCore/Implementation/SpotifyHttpClient.cs new file mode 100644 index 0000000..e22712a --- /dev/null +++ b/SpotNetCore/SpotNetCore/Implementation/SpotifyHttpClient.cs @@ -0,0 +1,25 @@ +using System.Net.Http; +using SpotNetCore.Endpoints; + +namespace SpotNetCore.Implementation +{ + public class SpotifyHttpClient + { + public PlayerEndpoint Player { get; } + public AlbumEndpoint Albums { get; } + public ArtistEndpoint Artists { get; } + public PlaylistEndpoint Playlist { get; } + public SearchEndpoint Search { get; } + + private readonly HttpClient _httpClient; + public SpotifyHttpClient(HttpClient httpClient) + { + _httpClient = httpClient; + Player = new PlayerEndpoint(httpClient); + Albums = new AlbumEndpoint(httpClient); + Artists = new ArtistEndpoint(httpClient); + Playlist = new PlaylistEndpoint(httpClient); + Search = new SearchEndpoint(httpClient); + } + } +} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Implementation/SpotifyHttpClientHandler.cs b/SpotNetCore/SpotNetCore/Implementation/SpotifyHttpClientHandler.cs new file mode 100644 index 0000000..d1e6206 --- /dev/null +++ b/SpotNetCore/SpotNetCore/Implementation/SpotifyHttpClientHandler.cs @@ -0,0 +1,36 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.WebUtilities; + +namespace SpotNetCore.Implementation +{ + public class SpotifyHttpClientHandler : DelegatingHandler + { + private readonly AuthenticationManager _authenticationManager; + + private readonly AppSettings _appSettings; + public SpotifyHttpClientHandler(AuthenticationManager authenticationManager, AppSettings appSettings) + { + _authenticationManager = authenticationManager; + _appSettings = appSettings; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (!_authenticationManager.IsTokenAboutToExpire()) + { + request.Headers.Authorization = new AuthenticationHeaderValue(_authenticationManager.Token.TokenType, _authenticationManager.Token.AccessToken); + } + + var uriBuilder = new UriBuilder(request.RequestUri!); + var query = QueryHelpers.AddQueryString(request.RequestUri.Query, "market", _appSettings.Market?.ToUpper()); + uriBuilder.Query = query; + + request.RequestUri = uriBuilder.Uri; + return await base.SendAsync(request, cancellationToken); + } + } +} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Implementation/Terminal.cs b/SpotNetCore/SpotNetCore/Implementation/Terminal.cs index 2644e08..2eb6fbb 100644 --- a/SpotNetCore/SpotNetCore/Implementation/Terminal.cs +++ b/SpotNetCore/SpotNetCore/Implementation/Terminal.cs @@ -12,6 +12,7 @@ namespace SpotNetCore.Implementation public class Terminal { private const ConsoleColor DefaultConsoleColor = ConsoleColor.White; + public static void Startup() { WriteDarkGreen(@" @@ -112,6 +113,11 @@ public static string ReadLine() return Console.ReadLine(); } + public static void SetCursorPosition(int left, int top = 0) + { + Console.SetCursorPosition(AppConstants.Prompt.Length + left, top); + } + public static void Clear() { Console.ForegroundColor = DefaultConsoleColor; diff --git a/SpotNetCore/SpotNetCore/Models/AppSettings.cs b/SpotNetCore/SpotNetCore/Models/AppSettings.cs new file mode 100644 index 0000000..0a8c5f2 --- /dev/null +++ b/SpotNetCore/SpotNetCore/Models/AppSettings.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; +using SpotNetCore.Models; + +namespace SpotNetCore.Implementation +{ + public class AppSettings : INotifyPropertyChanged + { + private SpotifyAccessToken _spotifyTokens; + private List _requiredScopes; + private string _clientId; + private string _market; + + [JsonPropertyName("requiredScopes")] + public List RequiredScopes + { + get => _requiredScopes; + set + { + if (_requiredScopes == value) return; + + _requiredScopes = value; + OnPropertyChanged(); + } + } + + [JsonPropertyName("clientId")] + public String ClientId + { + get => _clientId; + set + { + if (_clientId == value) return; + + _clientId = value; + OnPropertyChanged(); + } + } + + [JsonPropertyName("market")] + public String Market + { + get => _market; + set + { + if (_market == value) return; + + _market = value; + OnPropertyChanged(); + } + } + + [JsonPropertyName("spotifyTokens")] + [DefaultValue(typeof(SpotifyAccessToken))] + public SpotifyAccessToken SpotifyTokens + { + get => _spotifyTokens; + set + { + if (_spotifyTokens == value) return; + + if (value != null) + value.PropertyChanged += (sender, args) => OnPropertyChanged(); + + _spotifyTokens = value; + OnPropertyChanged(); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Models/SettingOption.cs b/SpotNetCore/SpotNetCore/Models/SettingOption.cs new file mode 100644 index 0000000..3411da0 --- /dev/null +++ b/SpotNetCore/SpotNetCore/Models/SettingOption.cs @@ -0,0 +1,7 @@ +namespace SpotNetCore.Models +{ + public enum SettingOption + { + Market + } +} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Models/SpotifyAccessToken.cs b/SpotNetCore/SpotNetCore/Models/SpotifyAccessToken.cs index b6dba95..64ae3bd 100644 --- a/SpotNetCore/SpotNetCore/Models/SpotifyAccessToken.cs +++ b/SpotNetCore/SpotNetCore/Models/SpotifyAccessToken.cs @@ -1,25 +1,102 @@ using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; using System.Text.Json.Serialization; namespace SpotNetCore.Models { - public class SpotifyAccessToken + public class SpotifyAccessToken : INotifyPropertyChanged { + private string _accessToken; + private string _tokenType; + private int _expiresInSeconds; + private string _refreshToken; + private DateTime _expiresAt; + private string _scope; + [JsonPropertyName("access_token")] - public string AccessToken { get; set; } - + public string AccessToken + { + get => _accessToken; + set + { + if (_accessToken == value) return; + + _accessToken = value; + OnPropertyChanged(); + } + } + [JsonPropertyName("token_type")] - public string TokenType { get; set; } - + public string TokenType + { + get => _tokenType; + set + { + if (_tokenType == value) return; + + _tokenType = value; + OnPropertyChanged(); + } + } + [JsonPropertyName("scope")] - public string Scope { get; set; } - + public string Scope + { + get => _scope; + set + { + if (_scope == value) return; + + _scope = value; + OnPropertyChanged(); + } + } + [JsonPropertyName("expires_in")] - public int ExpiresInSeconds { get; set; } - + public int ExpiresInSeconds + { + get => _expiresInSeconds; + set + { + if (_expiresInSeconds == value) return; + + _expiresInSeconds = value; + OnPropertyChanged(); + } + } + [JsonPropertyName("refresh_token")] - public string RefreshToken { get; set; } + public string RefreshToken + { + get => _refreshToken; + set + { + if (_refreshToken == value) return; + + _refreshToken = value; + OnPropertyChanged(); + } + } + + [JsonPropertyName("expires_at")] + public DateTime ExpiresAt + { + get => _expiresAt; + set + { + if (_expiresAt == value) return; + + _expiresAt = value; + OnPropertyChanged(); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; - public DateTime ExpiresAt { get; set; } + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } } } \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Models/SpotifyAlbum.cs b/SpotNetCore/SpotNetCore/Models/SpotifyAlbum.cs index f3e55eb..e43ed77 100644 --- a/SpotNetCore/SpotNetCore/Models/SpotifyAlbum.cs +++ b/SpotNetCore/SpotNetCore/Models/SpotifyAlbum.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Authorization; namespace SpotNetCore.Models { diff --git a/SpotNetCore/SpotNetCore/Models/SpotifyCommand.cs b/SpotNetCore/SpotNetCore/Models/SpotifyCommand.cs index ab245af..33219c8 100644 --- a/SpotNetCore/SpotNetCore/Models/SpotifyCommand.cs +++ b/SpotNetCore/SpotNetCore/Models/SpotifyCommand.cs @@ -19,6 +19,7 @@ public enum SpotifyCommand Queue, ClearQueue, Current, - Invalid + Invalid, + Settings } } \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Models/SpotifySearchResult.cs b/SpotNetCore/SpotNetCore/Models/SpotifySearchResult.cs new file mode 100644 index 0000000..4655f7b --- /dev/null +++ b/SpotNetCore/SpotNetCore/Models/SpotifySearchResult.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace SpotNetCore.Models +{ + public class SpotifySearchResult + { + + [JsonPropertyName("tracks")] + public SpotifySearchEntityResult Tracks { get; set; } + + [JsonPropertyName("albums")] + public SpotifySearchEntityResult Albums { get; set; } + + [JsonPropertyName("artists")] + public SpotifySearchEntityResult Artists { get; set; } + + [JsonPropertyName("playlists")] + public SpotifySearchEntityResult Playlists { get; set; } + + } + + public class SpotifySearchEntityResult + { + [JsonPropertyName("items")] + public IEnumerable Items { get; set; } + } +} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Program.cs b/SpotNetCore/SpotNetCore/Program.cs index 3cbd987..30b365c 100644 --- a/SpotNetCore/SpotNetCore/Program.cs +++ b/SpotNetCore/SpotNetCore/Program.cs @@ -1,6 +1,11 @@ -using System.Threading.Tasks; +using System; +using System.IO; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading.Tasks; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using SpotNetCore.Implementation; using SpotNetCore.Services; @@ -10,9 +15,22 @@ class Program { public static async Task Main(string[] args) { - Terminal.Startup(); - - var serviceProvider = new ServiceCollection() + var serviceCollection = new ServiceCollection() + .AddSingleton(_ => + { + var appSettings = JsonSerializer.Deserialize(File.ReadAllText("appsettings.json")); + appSettings!.SpotifyTokens ??= new(); + appSettings.PropertyChanged += (_, _) => + { + File.WriteAllText("appsettings.json", JsonSerializer.Serialize(appSettings, new() + { + WriteIndented = true + })); + }; + return appSettings; + }) + .AddTransient() + .AddTransient() .AddSingleton() .AddSingleton() .AddSingleton() @@ -20,15 +38,15 @@ public static async Task Main(string[] args) .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(config => new ConfigurationBuilder() - .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) - .Build()) - .BuildServiceProvider(); + .AddHttpClient(httpClient => { httpClient.BaseAddress = new Uri("https://api.spotify.com/"); }) + .AddHttpMessageHandler(provider => provider.GetRequiredService()) + .Services; + Terminal.Startup(); + + var serviceProvider = serviceCollection.BuildServiceProvider(); await serviceProvider.GetService().Authenticate(); - Terminal.Clear(); - //This is the main command handler. It will essentially handle everything apart from auth-related code. //API consumption is initiated here, but will eventually be executed elsewhere. await serviceProvider.GetService().HandleCommands(); diff --git a/SpotNetCore/SpotNetCore/Services/AlbumService.cs b/SpotNetCore/SpotNetCore/Services/AlbumService.cs index 56365ce..02c49f0 100644 --- a/SpotNetCore/SpotNetCore/Services/AlbumService.cs +++ b/SpotNetCore/SpotNetCore/Services/AlbumService.cs @@ -9,27 +9,15 @@ namespace SpotNetCore.Services { - public class AlbumService : IDisposable + public class AlbumService { - private readonly HttpClient _httpClient; + private readonly SpotifyHttpClient _spotifyHttpClient; - public AlbumService(AuthenticationManager authenticationManager) + public AlbumService(SpotifyHttpClient spotifyHttpClient) { - _httpClient = new HttpClient - { - DefaultRequestHeaders = - { - Authorization = new AuthenticationHeaderValue(authenticationManager.Token.TokenType, - authenticationManager.Token.AccessToken) - } - }; + _spotifyHttpClient = spotifyHttpClient; } - ~AlbumService() - { - Dispose(false); - } - public async Task> GetTracksFromAlbumCollection(IEnumerable albums) { if (albums.IsNullOrEmpty()) @@ -56,32 +44,7 @@ public async Task> GetTracksFromAlbumCollection(IEnume public async Task> GetTracksForAlbum(string albumId) { - var response = await _httpClient.GetAsync($"https://api.spotify.com/v1/albums/{albumId}/tracks"); - - response.EnsureSpotifySuccess(); - - return (await JsonSerializerExtensions.DeserializeAnonymousTypeAsync( - await response.Content.ReadAsStreamAsync(), - new - { - items = default(IEnumerable) - })).items; - } - - private void Dispose(bool disposing) - { - if (!disposing) - { - return; - } - - _httpClient?.Dispose(); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); + return await _spotifyHttpClient.Albums.GetAlbumTracks(albumId); } } } \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Services/ArtistService.cs b/SpotNetCore/SpotNetCore/Services/ArtistService.cs index 3a1f05b..2d406e2 100644 --- a/SpotNetCore/SpotNetCore/Services/ArtistService.cs +++ b/SpotNetCore/SpotNetCore/Services/ArtistService.cs @@ -9,70 +9,25 @@ namespace SpotNetCore.Services { - public class ArtistService : IDisposable + public class ArtistService { - private readonly HttpClient _httpClient; + private readonly SpotifyHttpClient _spotifyHttpClient; - public ArtistService(AuthenticationManager authenticationManager) + public ArtistService(SpotifyHttpClient spotifyHttpClient) { - _httpClient = new HttpClient - { - DefaultRequestHeaders = - { - Authorization = new AuthenticationHeaderValue(authenticationManager.Token.TokenType, - authenticationManager.Token.AccessToken) - } - }; - } - - ~ArtistService() - { - Dispose(false); + _spotifyHttpClient = spotifyHttpClient; } public async Task> GetTopTracksForArtist(string id) { - var response = await _httpClient.GetAsync($"https://api.spotify.com/v1/artists/{id}/top-tracks?market=GB"); - - response.EnsureSpotifySuccess(); - - return (await JsonSerializerExtensions.DeserializeAnonymousTypeAsync(await response.Content.ReadAsStreamAsync(), - new - { - tracks = default(IEnumerable) - })).tracks; + return await _spotifyHttpClient.Artists.GetArtistTopTracks(id); } public async Task> GetDiscographyForArtist(string id) { - var response = await _httpClient.GetAsync($"https://api.spotify.com/v1/artists/{id}/albums?market=GB&include_groups=album"); - - response.EnsureSpotifySuccess(); - - var albums = (await JsonSerializerExtensions.DeserializeAnonymousTypeAsync(await response.Content.ReadAsStreamAsync(), - new - { - items = default(IEnumerable) - })).items; + var result = await _spotifyHttpClient.Artists.GetArtistAlbums(id); - //Spotify returns the albums in date order descending. Discography should be played in ascending order. - return albums.Reverse(); - } - - private void Dispose(bool disposing) - { - if (!disposing) - { - return; - } - - _httpClient?.Dispose(); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); + return result.Reverse(); } } } \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Services/PlayerService.cs b/SpotNetCore/SpotNetCore/Services/PlayerService.cs index d0b5d06..dcb4cea 100644 --- a/SpotNetCore/SpotNetCore/Services/PlayerService.cs +++ b/SpotNetCore/SpotNetCore/Services/PlayerService.cs @@ -1,49 +1,31 @@ -using System; using System.Net; using System.Net.Http; -using System.Net.Http.Headers; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using SpotNetCore.Implementation; using SpotNetCore.Models; -using AuthenticationManager = SpotNetCore.Implementation.AuthenticationManager; namespace SpotNetCore.Services { - public class PlayerService : IDisposable + public class PlayerService { - private readonly AuthenticationManager _authenticationManager; private readonly HttpClient _httpClient; - public PlayerService(AuthenticationManager authenticationManager) + private readonly SpotifyHttpClient _spotifyHttpClient; + public PlayerService(SpotifyHttpClient spotifyHttpClient) { - _authenticationManager = authenticationManager; - _httpClient = new HttpClient - { - DefaultRequestHeaders = - { - Authorization = new AuthenticationHeaderValue(_authenticationManager.Token.TokenType, - _authenticationManager.Token.AccessToken) - } - }; - } - - ~PlayerService() - { - Dispose(false); + _spotifyHttpClient = spotifyHttpClient; } public async Task PlayCurrentTrack() { - var response = await _httpClient.PutAsync("https://api.spotify.com/v1/me/player/play", null); - + var response = await _spotifyHttpClient.Player.Play(); response.EnsureSpotifySuccess(); } public async Task PauseCurrentTrack() { - var response = await _httpClient.PutAsync("https://api.spotify.com/v1/me/player/pause", null); - + var response = await _spotifyHttpClient.Player.Pause(); response.EnsureSpotifySuccess(); } @@ -51,7 +33,7 @@ public async Task NextTrack() { var lastPlayingTrack = await GetPlayerContext(); - var response = await _httpClient.PostAsync("https://api.spotify.com/v1/me/player/next", null); + var response = await _spotifyHttpClient.Player.Next(); response.EnsureSpotifySuccess(); return response.StatusCode == HttpStatusCode.Forbidden @@ -61,18 +43,14 @@ public async Task NextTrack() public async Task GetPlayerContext() { - var response = await _httpClient.GetAsync("https://api.spotify.com/v1/me/player"); - - response.EnsureSpotifySuccess(); - - return JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); + return await _spotifyHttpClient.Player.Player(); } public async Task PreviousTrack() { var lastPlayingTrack = await GetPlayerContext(); - var response = await _httpClient.PostAsync("https://api.spotify.com/v1/me/player/previous", null); + var response = await _spotifyHttpClient.Player.Previous(); response.EnsureSpotifySuccess(); return response.StatusCode == HttpStatusCode.Forbidden @@ -82,8 +60,7 @@ public async Task PreviousTrack() public async Task RestartTrack() { - var response = await _httpClient.PutAsync("https://api.spotify.com/v1/me/player/seek?position_ms=0", null); - + var response = await _spotifyHttpClient.Player.Seek(0); response.EnsureSpotifySuccess(); } @@ -105,29 +82,16 @@ public async Task ShuffleToggle(bool? requestedShuffleState) { var shuffleState = requestedShuffleState ?? !(await GetPlayerContext()).ShuffleState; - var response = await _httpClient.PutAsync($"https://api.spotify.com/v1/me/player/shuffle?state={shuffleState}", null); + var response = await _spotifyHttpClient.Player.Shuffle(shuffleState); response.EnsureSpotifySuccess(); } public async Task QueueTrack(string trackUri) { - var response = await _httpClient.PostAsync($"https://api.spotify.com/v1/me/player/queue?uri={trackUri}", null); + var response = await _spotifyHttpClient.Player.Queue(trackUri); response.EnsureSpotifySuccess(); } - - private void Dispose(bool disposing) - { - if (!disposing) return; - - _httpClient?.Dispose(); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } } } \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Services/PlaylistService.cs b/SpotNetCore/SpotNetCore/Services/PlaylistService.cs index 1464ee9..8c10051 100644 --- a/SpotNetCore/SpotNetCore/Services/PlaylistService.cs +++ b/SpotNetCore/SpotNetCore/Services/PlaylistService.cs @@ -9,61 +9,24 @@ namespace SpotNetCore.Services { - public class PlaylistService : IDisposable + public class PlaylistService { - private readonly HttpClient _httpClient; - - public PlaylistService(AuthenticationManager authenticationManager) + private readonly SpotifyHttpClient _spotifyHttpClient; + public PlaylistService(SpotifyHttpClient spotifyHttpClient) { - _httpClient = new HttpClient - { - DefaultRequestHeaders = - { - Authorization = new AuthenticationHeaderValue(authenticationManager.Token.TokenType, - authenticationManager.Token.AccessToken) - } - }; - } - - ~PlaylistService() - { - Dispose(false); + _spotifyHttpClient = spotifyHttpClient; } public async Task> GetTracksInPlaylist(string id) { - var response = await _httpClient.GetAsync($"https://api.spotify.com/v1/playlists/{id}/tracks?market=GB"); + var items = await _spotifyHttpClient.Playlist.GetPlaylistTracks(id); - response.EnsureSpotifySuccess(); - - var items = (await JsonSerializerExtensions.DeserializeAnonymousTypeAsync(await response.Content.ReadAsStreamAsync(), - new - { - items = default(IEnumerable) - })).items.ToList(); - - if (items.IsNullOrEmpty()) + if (items == null || items.IsNullOrEmpty()) { throw new NoSearchResultException(); } return items.Select(x => x.Track); } - - private void Dispose(bool disposing) - { - if (!disposing) - { - return; - } - - _httpClient?.Dispose(); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } } } \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Services/SearchService.cs b/SpotNetCore/SpotNetCore/Services/SearchService.cs index 279ae31..26bce9b 100644 --- a/SpotNetCore/SpotNetCore/Services/SearchService.cs +++ b/SpotNetCore/SpotNetCore/Services/SearchService.cs @@ -1,177 +1,96 @@ -using System; using System.Collections.Generic; using System.Linq; -using System.Net.Http; -using System.Net.Http.Headers; using System.Threading.Tasks; using SpotNetCore.Implementation; using SpotNetCore.Models; namespace SpotNetCore.Services { - public class SearchService : IDisposable + public class SearchService { private readonly ArtistService _artistService; private readonly AlbumService _albumService; private readonly PlaylistService _playlistService; - private readonly HttpClient _httpClient; + private readonly SpotifyHttpClient _spotifyHttpClient; - public SearchService(AuthenticationManager authenticationManager, ArtistService artistService, AlbumService albumService, PlaylistService playlistService) + public SearchService(SpotifyHttpClient spotifyHttpClient, ArtistService artistService, AlbumService albumService, PlaylistService playlistService) { _artistService = artistService; _albumService = albumService; _playlistService = playlistService; - _httpClient = new HttpClient - { - DefaultRequestHeaders = - { - Authorization = new AuthenticationHeaderValue(authenticationManager.Token.TokenType, - authenticationManager.Token.AccessToken) - } - }; + _spotifyHttpClient = spotifyHttpClient; } - ~SearchService() - { - Dispose(false); - } - public async Task> SearchForTrack(string query) { - var response = await _httpClient.GetAsync($"https://api.spotify.com/v1/search?q={query}&type=track"); - - response.EnsureSpotifySuccess(); - - return (await JsonSerializerExtensions.DeserializeAnonymousTypeAsync(await response.Content.ReadAsStreamAsync(), - new - { - tracks = new - { - items = default(IEnumerable) - } - }))?.tracks?.items; + return await _spotifyHttpClient.Search.SearchTracks(query); } - public async Task SearchForAlbum(string query) + public async Task> SearchForAlbum(string query, int limit = 10) { - var metadataResponse = await _httpClient.GetAsync($"https://api.spotify.com/v1/search?q={query}&type=album"); + var albums = await _spotifyHttpClient.Search.SearchAlbums(query, limit); - metadataResponse.EnsureSpotifySuccess(); - - var album = (await JsonSerializerExtensions.DeserializeAnonymousTypeAsync(await metadataResponse.Content.ReadAsStreamAsync(), - new - { - albums = new - { - items = default(IEnumerable) - } - })).albums.items.FirstOrDefault(); - - if (album == null) + var spotifyAlbums = new List(); + foreach (var spotifyAlbum in albums) { - throw new NoSearchResultException(); + spotifyAlbum.Tracks = await _albumService.GetTracksForAlbum(spotifyAlbum.Id); + if (spotifyAlbum.Tracks != null && spotifyAlbum.Tracks.IsNullOrEmpty()) + spotifyAlbums.Add(spotifyAlbum); } - - var albumResponse = await _httpClient.GetAsync($"https://api.spotify.com/v1/albums/{album.Id}/tracks"); - - albumResponse.EnsureSpotifySuccess(); - - album.Tracks = (await JsonSerializerExtensions.DeserializeAnonymousTypeAsync( - await albumResponse.Content.ReadAsStreamAsync(), - new - { - items = default(IEnumerable) - })).items; - - if (album.Tracks.IsNullOrEmpty()) + + if (spotifyAlbums.Count == 0) { throw new NoSearchResultException(); } - return album; + return spotifyAlbums; } - public async Task SearchForArtist(string query, ArtistOption option) + public async Task> SearchForArtist(string query, ArtistOption option, int limit = 10) { - var metadataResponse = await _httpClient.GetAsync($"https://api.spotify.com/v1/search?q={query}&type=artist"); - - metadataResponse.EnsureSpotifySuccess(); - - var artist = (await JsonSerializerExtensions.DeserializeAnonymousTypeAsync( - await metadataResponse.Content.ReadAsStreamAsync(), - new - { - artists = new - { - items = default(IEnumerable) - } - })).artists.items.FirstOrDefault(); - - if (artist == null) - { - throw new NoSearchResultException(); - } - - if (option == ArtistOption.Discography) - { - artist.Tracks = await _albumService.GetTracksFromAlbumCollection(await _artistService.GetDiscographyForArtist(artist.Id)); - } - - if (option == ArtistOption.Popular) - { - artist.Tracks = await _artistService.GetTopTracksForArtist(artist.Id); - } - - if (option == ArtistOption.Essential) + var artists = await _spotifyHttpClient.Search.SearchArtists(query, limit); + + var spotifyArtists = new List(); + foreach (var artist in artists) { - artist.Tracks = (await SearchForPlaylist($"This Is {artist.Name}")).Tracks; + artist.Tracks = option switch + { + ArtistOption.Discography => await _albumService.GetTracksFromAlbumCollection(await _artistService.GetDiscographyForArtist(artist.Id)), + ArtistOption.Popular => await _artistService.GetTopTracksForArtist(artist.Id), + ArtistOption.Essential => (await SearchForPlaylist($"This Is {artist.Name}")).SelectMany(spotifyPlaylist=>spotifyPlaylist.Tracks), + _ => artist.Tracks + }; + + if (artist.Tracks != null && !artist.Tracks.IsNullOrEmpty()) + spotifyArtists.Add(artist); } - - if (artist.Tracks.IsNullOrEmpty()) + + if (spotifyArtists.Count == 0) { throw new NoSearchResultException(); } - - return artist; + + return spotifyArtists; } - public async Task SearchForPlaylist(string query) + public async Task> SearchForPlaylist(string query, int limit = 10) { - var response = await _httpClient.GetAsync($"https://api.spotify.com/v1/search?q={query}&type=playlist"); - - response.EnsureSpotifySuccess(); - - var playlist = (await JsonSerializerExtensions.DeserializeAnonymousTypeAsync( - await response.Content.ReadAsStreamAsync(), - new - { - playlists = new - { - items = default(IEnumerable) - } - })).playlists.items.FirstOrDefault(); - - if (playlist == null) + var playlists = await _spotifyHttpClient.Search.SearchPlaylists(query, limit); + + var spotifyPlaylists = new List(); + foreach (var playlist in playlists) + { + playlist.Tracks = await _playlistService.GetTracksInPlaylist(playlist.Id); + if (playlist.Tracks != null && !playlist.Tracks.IsNullOrEmpty()) + spotifyPlaylists.Add(playlist); + } + + if (spotifyPlaylists.Count == 0) { throw new NoSearchResultException(); } - playlist.Tracks = await _playlistService.GetTracksInPlaylist(playlist.Id); - - return playlist; - } - - private void Dispose(bool disposing) - { - if (!disposing) return; - - _httpClient?.Dispose(); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); + return spotifyPlaylists; } } } \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/SpotNetCore.csproj b/SpotNetCore/SpotNetCore/SpotNetCore.csproj index 2334660..607ed8b 100644 --- a/SpotNetCore/SpotNetCore/SpotNetCore.csproj +++ b/SpotNetCore/SpotNetCore/SpotNetCore.csproj @@ -7,16 +7,18 @@ - - - - + + + + + + + - - Always - + + PreserveNewest + - diff --git a/SpotNetCore/SpotNetCore/SpotifyHttpClientJsonExtensions.cs b/SpotNetCore/SpotNetCore/SpotifyHttpClientJsonExtensions.cs new file mode 100644 index 0000000..deaf0ed --- /dev/null +++ b/SpotNetCore/SpotNetCore/SpotifyHttpClientJsonExtensions.cs @@ -0,0 +1,19 @@ +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace SpotNetCore +{ + public static class SpotifyHttpClientJsonExtensions + { + public static async Task GetFromSpotifyJsonAsync(this HttpClient client, string requestUri, JsonSerializerOptions options = default, + CancellationToken cancellationToken = default) + { + using var response = await client.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + response.EnsureSpotifySuccess(); + return await response.Content!.ReadFromJsonAsync(options, cancellationToken).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/Startup.cs b/SpotNetCore/SpotNetCore/Startup.cs deleted file mode 100644 index 871143f..0000000 --- a/SpotNetCore/SpotNetCore/Startup.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using SpotNetCore.Implementation; - -namespace SpotNetCore -{ - public class Startup - { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } - - public void ConfigureServices(IServiceCollection services) - { - var serviceProvider = new ServiceCollection() - .AddSingleton() - .AddLogging(logging => - { - logging.AddConsole(); - }) - .BuildServiceProvider(); - } - - public void Configure(IApplicationBuilder app) - { - - } - } -} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/StringExtensions.cs b/SpotNetCore/SpotNetCore/StringExtensions.cs new file mode 100644 index 0000000..fc034a7 --- /dev/null +++ b/SpotNetCore/SpotNetCore/StringExtensions.cs @@ -0,0 +1,16 @@ +using System; +using System.Linq; + +namespace SpotNetCore +{ + public static class StringExtensions + { + public static string FirstCharToUpper(this string input) => + input switch + { + null => throw new ArgumentNullException(nameof(input)), + "" => throw new ArgumentException($"{nameof(input)} cannot be empty", nameof(input)), + _ => input.First().ToString().ToUpper() + input.Substring(1) + }; + } +} \ No newline at end of file diff --git a/SpotNetCore/SpotNetCore/appsettings.json b/SpotNetCore/SpotNetCore/appsettings.json index d10e602..0bb6849 100644 --- a/SpotNetCore/SpotNetCore/appsettings.json +++ b/SpotNetCore/SpotNetCore/appsettings.json @@ -4,5 +4,6 @@ "user-follow-modify", "user-read-playback-state" ], + "market": "GB", "clientId": "33bea7a309d24a08a71ff9c8f48be287" } \ No newline at end of file