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 @@ public enum FieldType
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 @@ public ImportListRepository(IMainDatabase database, IEventAggregator eventAggreg
: 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