From 23730ddded5895e0a4d2cf2e9cf8db8b9231e49a Mon Sep 17 00:00:00 2001 From: ta264 Date: Mon, 25 Jul 2022 22:10:05 +0100 Subject: [PATCH] Update SpotifyAPI.Web --- .../Spotify/SpotifyFollowedArtistsFixture.cs | 51 ++++--- .../Spotify/SpotifyPlaylistFixture.cs | 71 +++++----- .../Spotify/SpotifySavedAlbumsFixture.cs | 19 ++- .../Spotify/SpotifyAuthenticator.cs | 38 ++++++ ...SpotifyAuthorizationCodeRefreshResponse.cs | 19 +++ .../Spotify/SpotifyFollowedArtists.cs | 5 +- .../Spotify/SpotifyImportListBase.cs | 62 +++++---- .../ImportLists/Spotify/SpotifyPlaylist.cs | 128 ++++++++++-------- .../ImportLists/Spotify/SpotifyProxy.cs | 113 +++++++--------- .../Spotify/SpotifyRetryHandler.cs | 40 ++++++ .../ImportLists/Spotify/SpotifySavedAlbums.cs | 5 +- .../Spotify/SpotifySettingsBase.cs | 1 - src/NzbDrone.Core/Lidarr.Core.csproj | 2 +- 13 files changed, 324 insertions(+), 230 deletions(-) create mode 100644 src/NzbDrone.Core/ImportLists/Spotify/SpotifyAuthenticator.cs create mode 100644 src/NzbDrone.Core/ImportLists/Spotify/SpotifyAuthorizationCodeRefreshResponse.cs create mode 100644 src/NzbDrone.Core/ImportLists/Spotify/SpotifyRetryHandler.cs diff --git a/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyFollowedArtistsFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyFollowedArtistsFixture.cs index d293b4ec4..a0846681b 100644 --- a/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyFollowedArtistsFixture.cs +++ b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyFollowedArtistsFixture.cs @@ -5,7 +5,6 @@ using NUnit.Framework; using NzbDrone.Core.ImportLists.Spotify; using NzbDrone.Core.Test.Framework; using SpotifyAPI.Web; -using SpotifyAPI.Web.Models; namespace NzbDrone.Core.Test.ImportListTests { @@ -13,15 +12,15 @@ namespace NzbDrone.Core.Test.ImportListTests public class SpotifyFollowedArtistsFixture : CoreTest { // placeholder, we don't use real API - private readonly SpotifyWebAPI _api = null; + private readonly SpotifyClient _api = null; [Test] public void should_not_throw_if_followed_is_null() { Mocker.GetMock(). Setup(x => x.GetFollowedArtists(It.IsAny(), - It.IsAny())) - .Returns(default(FollowedArtists)); + It.IsAny())) + .Returns(default(FollowedArtistsResponse)); var result = Subject.Fetch(_api); @@ -31,14 +30,14 @@ namespace NzbDrone.Core.Test.ImportListTests [Test] public void should_not_throw_if_followed_artists_is_null() { - var followed = new FollowedArtists + var followed = new FollowedArtistsResponse { Artists = null }; Mocker.GetMock(). Setup(x => x.GetFollowedArtists(It.IsAny(), - It.IsAny())) + It.IsAny())) .Returns(followed); var result = Subject.Fetch(_api); @@ -49,9 +48,9 @@ namespace NzbDrone.Core.Test.ImportListTests [Test] public void should_not_throw_if_followed_artist_items_is_null() { - var followed = new FollowedArtists + var followed = new FollowedArtistsResponse { - Artists = new CursorPaging + Artists = new CursorPaging { Items = null } @@ -59,7 +58,7 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock(). Setup(x => x.GetFollowedArtists(It.IsAny(), - It.IsAny())) + It.IsAny())) .Returns(followed); var result = Subject.Fetch(_api); @@ -71,9 +70,9 @@ namespace NzbDrone.Core.Test.ImportListTests [Test] public void should_not_throw_if_artist_is_null() { - var followed = new FollowedArtists + var followed = new FollowedArtistsResponse { - Artists = new CursorPaging + Artists = new CursorPaging { Items = new List { @@ -84,7 +83,7 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock(). Setup(x => x.GetFollowedArtists(It.IsAny(), - It.IsAny())) + It.IsAny())) .Returns(followed); var result = Subject.Fetch(_api); @@ -96,9 +95,9 @@ namespace NzbDrone.Core.Test.ImportListTests [Test] public void should_parse_followed_artist() { - var followed = new FollowedArtists + var followed = new FollowedArtistsResponse { - Artists = new CursorPaging + Artists = new CursorPaging { Items = new List { @@ -112,7 +111,7 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock(). Setup(x => x.GetFollowedArtists(It.IsAny(), - It.IsAny())) + It.IsAny())) .Returns(followed); var result = Subject.Fetch(_api); @@ -123,9 +122,9 @@ namespace NzbDrone.Core.Test.ImportListTests [Test] public void should_not_throw_if_get_next_page_returns_null() { - var followed = new FollowedArtists + var followed = new FollowedArtistsResponse { - Artists = new CursorPaging + Artists = new CursorPaging { Items = new List { @@ -140,14 +139,14 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock(). Setup(x => x.GetFollowedArtists(It.IsAny(), - It.IsAny())) + It.IsAny())) .Returns(followed); Mocker.GetMock() .Setup(x => x.GetNextPage(It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(default(FollowedArtists)); + It.IsAny(), + It.IsAny())) + .Returns(default(FollowedArtistsResponse)); var result = Subject.Fetch(_api); @@ -155,8 +154,8 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock() .Verify(v => v.GetNextPage(It.IsAny(), - It.IsAny(), - It.IsAny()), + It.IsAny(), + It.IsAny()), Times.Once()); } @@ -164,9 +163,9 @@ namespace NzbDrone.Core.Test.ImportListTests [TestCase("")] public void should_skip_bad_artist_names(string name) { - var followed = new FollowedArtists + var followed = new FollowedArtistsResponse { - Artists = new CursorPaging + Artists = new CursorPaging { Items = new List { @@ -180,7 +179,7 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock(). Setup(x => x.GetFollowedArtists(It.IsAny(), - It.IsAny())) + It.IsAny())) .Returns(followed); var result = Subject.Fetch(_api); diff --git a/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyPlaylistFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyPlaylistFixture.cs index ef2d7ba9f..5aeebaea3 100644 --- a/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyPlaylistFixture.cs +++ b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyPlaylistFixture.cs @@ -5,7 +5,6 @@ using NUnit.Framework; using NzbDrone.Core.ImportLists.Spotify; using NzbDrone.Core.Test.Framework; using SpotifyAPI.Web; -using SpotifyAPI.Web.Models; namespace NzbDrone.Core.Test.ImportListTests { @@ -13,16 +12,16 @@ namespace NzbDrone.Core.Test.ImportListTests public class SpotifyPlaylistFixture : CoreTest { // placeholder, we don't use real API - private readonly SpotifyWebAPI _api = null; + private readonly SpotifyClient _api = null; [Test] public void should_not_throw_if_playlist_tracks_is_null() { Mocker.GetMock(). Setup(x => x.GetPlaylist(It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny>())) .Returns(default(FullPlaylist)); var result = Subject.Fetch(_api, "playlistid"); @@ -33,16 +32,16 @@ namespace NzbDrone.Core.Test.ImportListTests [Test] public void should_not_throw_if_playlist_tracks_items_is_null() { - var playlistTracks = new Paging + var playlistTracks = new Paging> { Items = null }; Mocker.GetMock(). Setup(x => x.GetPlaylist(It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny>())) .Returns(new FullPlaylist { Tracks = playlistTracks }); var result = Subject.Fetch(_api, "playlistid"); @@ -53,9 +52,9 @@ namespace NzbDrone.Core.Test.ImportListTests [Test] public void should_not_throw_if_playlist_track_is_null() { - var playlistTracks = new Paging + var playlistTracks = new Paging> { - Items = new List + Items = new List> { null } @@ -63,9 +62,9 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock(). Setup(x => x.GetPlaylist(It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny>())) .Returns(new FullPlaylist { Tracks = playlistTracks }); var result = Subject.Fetch(_api, "playlistid"); @@ -76,11 +75,11 @@ namespace NzbDrone.Core.Test.ImportListTests [Test] public void should_use_album_artist_when_it_exists() { - var playlistTracks = new Paging + var playlistTracks = new Paging> { - Items = new List + Items = new List> { - new PlaylistTrack + new PlaylistTrack { Track = new FullTrack { @@ -109,9 +108,9 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock(). Setup(x => x.GetPlaylist(It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny>())) .Returns(new FullPlaylist { Tracks = playlistTracks }); var result = Subject.Fetch(_api, "playlistid"); @@ -123,11 +122,11 @@ namespace NzbDrone.Core.Test.ImportListTests [Test] public void should_fall_back_to_track_artist_if_album_artist_missing() { - var playlistTracks = new Paging + var playlistTracks = new Paging> { - Items = new List + Items = new List> { - new PlaylistTrack + new PlaylistTrack { Track = new FullTrack { @@ -156,9 +155,9 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock(). Setup(x => x.GetPlaylist(It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny>())) .Returns(new FullPlaylist { Tracks = playlistTracks }); var result = Subject.Fetch(_api, "playlistid"); @@ -172,11 +171,11 @@ namespace NzbDrone.Core.Test.ImportListTests [TestCase(null, "TrackArtist", null)] public void should_skip_bad_artist_or_album_names(string albumArtistName, string trackArtistName, string albumName) { - var playlistTracks = new Paging + var playlistTracks = new Paging> { - Items = new List + Items = new List> { - new PlaylistTrack + new PlaylistTrack { Track = new FullTrack { @@ -205,9 +204,9 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock(). Setup(x => x.GetPlaylist(It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny>())) .Returns(new FullPlaylist { Tracks = playlistTracks }); var result = Subject.Fetch(_api, "playlistid"); @@ -218,11 +217,11 @@ namespace NzbDrone.Core.Test.ImportListTests [Test] public void should_not_throw_if_get_next_page_returns_null() { - var playlistTracks = new Paging + var playlistTracks = new Paging> { - Items = new List + Items = new List> { - new PlaylistTrack + new PlaylistTrack { Track = new FullTrack { @@ -252,16 +251,16 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock(). Setup(x => x.GetPlaylist(It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny>())) .Returns(new FullPlaylist { Tracks = playlistTracks }); Mocker.GetMock() .Setup(x => x.GetNextPage(It.IsAny(), - It.IsAny(), - It.IsAny>())) - .Returns(default(Paging)); + It.IsAny(), + It.IsAny>>())) + .Returns(default(Paging>)); var result = Subject.Fetch(_api, "playlistid"); @@ -269,8 +268,8 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock() .Verify(x => x.GetNextPage(It.IsAny(), - It.IsAny(), - It.IsAny>()), + It.IsAny(), + It.IsAny>>()), Times.Once()); } } diff --git a/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifySavedAlbumsFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifySavedAlbumsFixture.cs index 4f1371f5d..983a3e7bc 100644 --- a/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifySavedAlbumsFixture.cs +++ b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifySavedAlbumsFixture.cs @@ -5,7 +5,6 @@ using NUnit.Framework; using NzbDrone.Core.ImportLists.Spotify; using NzbDrone.Core.Test.Framework; using SpotifyAPI.Web; -using SpotifyAPI.Web.Models; namespace NzbDrone.Core.Test.ImportListTests { @@ -13,14 +12,14 @@ namespace NzbDrone.Core.Test.ImportListTests public class SpotifySavedAlbumsFixture : CoreTest { // placeholder, we don't use real API - private readonly SpotifyWebAPI _api = null; + private readonly SpotifyClient _api = null; [Test] public void should_not_throw_if_saved_albums_is_null() { Mocker.GetMock(). Setup(x => x.GetSavedAlbums(It.IsAny(), - It.IsAny())) + It.IsAny())) .Returns(default(Paging)); var result = Subject.Fetch(_api); @@ -38,7 +37,7 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock(). Setup(x => x.GetSavedAlbums(It.IsAny(), - It.IsAny())) + It.IsAny())) .Returns(savedAlbums); var result = Subject.Fetch(_api); @@ -59,7 +58,7 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock(). Setup(x => x.GetSavedAlbums(It.IsAny(), - It.IsAny())) + It.IsAny())) .Returns(savedAlbums); var result = Subject.Fetch(_api); @@ -93,7 +92,7 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock(). Setup(x => x.GetSavedAlbums(It.IsAny(), - It.IsAny())) + It.IsAny())) .Returns(savedAlbums); var result = Subject.Fetch(_api); @@ -128,12 +127,12 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock(). Setup(x => x.GetSavedAlbums(It.IsAny(), - It.IsAny())) + It.IsAny())) .Returns(savedAlbums); Mocker.GetMock() .Setup(x => x.GetNextPage(It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny>())) .Returns(default(Paging)); @@ -143,7 +142,7 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock() .Verify(x => x.GetNextPage(It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny>()), Times.Once()); } @@ -176,7 +175,7 @@ namespace NzbDrone.Core.Test.ImportListTests Mocker.GetMock(). Setup(x => x.GetSavedAlbums(It.IsAny(), - It.IsAny())) + It.IsAny())) .Returns(savedAlbums); var result = Subject.Fetch(_api); diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyAuthenticator.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyAuthenticator.cs new file mode 100644 index 000000000..00e51cb38 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyAuthenticator.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading.Tasks; +using NzbDrone.Common.Extensions; +using SpotifyAPI.Web; +using SpotifyAPI.Web.Http; + +namespace NzbDrone.Core.ImportLists.Spotify +{ + public class SpotifyAuthenticator : IAuthenticator + { + private readonly AuthorizationCodeTokenResponse _token; + private readonly Func _refreshToken; + + public SpotifyAuthenticator(AuthorizationCodeTokenResponse token, Func refreshToken) + { + _token = token; + _refreshToken = refreshToken; + } + + public Task Apply(IRequest request, IAPIConnector apiConnector) + { + if (_token.AccessToken.IsNullOrWhiteSpace() || _token.IsExpired) + { + var refreshedToken = _refreshToken(); + + _token.AccessToken = refreshedToken.AccessToken; + _token.CreatedAt = refreshedToken.CreatedAt; + _token.ExpiresIn = refreshedToken.ExpiresIn; + _token.Scope = refreshedToken.Scope; + _token.TokenType = refreshedToken.TokenType; + } + + request.Headers["Authorization"] = $"{_token.TokenType} {_token.AccessToken}"; + + return Task.CompletedTask; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyAuthorizationCodeRefreshResponse.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyAuthorizationCodeRefreshResponse.cs new file mode 100644 index 000000000..73f9e0c41 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyAuthorizationCodeRefreshResponse.cs @@ -0,0 +1,19 @@ +using System; +using Newtonsoft.Json; + +namespace NzbDrone.Core.ImportLists.Spotify +{ + public class SpotifyAuthorizationCodeRefreshResponse + { + [JsonProperty("access_token")] + public string AccessToken { get; set; } + [JsonProperty("token_type")] + public string TokenType { get; set; } + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } + public string Scope { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public bool IsExpired { get => CreatedAt.AddSeconds(ExpiresIn) <= DateTime.UtcNow; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyFollowedArtists.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyFollowedArtists.cs index 414c05d67..dc248c87b 100644 --- a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyFollowedArtists.cs +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyFollowedArtists.cs @@ -6,7 +6,6 @@ using NzbDrone.Core.Configuration; using NzbDrone.Core.MetadataSource; using NzbDrone.Core.Parser; using SpotifyAPI.Web; -using SpotifyAPI.Web.Models; namespace NzbDrone.Core.ImportLists.Spotify { @@ -31,7 +30,7 @@ namespace NzbDrone.Core.ImportLists.Spotify public override string Name => "Spotify Followed Artists"; - public override IList Fetch(SpotifyWebAPI api) + public override IList Fetch(SpotifyClient api) { var result = new List(); @@ -50,7 +49,7 @@ namespace NzbDrone.Core.ImportLists.Spotify result.AddIfNotNull(ParseFullArtist(artist)); } - if (!artists.HasNext()) + if (artists.Next == null) { break; } diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyImportListBase.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyImportListBase.cs index ba1b50fae..bbda65bb7 100644 --- a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyImportListBase.cs +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyImportListBase.cs @@ -15,7 +15,6 @@ using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; using SpotifyAPI.Web; -using SpotifyAPI.Web.Models; namespace NzbDrone.Core.ImportLists.Spotify { @@ -48,7 +47,7 @@ namespace NzbDrone.Core.ImportLists.Spotify public string AccessToken => Settings.AccessToken; - public void RefreshToken() + public AuthorizationCodeRefreshResponse RefreshToken() { _logger.Trace("Refreshing Token"); @@ -60,52 +59,64 @@ namespace NzbDrone.Core.ImportLists.Spotify try { - var response = _httpClient.Get(request); + var response = _httpClient.Get(request); - if (response != null && response.Resource != null) + if (response?.Resource != null) { var token = response.Resource; + Settings.AccessToken = token.AccessToken; - Settings.Expires = DateTime.UtcNow.AddSeconds(token.ExpiresIn); - Settings.RefreshToken = token.RefreshToken != null ? token.RefreshToken : Settings.RefreshToken; + Settings.Expires = token.CreatedAt.AddSeconds(token.ExpiresIn); if (Definition.Id > 0) { _importListRepository.UpdateSettings((ImportListDefinition)Definition); } + + return new AuthorizationCodeRefreshResponse + { + AccessToken = token.AccessToken, + TokenType = token.TokenType, + ExpiresIn = token.ExpiresIn, + Scope = token.Scope + }; } } catch (HttpException) { _logger.Warn($"Error refreshing spotify access token"); } + + return null; } - public SpotifyWebAPI GetApi() + public SpotifyClient GetApi() { - Settings.Validate().Filter("AccessToken", "RefreshToken").ThrowOnError(); + Settings.Validate().Filter("RefreshToken").ThrowOnError(); _logger.Trace($"Access token expires at {Settings.Expires}"); - if (Settings.Expires < DateTime.UtcNow.AddMinutes(5)) - { - RefreshToken(); - } - - return new SpotifyWebAPI + var token = new AuthorizationCodeTokenResponse { AccessToken = Settings.AccessToken, + ExpiresIn = (int)(Settings.Expires - DateTime.UtcNow).TotalSeconds, + RefreshToken = Settings.RefreshToken, TokenType = "Bearer" }; + + var config = SpotifyClientConfig.CreateDefault() + .WithAuthenticator(new SpotifyAuthenticator(token, RefreshToken)) + .WithRetryHandler(new SpotifyRetryHandler(token)); + + return new SpotifyClient(config); } public override IList Fetch() { IList releases; - using (var api = GetApi()) - { - _logger.Debug("Starting spotify import list sync"); - releases = Fetch(api); - } + var api = GetApi(); + + _logger.Debug("Starting spotify import list sync"); + releases = Fetch(api); // map to musicbrainz ids releases = MapSpotifyReleases(releases); @@ -115,7 +126,7 @@ namespace NzbDrone.Core.ImportLists.Spotify return CleanupListItems(releases); } - public abstract IList Fetch(SpotifyWebAPI api); + public abstract IList Fetch(SpotifyClient api); protected DateTime ParseSpotifyDate(string date, string precision) { @@ -134,7 +145,6 @@ namespace NzbDrone.Core.ImportLists.Spotify case "month": format = "yyyy-MM"; break; - case "day": default: format = "yyyy-MM-dd"; break; @@ -307,12 +317,10 @@ namespace NzbDrone.Core.ImportLists.Spotify { try { - using (var api = GetApi()) - { - var profile = _spotifyProxy.GetPrivateProfile(this, api); - _logger.Debug($"Connected to spotify profile {profile.DisplayName} [{profile.Id}]"); - return null; - } + var api = GetApi(); + var profile = _spotifyProxy.GetPrivateProfile(this, api); + _logger.Debug($"Connected to spotify profile {profile.DisplayName} [{profile.Id}]"); + return null; } catch (SpotifyAuthorizationException ex) { diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylist.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylist.cs index e6d27dc95..2a55a8f5c 100644 --- a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylist.cs +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylist.cs @@ -11,13 +11,12 @@ using NzbDrone.Core.Music; using NzbDrone.Core.Parser; using NzbDrone.Core.Validation; using SpotifyAPI.Web; -using SpotifyAPI.Web.Enums; -using SpotifyAPI.Web.Models; namespace NzbDrone.Core.ImportLists.Spotify { public class SpotifyPlaylist : SpotifyImportListBase { + private readonly List _fields = new () { "id", "name", "tracks.next", "tracks.items(track(type, name, artists(id, name), album(id, album_type, name, release_date, release_date_precision, artists(id, name))))" }; private readonly IPlaylistService _playlistService; public SpotifyPlaylist(ISpotifyProxy spotifyProxy, @@ -36,18 +35,18 @@ namespace NzbDrone.Core.ImportLists.Spotify public override string Name => "Spotify Playlists"; - public override IList Fetch(SpotifyWebAPI api) + public override IList Fetch(SpotifyClient api) { return Settings.PlaylistIds.SelectMany(x => Fetch(api, x)).ToList(); } - public IList Fetch(SpotifyWebAPI api, string playlistId) + public IList Fetch(SpotifyClient api, string playlistId) { var result = new List(); _logger.Trace($"Processing playlist {playlistId}"); - var playlist = _spotifyProxy.GetPlaylist(this, api, playlistId, "id, name, tracks.next, tracks.items(track(name, artists(id, name), album(id, album_type, name, release_date, release_date_precision, artists(id, name))))"); + var playlist = _spotifyProxy.GetPlaylist(this, api, playlistId, _fields); var playlistTracks = playlist?.Tracks; int order = 0; @@ -63,7 +62,7 @@ namespace NzbDrone.Core.ImportLists.Spotify result.AddIfNotNull(ParsePlaylistTrack(api, playlistTrack, playlistId, playlist.Name, ref order)); } - if (!playlistTracks.HasNextPage()) + if (playlistTracks.Next == null) { break; } @@ -103,17 +102,19 @@ namespace NzbDrone.Core.ImportLists.Spotify } } - private SpotifyPlaylistItemInfo ParsePlaylistTrack(SpotifyWebAPI api, PlaylistTrack playlistTrack, string playlistId, string playlistName, ref int order) + private SpotifyPlaylistItemInfo ParsePlaylistTrack(SpotifyClient api, PlaylistTrack playableItem, string playlistId, string playlistName, ref int order) { + var track = playableItem.Track as FullTrack; + // From spotify docs: "Note, a track object may be null. This can happen if a track is no longer available." - if (playlistTrack?.Track?.Album == null) + if (track?.Album == null) { return null; } - var album = playlistTrack.Track.Album; - var trackName = playlistTrack.Track.Name; - var artistName = album.Artists?.FirstOrDefault()?.Name ?? playlistTrack.Track?.Artists?.FirstOrDefault()?.Name; + var album = track.Album; + var trackName = track.Name; + var artistName = album.Artists?.FirstOrDefault()?.Name ?? track?.Artists?.FirstOrDefault()?.Name; if (artistName.IsNullOrWhiteSpace()) { @@ -124,11 +125,10 @@ namespace NzbDrone.Core.ImportLists.Spotify if (album.AlbumType == "single") { - album = GetBestAlbum(api, artistName, trackName) ?? album; + album = GetBestAlbum(api, artistName, trackName, album.TotalTracks) ?? album; + _logger.Trace($"revised type: {album.AlbumType}"); } - _logger.Trace($"revised type: {album.AlbumType}"); - var albumName = album.Name; if (albumName.IsNullOrWhiteSpace()) @@ -143,25 +143,41 @@ namespace NzbDrone.Core.ImportLists.Spotify Album = album.Name, AlbumSpotifyId = album.Id, PlaylistTitle = playlistName, - TrackTitle = playlistTrack.Track.Name, + TrackTitle = track.Name, Order = ++order, ReleaseDate = ParseSpotifyDate(album.ReleaseDate, album.ReleaseDatePrecision) }; } - private SimpleAlbum GetBestAlbum(SpotifyWebAPI api, string artistName, string trackName) + private SimpleAlbum GetBestAlbum(SpotifyClient api, string artistName, string trackName, int currentTrackCount) { _logger.Trace($"Finding full album for {artistName}: {trackName}"); - var search = _spotifyProxy.SearchItems(this, api, $"artist:\"{artistName}\" track:\"{trackName}\"", SearchType.Track); + var search = _spotifyProxy.SearchItems(this, api, $"artist:\"{artistName}\" track:\"{trackName}\"", SearchRequest.Types.Track); - return search?.Tracks?.Items?.FirstOrDefault(x => x?.Album?.AlbumType == "album" && !(x?.Album?.Artists?.Any(a => a.Name == "Various Artists") ?? false))?.Album; + var result = search?.Tracks?.Items?.FirstOrDefault(x => x?.Album?.AlbumType == "album" && IsAcceptableAlbumOrSingle(x, artistName, trackName))?.Album ?? + search?.Tracks?.Items?.FirstOrDefault(x => x?.Album?.AlbumType == "single" && x.Album.TotalTracks > 3 && x.Album.TotalTracks > currentTrackCount && IsAcceptableAlbumOrSingle(x, artistName, trackName))?.Album; + + if (result != null) + { + _logger.Trace($"Found {result.AlbumType} {result.Name} by {result.Artists.FirstOrDefault()?.Name}"); + } + + return result; + } + + private bool IsAcceptableAlbumOrSingle(FullTrack x, string artistName, string trackName) + { + return x.Name == trackName && + (x.Artists?.Any(a => a.Name == artistName) ?? false) && + !(x.Album.Artists?.Any(a => a.Name == "Various Artists") ?? false) && + ParseSpotifyDate(x?.Album.ReleaseDate, x.Album.ReleaseDatePrecision) <= DateTime.UtcNow; } public override object RequestAction(string action, IDictionary query) { if (action == "getPlaylists") { - if (Settings.AccessToken.IsNullOrWhiteSpace()) + if (Settings.RefreshToken.IsNullOrWhiteSpace()) { return new { @@ -169,53 +185,51 @@ namespace NzbDrone.Core.ImportLists.Spotify }; } - Settings.Validate().Filter("AccessToken").ThrowOnError(); + Settings.Validate().Filter("RefreshToken").ThrowOnError(); - using (var api = GetApi()) + var api = GetApi(); + try { - try + var profile = _spotifyProxy.GetPrivateProfile(this, api); + var playlistPage = _spotifyProxy.GetUserPlaylists(this, api, profile.Id); + _logger.Trace($"Got {playlistPage.Total} playlists"); + + var playlists = new List(); + while (true) { - var profile = _spotifyProxy.GetPrivateProfile(this, api); - var playlistPage = _spotifyProxy.GetUserPlaylists(this, api, profile.Id); - _logger.Trace($"Got {playlistPage.Total} playlists"); - - var playlists = new List(playlistPage.Total); - while (true) + if (playlistPage == null) { - if (playlistPage == null) - { - break; - } - - playlists.AddRange(playlistPage.Items); - - if (!playlistPage.HasNextPage()) - { - break; - } - - playlistPage = _spotifyProxy.GetNextPage(this, api, playlistPage); + break; } - return new + playlists.AddRange(playlistPage.Items); + + if (playlistPage.Next == null) { - options = new - { - user = profile.DisplayName, - playlists = playlists.OrderBy(p => p.Name) - .Select(p => new - { - id = p.Id, - name = p.Name - }) - } - }; + break; + } + + playlistPage = _spotifyProxy.GetNextPage(this, api, playlistPage); } - catch (Exception ex) + + return new { - _logger.Warn(ex, "Error fetching playlists from Spotify"); - return new { }; - } + options = new + { + user = profile.DisplayName, + playlists = playlists.OrderBy(p => p.Name) + .Select(p => new + { + id = p.Id, + name = p.Name + }) + } + }; + } + catch (Exception ex) + { + _logger.Warn(ex, "Error fetching playlists from Spotify"); + return new { }; } } else diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyProxy.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyProxy.cs index 7f7932ab2..5b331bf42 100644 --- a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyProxy.cs +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyProxy.cs @@ -1,28 +1,28 @@ using System; +using System.Collections.Generic; +using System.Threading.Tasks; using NLog; using SpotifyAPI.Web; -using SpotifyAPI.Web.Enums; -using SpotifyAPI.Web.Models; namespace NzbDrone.Core.ImportLists.Spotify { public interface ISpotifyProxy { - PrivateProfile GetPrivateProfile(SpotifyImportListBase list, SpotifyWebAPI api) + PrivateUser GetPrivateProfile(SpotifyImportListBase list, SpotifyClient api) where TSettings : SpotifySettingsBase, new(); - Paging GetUserPlaylists(SpotifyImportListBase list, SpotifyWebAPI api, string id) + Paging GetUserPlaylists(SpotifyImportListBase list, SpotifyClient api, string id) where TSettings : SpotifySettingsBase, new(); - FollowedArtists GetFollowedArtists(SpotifyImportListBase list, SpotifyWebAPI api) + FollowedArtistsResponse GetFollowedArtists(SpotifyImportListBase list, SpotifyClient api) where TSettings : SpotifySettingsBase, new(); - Paging GetSavedAlbums(SpotifyImportListBase list, SpotifyWebAPI api) + Paging GetSavedAlbums(SpotifyImportListBase list, SpotifyClient api) where TSettings : SpotifySettingsBase, new(); - FullPlaylist GetPlaylist(SpotifyImportListBase list, SpotifyWebAPI api, string id, string fields) + FullPlaylist GetPlaylist(SpotifyImportListBase list, SpotifyClient api, string id, List fields) where TSettings : SpotifySettingsBase, new(); - Paging GetNextPage(SpotifyImportListBase list, SpotifyWebAPI api, Paging item) + Paging GetNextPage(SpotifyImportListBase list, SpotifyClient api, Paging item) where TSettings : SpotifySettingsBase, new(); - FollowedArtists GetNextPage(SpotifyImportListBase list, SpotifyWebAPI api, FollowedArtists item) + FollowedArtistsResponse GetNextPage(SpotifyImportListBase list, SpotifyClient api, FollowedArtistsResponse item) where TSettings : SpotifySettingsBase, new(); - SearchItem SearchItems(SpotifyImportListBase list, SpotifyWebAPI api, string query, SearchType type) + SearchResponse SearchItems(SpotifyImportListBase list, SpotifyClient api, string query, SearchRequest.Types type) where TSettings : SpotifySettingsBase, new(); } @@ -35,83 +35,64 @@ namespace NzbDrone.Core.ImportLists.Spotify _logger = logger; } - public PrivateProfile GetPrivateProfile(SpotifyImportListBase list, SpotifyWebAPI api) + public PrivateUser GetPrivateProfile(SpotifyImportListBase list, SpotifyClient api) where TSettings : SpotifySettingsBase, new() { - return Execute(list, api, x => x.GetPrivateProfile()); + return Execute(list, api, x => x.UserProfile.Current()); } - public Paging GetUserPlaylists(SpotifyImportListBase list, SpotifyWebAPI api, string id) + public Paging GetUserPlaylists(SpotifyImportListBase list, SpotifyClient api, string id) where TSettings : SpotifySettingsBase, new() { - return Execute(list, api, x => x.GetUserPlaylists(id)); + return Execute(list, api, x => x.Playlists.GetUsers(id)); } - public FollowedArtists GetFollowedArtists(SpotifyImportListBase list, SpotifyWebAPI api) + public FollowedArtistsResponse GetFollowedArtists(SpotifyImportListBase list, SpotifyClient api) where TSettings : SpotifySettingsBase, new() { - return Execute(list, api, x => x.GetFollowedArtists(FollowType.Artist, 50)); + return Execute(list, api, x => x.Follow.OfCurrentUser(new FollowOfCurrentUserRequest { Limit = 50 })); } - public Paging GetSavedAlbums(SpotifyImportListBase list, SpotifyWebAPI api) + public Paging GetSavedAlbums(SpotifyImportListBase list, SpotifyClient api) where TSettings : SpotifySettingsBase, new() { - return Execute(list, api, x => x.GetSavedAlbums(50)); + return Execute(list, api, x => x.Library.GetAlbums(new LibraryAlbumsRequest { Limit = 50 })); } - public FullPlaylist GetPlaylist(SpotifyImportListBase list, SpotifyWebAPI api, string id, string fields) + public FullPlaylist GetPlaylist(SpotifyImportListBase list, SpotifyClient api, string id, List fields) where TSettings : SpotifySettingsBase, new() { - return Execute(list, api, x => x.GetPlaylist(id, fields: fields)); - } - - public Paging GetNextPage(SpotifyImportListBase list, SpotifyWebAPI api, Paging item) - where TSettings : SpotifySettingsBase, new() - { - return Execute(list, api, (x) => x.GetNextPage(item)); - } - - public FollowedArtists GetNextPage(SpotifyImportListBase list, SpotifyWebAPI api, FollowedArtists item) - where TSettings : SpotifySettingsBase, new() - { - return Execute(list, api, (x) => x.GetNextPage(item.Artists)); - } - - public SearchItem SearchItems(SpotifyImportListBase list, SpotifyWebAPI api, string query, SearchType type) - where TSettings : SpotifySettingsBase, new() - { - return Execute(list, api, (x) => x.SearchItems(query, type)); - } - - public T Execute(SpotifyImportListBase list, SpotifyWebAPI api, Func method, bool allowReauth = true) - where T : BasicModel - where TSettings : SpotifySettingsBase, new() - { - T result = method(api); - if (result.HasError()) + var request = new PlaylistGetRequest(PlaylistGetRequest.AdditionalTypes.Track); + foreach (var field in fields) { - // If unauthorized, refresh token and try again - if (result.Error.Status == 401) - { - if (allowReauth) - { - _logger.Debug("Spotify authorization error, refreshing token and retrying"); - list.RefreshToken(); - api.AccessToken = list.AccessToken; - return Execute(list, api, method, false); - } - else - { - throw new SpotifyAuthorizationException(result.Error.Message); - } - } - else - { - throw new SpotifyException("[{0}] {1}", result.Error.Status, result.Error.Message); - } + request.Fields.Add(field); } - return result; + return Execute(list, api, x => x.Playlists.Get(id, request)); + } + + public Paging GetNextPage(SpotifyImportListBase list, SpotifyClient api, Paging item) + where TSettings : SpotifySettingsBase, new() + { + return Execute(list, api, (x) => x.NextPage(item)); + } + + public FollowedArtistsResponse GetNextPage(SpotifyImportListBase list, SpotifyClient api, FollowedArtistsResponse item) + where TSettings : SpotifySettingsBase, new() + { + return Execute(list, api, (x) => x.NextPage(item.Artists)); + } + + public SearchResponse SearchItems(SpotifyImportListBase list, SpotifyClient api, string query, SearchRequest.Types type) + where TSettings : SpotifySettingsBase, new() + { + return Execute(list, api, (x) => x.Search.Item(new SearchRequest(type, query))); + } + + public T Execute(SpotifyImportListBase list, SpotifyClient api, Func> method) + where TSettings : SpotifySettingsBase, new() + { + return method(api).GetAwaiter().GetResult(); } } } diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyRetryHandler.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyRetryHandler.cs new file mode 100644 index 000000000..d4731d63a --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyRetryHandler.cs @@ -0,0 +1,40 @@ +using System.Net; +using System.Threading.Tasks; +using SpotifyAPI.Web; +using SpotifyAPI.Web.Http; + +namespace NzbDrone.Core.ImportLists.Spotify +{ + public class SpotifyRetryHandler : IRetryHandler + { + private readonly SimpleRetryHandler _simpleHandler; + private readonly AuthorizationCodeTokenResponse _token; + + public SpotifyRetryHandler(AuthorizationCodeTokenResponse token) + { + _token = token; + + _simpleHandler = new SimpleRetryHandler + { + RetryTimes = 3, + RetryErrorCodes = new[] + { + HttpStatusCode.InternalServerError, + HttpStatusCode.BadGateway, + HttpStatusCode.ServiceUnavailable, + HttpStatusCode.Unauthorized + } + }; + } + + public Task HandleRetry(IRequest request, IResponse response, IRetryHandler.RetryFunc retry) + { + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + _token.ExpiresIn = -1; + } + + return _simpleHandler.HandleRetry(request, response, retry); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifySavedAlbums.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifySavedAlbums.cs index 2d9004328..e429066ed 100644 --- a/src/NzbDrone.Core/ImportLists/Spotify/SpotifySavedAlbums.cs +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifySavedAlbums.cs @@ -7,7 +7,6 @@ using NzbDrone.Core.Configuration; using NzbDrone.Core.MetadataSource; using NzbDrone.Core.Parser; using SpotifyAPI.Web; -using SpotifyAPI.Web.Models; namespace NzbDrone.Core.ImportLists.Spotify { @@ -32,7 +31,7 @@ namespace NzbDrone.Core.ImportLists.Spotify public override string Name => "Spotify Saved Albums"; - public override IList Fetch(SpotifyWebAPI api) + public override IList Fetch(SpotifyClient api) { var result = new List(); @@ -52,7 +51,7 @@ namespace NzbDrone.Core.ImportLists.Spotify result.AddIfNotNull(ParseSavedAlbum(savedAlbum)); } - if (!savedAlbums.HasNextPage()) + if (savedAlbums.Next == null) { break; } diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifySettingsBase.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifySettingsBase.cs index 590bfcdbd..5596d26bd 100644 --- a/src/NzbDrone.Core/ImportLists/Spotify/SpotifySettingsBase.cs +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifySettingsBase.cs @@ -10,7 +10,6 @@ namespace NzbDrone.Core.ImportLists.Spotify { public SpotifySettingsBaseValidator() { - RuleFor(c => c.AccessToken).NotEmpty(); RuleFor(c => c.RefreshToken).NotEmpty(); RuleFor(c => c.Expires).NotEmpty(); } diff --git a/src/NzbDrone.Core/Lidarr.Core.csproj b/src/NzbDrone.Core/Lidarr.Core.csproj index 0cf40519d..37306fb7c 100644 --- a/src/NzbDrone.Core/Lidarr.Core.csproj +++ b/src/NzbDrone.Core/Lidarr.Core.csproj @@ -20,7 +20,7 @@ - +