New: Spotify integration

Import playlists, followed artists and saved albums
pull/878/head
ta264 5 years ago
parent 2f1290d488
commit d075ea3625

@ -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;

@ -0,0 +1,9 @@
.playlistInputWrapper {
display: flex;
flex-direction: column;
}
.input {
composes: input from '~./TagInput.css';
composes: hasButton from '~Components/Form/Input.css';
}

@ -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 (
<div className={className}>
{
isFetching &&
<LoadingIndicator />
}
{
!isPopulated && !isFetching &&
<div>
Authenticate with spotify to retrieve playlists to import.
</div>
}
{
isPopulated && !isFetching && !user &&
<div>
Could not retrieve data from Spotify. Try re-authenticating.
</div>
}
{
isPopulated && !isFetching && user && !items.length &&
<div>
No playlists found for Spotify user {user}.
</div>
}
{
isPopulated && !isFetching && user && !!items.length &&
<div className={className}>
Select playlists to import from Spotify user {user}.
<Table
columns={columns}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<TableRow
key={item.id}
>
<TableSelectCell
id={item.id}
isSelected={selectedState[item.id]}
onSelectedChange={this.onSelectedChange}
/>
<TableRowCell
className={styles.relativePath}
title={item.name}
>
{item.name}
</TableRowCell>
</TableRow>
);
})
}
</TableBody>
</Table>
</div>
}
</div>
);
}
}
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;

@ -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 (
<PlaylistInput
{...this.props}
onRefreshPress={this.onRefreshPress}
/>
);
}
}
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);

@ -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':

@ -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,

@ -221,6 +221,7 @@ function EditImportListModalContent(props) {
advancedSettings={advancedSettings}
provider="importList"
providerData={item}
section="settings.importLists"
{...field}
onChange={onFieldChange}
/>

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

@ -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);
}
}
}

@ -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)
{
}
}
}

@ -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;
}
}
}

@ -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 { };
}
}
}

@ -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);
}
}
}
}

@ -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; }
}
}

@ -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;
}
}
}

@ -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));
}
}
}

@ -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;
}

@ -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>

Loading…
Cancel
Save