From d075ea362583d0aa4d4168c659b9db721a136b3e Mon Sep 17 00:00:00 2001 From: ta264 Date: Wed, 24 Jul 2019 21:40:30 +0100 Subject: [PATCH] New: Spotify integration Import playlists, followed artists and saved albums --- .../src/Components/Form/FormInputGroup.js | 4 + .../src/Components/Form/PlaylistInput.css | 9 + frontend/src/Components/Form/PlaylistInput.js | 186 ++++++++++++++++ .../Components/Form/PlaylistInputConnector.js | 97 ++++++++ .../Components/Form/ProviderFieldFormGroup.js | 2 + frontend/src/Helpers/Props/inputTypes.js | 2 + .../ImportLists/EditImportListModalContent.js | 1 + .../Annotations/FieldDefinitionAttribute.cs | 3 +- .../ImportLists/ImportListRepository.cs | 6 + .../ImportLists/Spotify/SpotifyException.cs | 27 +++ .../Spotify/SpotifyFollowedArtists.cs | 58 +++++ .../Spotify/SpotifyImportListBase.cs | 210 ++++++++++++++++++ .../ImportLists/Spotify/SpotifyPlaylist.cs | 130 +++++++++++ .../Spotify/SpotifyPlaylistSettings.cs | 30 +++ .../ImportLists/Spotify/SpotifySavedAlbums.cs | 64 ++++++ .../Spotify/SpotifySettingsBase.cs | 55 +++++ .../MetadataSource/SkyHook/SkyHookProxy.cs | 1 + src/NzbDrone.Core/NzbDrone.Core.csproj | 8 + 18 files changed, 892 insertions(+), 1 deletion(-) create mode 100644 frontend/src/Components/Form/PlaylistInput.css create mode 100644 frontend/src/Components/Form/PlaylistInput.js create mode 100644 frontend/src/Components/Form/PlaylistInputConnector.js create mode 100644 src/NzbDrone.Core/ImportLists/Spotify/SpotifyException.cs create mode 100644 src/NzbDrone.Core/ImportLists/Spotify/SpotifyFollowedArtists.cs create mode 100644 src/NzbDrone.Core/ImportLists/Spotify/SpotifyImportListBase.cs create mode 100644 src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylist.cs create mode 100644 src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylistSettings.cs create mode 100644 src/NzbDrone.Core/ImportLists/Spotify/SpotifySavedAlbums.cs create mode 100644 src/NzbDrone.Core/ImportLists/Spotify/SpotifySettingsBase.cs diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index a487d1a0b..9b671cb4a 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -6,6 +6,7 @@ import AutoCompleteInput from './AutoCompleteInput'; import CaptchaInputConnector from './CaptchaInputConnector'; import CheckInput from './CheckInput'; import DeviceInputConnector from './DeviceInputConnector'; +import PlaylistInputConnector from './PlaylistInputConnector'; import KeyValueListInput from './KeyValueListInput'; import MonitorAlbumsSelectInput from './MonitorAlbumsSelectInput'; import NumberInput from './NumberInput'; @@ -39,6 +40,9 @@ function getComponent(type) { case inputTypes.DEVICE: return DeviceInputConnector; + case inputTypes.PLAYLIST: + return PlaylistInputConnector; + case inputTypes.KEY_VALUE_LIST: return KeyValueListInput; diff --git a/frontend/src/Components/Form/PlaylistInput.css b/frontend/src/Components/Form/PlaylistInput.css new file mode 100644 index 000000000..078d3beac --- /dev/null +++ b/frontend/src/Components/Form/PlaylistInput.css @@ -0,0 +1,9 @@ +.playlistInputWrapper { + display: flex; + flex-direction: column; +} + +.input { + composes: input from '~./TagInput.css'; + composes: hasButton from '~Components/Form/Input.css'; +} diff --git a/frontend/src/Components/Form/PlaylistInput.js b/frontend/src/Components/Form/PlaylistInput.js new file mode 100644 index 000000000..df482e7bb --- /dev/null +++ b/frontend/src/Components/Form/PlaylistInput.js @@ -0,0 +1,186 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import tagShape from 'Helpers/Props/Shapes/tagShape'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import styles from './PlaylistInput.css'; + +const columns = [ + { + name: 'name', + label: 'Playlist', + isSortable: false, + isVisible: true + } +]; + +class PlaylistInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const initialSelection = _.mapValues(_.keyBy(props.value), () => true); + + this.state = { + allSelected: false, + allUnselected: false, + selectedState: initialSelection + }; + } + + componentDidUpdate(prevProps, prevState) { + const { + name, + onChange + } = this.props; + + const oldSelected = getSelectedIds(prevState.selectedState, { parseIds: false }).sort(); + const newSelected = this.getSelectedIds().sort(); + + if (!_.isEqual(oldSelected, newSelected)) { + onChange({ + name, + value: newSelected + }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState, { parseIds: false }); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state, props) => { + return toggleSelected(state, props.items, id, value, shiftKey); + }); + } + + // + // Render + + render() { + const { + className, + items, + user, + isFetching, + isPopulated + } = this.props; + + const { + allSelected, + allUnselected, + selectedState + } = this.state; + + return ( +
+ { + isFetching && + + } + + { + !isPopulated && !isFetching && +
+ Authenticate with spotify to retrieve playlists to import. +
+ } + + { + isPopulated && !isFetching && !user && +
+ Could not retrieve data from Spotify. Try re-authenticating. +
+ } + + { + isPopulated && !isFetching && user && !items.length && +
+ No playlists found for Spotify user {user}. +
+ } + + { + isPopulated && !isFetching && user && !!items.length && +
+ Select playlists to import from Spotify user {user}. + + + { + items.map((item) => { + return ( + + + + + {item.name} + + + ); + }) + } + +
+
+ } +
+ ); + } +} + +PlaylistInput.propTypes = { + className: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired, + user: PropTypes.string.isRequired, + items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired +}; + +PlaylistInput.defaultProps = { + className: styles.playlistInputWrapper, + inputClassName: styles.input +}; + +export default PlaylistInput; diff --git a/frontend/src/Components/Form/PlaylistInputConnector.js b/frontend/src/Components/Form/PlaylistInputConnector.js new file mode 100644 index 000000000..e70765671 --- /dev/null +++ b/frontend/src/Components/Form/PlaylistInputConnector.js @@ -0,0 +1,97 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchOptions, clearOptions } from 'Store/Actions/providerOptionActions'; +import PlaylistInput from './PlaylistInput'; + +function createMapStateToProps() { + return createSelector( + (state) => state.providerOptions, + (state) => { + const { + items, + ...otherState + } = state; + return ({ + user: items.user ? items.user : '', + items: items.playlists ? items.playlists : [], + ...otherState + }); + } + ); +} + +const mapDispatchToProps = { + dispatchFetchOptions: fetchOptions, + dispatchClearOptions: clearOptions +}; + +class PlaylistInputConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (this._getAccessToken(this.props)) { + this._populate(); + } + } + + componentDidUpdate(prevProps, prevState) { + const newToken = this._getAccessToken(this.props); + const oldToken = this._getAccessToken(prevProps); + if (newToken && newToken !== oldToken) { + this._populate(); + } + } + + componentWillUnmount = () => { + this.props.dispatchClearOptions(); + } + + // + // Control + + _populate() { + const { + provider, + providerData, + dispatchFetchOptions + } = this.props; + + dispatchFetchOptions({ + action: 'getPlaylists', + provider, + providerData + }); + } + + _getAccessToken(props) { + return _.filter(props.providerData.fields, { name: 'accessToken' })[0].value; + } + + // + // Render + + render() { + return ( + + ); + } +} + +PlaylistInputConnector.propTypes = { + provider: PropTypes.string.isRequired, + providerData: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + dispatchFetchOptions: PropTypes.func.isRequired, + dispatchClearOptions: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(PlaylistInputConnector); diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js index d52afb4db..dca32aa1e 100644 --- a/frontend/src/Components/Form/ProviderFieldFormGroup.js +++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js @@ -14,6 +14,8 @@ function getType(type) { return inputTypes.CHECK; case 'device': return inputTypes.DEVICE; + case 'playlist': + return inputTypes.PLAYLIST; case 'password': return inputTypes.PASSWORD; case 'number': diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index deb8dbb7d..07a7abb84 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -2,6 +2,7 @@ export const AUTO_COMPLETE = 'autoComplete'; export const CAPTCHA = 'captcha'; export const CHECK = 'check'; export const DEVICE = 'device'; +export const PLAYLIST = 'playlist'; export const KEY_VALUE_LIST = 'keyValueList'; export const MONITOR_ALBUMS_SELECT = 'monitorAlbumsSelect'; export const NUMBER = 'number'; @@ -24,6 +25,7 @@ export const all = [ CAPTCHA, CHECK, DEVICE, + PLAYLIST, KEY_VALUE_LIST, MONITOR_ALBUMS_SELECT, NUMBER, diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js index 25da42106..24e143ca3 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js +++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js @@ -221,6 +221,7 @@ function EditImportListModalContent(props) { advancedSettings={advancedSettings} provider="importList" providerData={item} + section="settings.importLists" {...field} onChange={onFieldChange} /> diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index 70a9fbe46..e02acd67d 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -36,7 +36,8 @@ namespace NzbDrone.Core.Annotations Url, Captcha, OAuth, - Device + Device, + Playlist } public enum HiddenType diff --git a/src/NzbDrone.Core/ImportLists/ImportListRepository.cs b/src/NzbDrone.Core/ImportLists/ImportListRepository.cs index 8de00d5e6..3471d39a0 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListRepository.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListRepository.cs @@ -6,6 +6,7 @@ namespace NzbDrone.Core.ImportLists { public interface IImportListRepository : IProviderRepository { + void UpdateSettings(ImportListDefinition model); } public class ImportListRepository : ProviderRepository, IImportListRepository @@ -14,5 +15,10 @@ namespace NzbDrone.Core.ImportLists : base(database, eventAggregator) { } + + public void UpdateSettings(ImportListDefinition model) + { + SetFields(model, m => m.Settings); + } } } diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyException.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyException.cs new file mode 100644 index 000000000..9f2125a36 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyException.cs @@ -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) + { + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyFollowedArtists.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyFollowedArtists.cs new file mode 100644 index 000000000..112a78594 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyFollowedArtists.cs @@ -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 + { + public override string Scope => "user-follow-read"; + } + + public class SpotifyFollowedArtists : SpotifyImportListBase + { + 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 Fetch(SpotifyWebAPI api) + { + var result = new List(); + + 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; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyImportListBase.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyImportListBase.cs new file mode 100644 index 000000000..ddc3fec75 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyImportListBase.cs @@ -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 : ImportListBase + where TSettings : SpotifySettingsBase, 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(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(SpotifyWebAPI api, Func 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 Fetch() + { + using (var api = GetApi()) + { + _logger.Debug("Starting spotify import list sync"); + var releases = Fetch(api); + return CleanupListItems(releases); + } + } + + public abstract IList 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 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 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 { }; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylist.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylist.cs new file mode 100644 index 000000000..0af1ac55b --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylist.cs @@ -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 + { + 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 Fetch(SpotifyWebAPI api) + { + var result = new List(); + + 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 query) + { + if (action == "getPlaylists") + { + if (Settings.AccessToken.IsNullOrWhiteSpace()) + { + return new + { + playlists = new List() + }; + } + + 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(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); + } + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylistSettings.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylistSettings.cs new file mode 100644 index 000000000..ac4d87199 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylistSettings.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using FluentValidation; +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.ImportLists.Spotify +{ + public class SpotifyPlaylistSettingsValidator : SpotifySettingsBaseValidator + { + public SpotifyPlaylistSettingsValidator() + : base() + { + RuleFor(c => c.PlaylistIds).NotEmpty(); + } + } + + public class SpotifyPlaylistSettings : SpotifySettingsBase + { + protected override AbstractValidator Validator => new SpotifyPlaylistSettingsValidator(); + + public SpotifyPlaylistSettings() + { + PlaylistIds = new string[] { }; + } + + public override string Scope => "playlist-read-private"; + + [FieldDefinition(1, Label = "Playlists", Type = FieldType.Playlist)] + public IEnumerable PlaylistIds { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifySavedAlbums.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifySavedAlbums.cs new file mode 100644 index 000000000..f0ee40374 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifySavedAlbums.cs @@ -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 + { + public override string Scope => "user-library-read"; + } + + public class SpotifySavedAlbums : SpotifyImportListBase + { + 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 Fetch(SpotifyWebAPI api) + { + var result = new List(); + + 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; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifySettingsBase.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifySettingsBase.cs new file mode 100644 index 000000000..590bfcdbd --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifySettingsBase.cs @@ -0,0 +1,55 @@ +using System; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Spotify +{ + public class SpotifySettingsBaseValidator : AbstractValidator + where TSettings : SpotifySettingsBase + { + public SpotifySettingsBaseValidator() + { + RuleFor(c => c.AccessToken).NotEmpty(); + RuleFor(c => c.RefreshToken).NotEmpty(); + RuleFor(c => c.Expires).NotEmpty(); + } + } + + public class SpotifySettingsBase : IImportListSettings + where TSettings : SpotifySettingsBase + { + protected virtual AbstractValidator Validator => new SpotifySettingsBaseValidator(); + + 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)); + } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs index 7d3badd8b..a5af778b9 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs @@ -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; } diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 3e5a46386..6f7ac856b 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -569,6 +569,13 @@ + + + + + + + @@ -1333,6 +1340,7 @@ 2.5.0 +