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