New: Spotify integration

Import playlists, followed artists and saved albums
This commit is contained in:
ta264 2019-07-24 21:40:30 +01:00
commit d075ea3625
18 changed files with 892 additions and 1 deletions

View file

@ -36,7 +36,8 @@ namespace NzbDrone.Core.Annotations
Url,
Captcha,
OAuth,
Device
Device,
Playlist
}
public enum HiddenType

View file

@ -6,6 +6,7 @@ namespace NzbDrone.Core.ImportLists
{
public interface IImportListRepository : IProviderRepository<ImportListDefinition>
{
void UpdateSettings(ImportListDefinition model);
}
public class ImportListRepository : ProviderRepository<ImportListDefinition>, IImportListRepository
@ -14,5 +15,10 @@ namespace NzbDrone.Core.ImportLists
: base(database, eventAggregator)
{
}
public void UpdateSettings(ImportListDefinition model)
{
SetFields(model, m => m.Settings);
}
}
}

View file

@ -0,0 +1,27 @@
using System;
using NzbDrone.Common.Exceptions;
namespace NzbDrone.Core.ImportLists.Spotify
{
public class SpotifyException : NzbDroneException
{
public SpotifyException(string message) : base(message)
{
}
public SpotifyException(string message, params object[] args) : base(message, args)
{
}
public SpotifyException(string message, Exception innerException) : base(message, innerException)
{
}
}
public class SpotifyAuthorizationException : SpotifyException
{
public SpotifyAuthorizationException(string message) : base(message)
{
}
}
}

View file

@ -0,0 +1,58 @@
using System.Collections.Generic;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model;
using SpotifyAPI.Web;
using SpotifyAPI.Web.Enums;
namespace NzbDrone.Core.ImportLists.Spotify
{
public class SpotifyFollowedArtistsSettings : SpotifySettingsBase<SpotifyFollowedArtistsSettings>
{
public override string Scope => "user-follow-read";
}
public class SpotifyFollowedArtists : SpotifyImportListBase<SpotifyFollowedArtistsSettings>
{
public SpotifyFollowedArtists(IImportListStatusService importListStatusService,
IImportListRepository importListRepository,
IConfigService configService,
IParsingService parsingService,
HttpClient httpClient,
Logger logger)
: base(importListStatusService, importListRepository, configService, parsingService, httpClient, logger)
{
}
public override string Name => "Spotify Followed Artists";
public override IList<ImportListItemInfo> Fetch(SpotifyWebAPI api)
{
var result = new List<ImportListItemInfo>();
var followed = Execute(api, (x) => x.GetFollowedArtists(FollowType.Artist, 50));
var artists = followed.Artists;
while (true)
{
foreach (var artist in artists.Items)
{
if (artist.Name.IsNotNullOrWhiteSpace())
{
result.AddIfNotNull(new ImportListItemInfo
{
Artist = artist.Name,
});
}
}
if (!artists.HasNext())
break;
artists = Execute(api, (x) => x.GetNextPage(artists));
}
return result;
}
}
}

View file

@ -0,0 +1,210 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using FluentValidation.Results;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration;
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
{
public abstract class SpotifyImportListBase<TSettings> : ImportListBase<TSettings>
where TSettings : SpotifySettingsBase<TSettings>, new()
{
private IHttpClient _httpClient;
private IImportListRepository _importListRepository;
public SpotifyImportListBase(IImportListStatusService importListStatusService,
IImportListRepository importListRepository,
IConfigService configService,
IParsingService parsingService,
HttpClient httpClient,
Logger logger)
: base(importListStatusService, configService, parsingService, logger)
{
_httpClient = httpClient;
_importListRepository = importListRepository;
}
private void RefreshToken()
{
_logger.Trace("Refreshing Token");
Settings.Validate().Filter("RefreshToken").ThrowOnError();
var request = new HttpRequestBuilder(Settings.RenewUri)
.AddQueryParam("refresh_token", Settings.RefreshToken)
.Build();
try
{
var response = _httpClient.Get<Token>(request);
if (response != null && 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;
if (Definition.Id > 0)
{
_importListRepository.UpdateSettings((ImportListDefinition)Definition);
}
}
}
catch (HttpException)
{
_logger.Warn($"Error refreshing spotify access token");
}
}
protected SpotifyWebAPI GetApi()
{
Settings.Validate().Filter("AccessToken", "RefreshToken").ThrowOnError();
_logger.Trace($"Access token expires at {Settings.Expires}");
if (Settings.Expires < DateTime.UtcNow.AddMinutes(5))
{
RefreshToken();
}
return new SpotifyWebAPI
{
AccessToken = Settings.AccessToken,
TokenType = "Bearer"
};
}
protected T Execute<T>(SpotifyWebAPI api, Func<SpotifyWebAPI, T> method, bool allowReauth = true) where T : BasicModel
{
T result = method(api);
if (result.HasError())
{
// If unauthorized, refresh token and try again
if (result.Error.Status == 401)
{
if (allowReauth)
{
_logger.Debug("Spotify authorization error, refreshing token and retrying");
RefreshToken();
api.AccessToken = Settings.AccessToken;
return Execute(api, method, false);
}
else
{
throw new SpotifyAuthorizationException(result.Error.Message);
}
}
else
{
throw new SpotifyException("[{0}] {1}", result.Error.Status, result.Error.Message);
}
}
return result;
}
public override IList<ImportListItemInfo> Fetch()
{
using (var api = GetApi())
{
_logger.Debug("Starting spotify import list sync");
var releases = Fetch(api);
return CleanupListItems(releases);
}
}
public abstract IList<ImportListItemInfo> Fetch(SpotifyWebAPI api);
protected DateTime ParseSpotifyDate(string date, string precision)
{
if (date.IsNullOrWhiteSpace() || precision.IsNullOrWhiteSpace())
{
return default(DateTime);
}
string format;
switch (precision) {
case "year":
format = "yyyy";
break;
case "month":
format = "yyyy-MM";
break;
case "day":
default:
format = "yyyy-MM-dd";
break;
}
return DateTime.TryParseExact(date, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result) ? result : default(DateTime);
}
protected override void Test(List<ValidationFailure> failures)
{
failures.AddIfNotNull(TestConnection());
}
private ValidationFailure TestConnection()
{
try
{
using (var api = GetApi())
{
var profile = Execute(api, (x) => x.GetPrivateProfile());
_logger.Debug($"Connected to spotify profile {profile.DisplayName} [{profile.Id}]");
return null;
}
}
catch (SpotifyAuthorizationException ex)
{
_logger.Warn(ex, "Spotify Authentication Error");
return new ValidationFailure(string.Empty, $"Spotify authentication error: {ex.Message}");
}
catch (Exception ex)
{
_logger.Warn(ex, "Unable to connect to Spotify");
return new ValidationFailure(string.Empty, "Unable to connect to import list, check the log for more details");
}
}
public override object RequestAction(string action, IDictionary<string, string> query)
{
if (action == "startOAuth")
{
var request = new HttpRequestBuilder(Settings.OAuthUrl)
.AddQueryParam("client_id", Settings.ClientId)
.AddQueryParam("response_type", "code")
.AddQueryParam("redirect_uri", Settings.RedirectUri)
.AddQueryParam("scope", Settings.Scope)
.AddQueryParam("state", query["callbackUrl"])
.AddQueryParam("show_dialog", true)
.Build();
return new {
OauthUrl = request.Url.ToString()
};
}
else if (action == "getOAuthToken")
{
return new {
accessToken = query["access_token"],
expires = DateTime.UtcNow.AddSeconds(int.Parse(query["expires_in"])),
refreshToken = query["refresh_token"],
};
}
return new { };
}
}
}

View file

@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration;
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
{
public class SpotifyPlaylist : SpotifyImportListBase<SpotifyPlaylistSettings>
{
public SpotifyPlaylist(IImportListStatusService importListStatusService,
IImportListRepository importListRepository,
IConfigService configService,
IParsingService parsingService,
HttpClient httpClient,
Logger logger)
: base(importListStatusService, importListRepository, configService, parsingService, httpClient, logger)
{
}
public override string Name => "Spotify Playlists";
public override IList<ImportListItemInfo> Fetch(SpotifyWebAPI api)
{
var result = new List<ImportListItemInfo>();
foreach (var id in Settings.PlaylistIds)
{
_logger.Trace($"Processing playlist {id}");
var playlistTracks = Execute(api, (x) => x.GetPlaylistTracks(id, fields: "next, items(track(name, album(name,artists)))"));
while (true)
{
foreach (var track in playlistTracks.Items)
{
var fullTrack = track.Track;
// From spotify docs: "Note, a track object may be null. This can happen if a track is no longer available."
if (fullTrack != null)
{
var album = fullTrack.Album?.Name;
var artist = fullTrack.Album?.Artists?.FirstOrDefault()?.Name ?? fullTrack.Artists.FirstOrDefault()?.Name;
if (album.IsNotNullOrWhiteSpace() && artist.IsNotNullOrWhiteSpace())
{
result.AddIfNotNull(new ImportListItemInfo
{
Artist = artist,
Album = album,
ReleaseDate = ParseSpotifyDate(fullTrack.Album.ReleaseDate, fullTrack.Album.ReleaseDatePrecision)
});
}
}
}
if (!playlistTracks.HasNextPage())
break;
playlistTracks = Execute(api, (x) => x.GetNextPage(playlistTracks));
}
}
return result;
}
public override object RequestAction(string action, IDictionary<string, string> query)
{
if (action == "getPlaylists")
{
if (Settings.AccessToken.IsNullOrWhiteSpace())
{
return new
{
playlists = new List<object>()
};
}
Settings.Validate().Filter("AccessToken").ThrowOnError();
using (var api = GetApi())
{
try
{
var profile = Execute(api, (x) => x.GetPrivateProfile());
var playlistPage = Execute(api, (x) => x.GetUserPlaylists(profile.Id));
_logger.Trace($"Got {playlistPage.Total} playlists");
var playlists = new List<SimplePlaylist>(playlistPage.Total);
while (true)
{
playlists.AddRange(playlistPage.Items);
if (!playlistPage.HasNextPage())
break;
playlistPage = Execute(api, (x) => x.GetNextPage(playlistPage));
}
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
{
return base.RequestAction(action, query);
}
}
}
}

View file

@ -0,0 +1,30 @@
using System.Collections.Generic;
using FluentValidation;
using NzbDrone.Core.Annotations;
namespace NzbDrone.Core.ImportLists.Spotify
{
public class SpotifyPlaylistSettingsValidator : SpotifySettingsBaseValidator<SpotifyPlaylistSettings>
{
public SpotifyPlaylistSettingsValidator()
: base()
{
RuleFor(c => c.PlaylistIds).NotEmpty();
}
}
public class SpotifyPlaylistSettings : SpotifySettingsBase<SpotifyPlaylistSettings>
{
protected override AbstractValidator<SpotifyPlaylistSettings> Validator => new SpotifyPlaylistSettingsValidator();
public SpotifyPlaylistSettings()
{
PlaylistIds = new string[] { };
}
public override string Scope => "playlist-read-private";
[FieldDefinition(1, Label = "Playlists", Type = FieldType.Playlist)]
public IEnumerable<string> PlaylistIds { get; set; }
}
}

View file

@ -0,0 +1,64 @@
using System.Collections.Generic;
using System.Linq;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model;
using SpotifyAPI.Web;
namespace NzbDrone.Core.ImportLists.Spotify
{
public class SpotifySavedAlbumsSettings : SpotifySettingsBase<SpotifySavedAlbumsSettings>
{
public override string Scope => "user-library-read";
}
public class SpotifySavedAlbums : SpotifyImportListBase<SpotifySavedAlbumsSettings>
{
public SpotifySavedAlbums(IImportListStatusService importListStatusService,
IImportListRepository importListRepository,
IConfigService configService,
IParsingService parsingService,
HttpClient httpClient,
Logger logger)
: base(importListStatusService, importListRepository, configService, parsingService, httpClient, logger)
{
}
public override string Name => "Spotify Saved Albums";
public override IList<ImportListItemInfo> Fetch(SpotifyWebAPI api)
{
var result = new List<ImportListItemInfo>();
var albums = Execute(api, (x) => x.GetSavedAlbums(50));
_logger.Trace($"Got {albums.Total} saved albums");
while (true)
{
foreach (var album in albums.Items)
{
var artistName = album.Album.Artists.FirstOrDefault()?.Name;
var albumName = album.Album.Name;
_logger.Trace($"Adding {artistName} - {albumName}");
if (artistName.IsNotNullOrWhiteSpace() && albumName.IsNotNullOrWhiteSpace())
{
result.AddIfNotNull(new ImportListItemInfo
{
Artist = artistName,
Album = albumName
});
}
}
if (!albums.HasNextPage())
break;
albums = Execute(api, (x) => x.GetNextPage(albums));
}
return result;
}
}
}

View file

@ -0,0 +1,55 @@
using System;
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.ImportLists.Spotify
{
public class SpotifySettingsBaseValidator<TSettings> : AbstractValidator<TSettings>
where TSettings : SpotifySettingsBase<TSettings>
{
public SpotifySettingsBaseValidator()
{
RuleFor(c => c.AccessToken).NotEmpty();
RuleFor(c => c.RefreshToken).NotEmpty();
RuleFor(c => c.Expires).NotEmpty();
}
}
public class SpotifySettingsBase<TSettings> : IImportListSettings
where TSettings : SpotifySettingsBase<TSettings>
{
protected virtual AbstractValidator<TSettings> Validator => new SpotifySettingsBaseValidator<TSettings>();
public SpotifySettingsBase()
{
BaseUrl = "https://api.spotify.com/v1";
SignIn = "startOAuth";
}
public string BaseUrl { get; set; }
public string OAuthUrl => "https://accounts.spotify.com/authorize";
public string RedirectUri => "https://spotify.lidarr.audio/auth";
public string RenewUri => "https://spotify.lidarr.audio/renew";
public string ClientId => "848082790c32436d8a0405fddca0aa18";
public virtual string Scope => "";
[FieldDefinition(0, Label = "Access Token", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)]
public string AccessToken { get; set; }
[FieldDefinition(0, Label = "Refresh Token", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)]
public string RefreshToken { get; set; }
[FieldDefinition(0, Label = "Expires", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)]
public DateTime Expires { get; set; }
[FieldDefinition(99, Label = "Authenticate with Spotify", Type = FieldType.OAuth)]
public string SignIn { get; set; }
public NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate((TSettings)this));
}
}
}

View file

@ -289,6 +289,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook
artist.Metadata = MapArtistMetadata(resource.Artists.Single(x => x.Id == resource.ArtistId));
}
album.Artist = artist;
album.ArtistMetadata = artist.Metadata;
return album;
}

View file

@ -569,6 +569,13 @@
<Compile Include="ImportLists\LidarrLists\LidarrListsParser.cs" />
<Compile Include="ImportLists\LidarrLists\LidarrListsRequestGenerator.cs" />
<Compile Include="ImportLists\LidarrLists\LidarrListsSettings.cs" />
<Compile Include="ImportLists\Spotify\SpotifyException.cs" />
<Compile Include="ImportLists\Spotify\SpotifySettingsBase.cs" />
<Compile Include="ImportLists\Spotify\SpotifyImportListBase.cs" />
<Compile Include="ImportLists\Spotify\SpotifyPlaylistSettings.cs" />
<Compile Include="ImportLists\Spotify\SpotifyPlaylist.cs" />
<Compile Include="ImportLists\Spotify\SpotifyFollowedArtists.cs" />
<Compile Include="ImportLists\Spotify\SpotifySavedAlbums.cs" />
<Compile Include="IndexerSearch\AlbumSearchCommand.cs" />
<Compile Include="IndexerSearch\AlbumSearchService.cs" />
<Compile Include="IndexerSearch\ArtistSearchCommand.cs" />
@ -1333,6 +1340,7 @@
<PackageReference Include="xmlrpcnet">
<Version>2.5.0</Version>
</PackageReference>
<PackageReference Include="SpotifyAPI.Web" Version="4.2.0" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<PropertyGroup>