parent
2f1290d488
commit
d075ea3625
@ -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);
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in new issue