mirror of
https://github.com/Readarr/Readarr
synced 2025-12-24 17:24:13 +01:00
New: Goodreads Shelves + Owned Books notifications
This commit is contained in:
parent
3504cbe9cd
commit
821aa90b14
17 changed files with 586 additions and 62 deletions
|
|
@ -1,4 +1,4 @@
|
|||
.playlistInputWrapper {
|
||||
.bookshelfInputWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ 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';
|
||||
import styles from './BookshelfInput.css';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
|
|
@ -22,7 +22,7 @@ const columns = [
|
|||
}
|
||||
];
|
||||
|
||||
class PlaylistInput extends Component {
|
||||
class BookshelfInput extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
|
@ -82,6 +82,7 @@ class PlaylistInput extends Component {
|
|||
render() {
|
||||
const {
|
||||
className,
|
||||
helptext,
|
||||
items,
|
||||
user,
|
||||
isFetching,
|
||||
|
|
@ -104,7 +105,7 @@ class PlaylistInput extends Component {
|
|||
{
|
||||
!isPopulated && !isFetching &&
|
||||
<div>
|
||||
Authenticate with Goodreads to retrieve bookshelves to import.
|
||||
Authenticate with Goodreads to retrieve bookshelves.
|
||||
</div>
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +126,7 @@ class PlaylistInput extends Component {
|
|||
{
|
||||
isPopulated && !isFetching && user && !!items.length &&
|
||||
<div className={className}>
|
||||
Select bookshelves to import from Goodreads user {user}.
|
||||
{helptext}
|
||||
<Table
|
||||
columns={columns}
|
||||
selectAll={true}
|
||||
|
|
@ -165,10 +166,11 @@ class PlaylistInput extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
PlaylistInput.propTypes = {
|
||||
BookshelfInput.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired,
|
||||
helptext: PropTypes.string.isRequired,
|
||||
user: PropTypes.string.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
|
||||
hasError: PropTypes.bool,
|
||||
|
|
@ -178,9 +180,9 @@ PlaylistInput.propTypes = {
|
|||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
PlaylistInput.defaultProps = {
|
||||
className: styles.playlistInputWrapper,
|
||||
BookshelfInput.defaultProps = {
|
||||
className: styles.bookshelfInputWrapper,
|
||||
inputClassName: styles.input
|
||||
};
|
||||
|
||||
export default PlaylistInput;
|
||||
export default BookshelfInput;
|
||||
|
|
@ -4,19 +4,21 @@ 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';
|
||||
import BookshelfInput from './BookshelfInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.providerOptions,
|
||||
(state) => {
|
||||
(state, props) => props.name,
|
||||
(state, name) => {
|
||||
const {
|
||||
items,
|
||||
...otherState
|
||||
} = state;
|
||||
return ({
|
||||
helptext: items.helptext && items.helptext[name] ? items.helptext[name] : '',
|
||||
user: items.user ? items.user : '',
|
||||
items: items.playlists ? items.playlists : [],
|
||||
items: items.shelves ? items.shelves : [],
|
||||
...otherState
|
||||
});
|
||||
}
|
||||
|
|
@ -28,7 +30,7 @@ const mapDispatchToProps = {
|
|||
dispatchClearOptions: clearOptions
|
||||
};
|
||||
|
||||
class PlaylistInputConnector extends Component {
|
||||
class BookshelfInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
|
@ -58,11 +60,13 @@ class PlaylistInputConnector extends Component {
|
|||
const {
|
||||
provider,
|
||||
providerData,
|
||||
dispatchFetchOptions
|
||||
dispatchFetchOptions,
|
||||
name
|
||||
} = this.props;
|
||||
|
||||
dispatchFetchOptions({
|
||||
action: 'getPlaylists',
|
||||
action: 'getBookshelves',
|
||||
queryParams: { name },
|
||||
provider,
|
||||
providerData
|
||||
});
|
||||
|
|
@ -77,7 +81,7 @@ class PlaylistInputConnector extends Component {
|
|||
|
||||
render() {
|
||||
return (
|
||||
<PlaylistInput
|
||||
<BookshelfInput
|
||||
{...this.props}
|
||||
onRefreshPress={this.onRefreshPress}
|
||||
/>
|
||||
|
|
@ -85,7 +89,7 @@ class PlaylistInputConnector extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
PlaylistInputConnector.propTypes = {
|
||||
BookshelfInputConnector.propTypes = {
|
||||
provider: PropTypes.string.isRequired,
|
||||
providerData: PropTypes.object.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
|
|
@ -94,4 +98,4 @@ PlaylistInputConnector.propTypes = {
|
|||
dispatchClearOptions: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(PlaylistInputConnector);
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(BookshelfInputConnector);
|
||||
|
|
@ -6,7 +6,7 @@ import AutoCompleteInput from './AutoCompleteInput';
|
|||
import CaptchaInputConnector from './CaptchaInputConnector';
|
||||
import CheckInput from './CheckInput';
|
||||
import DeviceInputConnector from './DeviceInputConnector';
|
||||
import PlaylistInputConnector from './PlaylistInputConnector';
|
||||
import BookshelfInputConnector from './BookshelfInputConnector';
|
||||
import KeyValueListInput from './KeyValueListInput';
|
||||
import MonitorBooksSelectInput from './MonitorBooksSelectInput';
|
||||
import NumberInput from './NumberInput';
|
||||
|
|
@ -39,8 +39,8 @@ function getComponent(type) {
|
|||
case inputTypes.DEVICE:
|
||||
return DeviceInputConnector;
|
||||
|
||||
case inputTypes.PLAYLIST:
|
||||
return PlaylistInputConnector;
|
||||
case inputTypes.BOOKSHELF:
|
||||
return BookshelfInputConnector;
|
||||
|
||||
case inputTypes.KEY_VALUE_LIST:
|
||||
return KeyValueListInput;
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ function getType(type) {
|
|||
return inputTypes.CHECK;
|
||||
case 'device':
|
||||
return inputTypes.DEVICE;
|
||||
case 'playlist':
|
||||
return inputTypes.PLAYLIST;
|
||||
case 'bookshelf':
|
||||
return inputTypes.BOOKSHELF;
|
||||
case 'password':
|
||||
return inputTypes.PASSWORD;
|
||||
case 'number':
|
||||
|
|
|
|||
|
|
@ -2,7 +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 BOOKSHELF = 'bookshelf';
|
||||
export const KEY_VALUE_LIST = 'keyValueList';
|
||||
export const MONITOR_BOOKS_SELECT = 'monitorBooksSelect';
|
||||
export const NUMBER = 'number';
|
||||
|
|
@ -24,7 +24,7 @@ export const all = [
|
|||
CAPTCHA,
|
||||
CHECK,
|
||||
DEVICE,
|
||||
PLAYLIST,
|
||||
BOOKSHELF,
|
||||
KEY_VALUE_LIST,
|
||||
MONITOR_BOOKS_SELECT,
|
||||
NUMBER,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ public enum FieldType
|
|||
Captcha,
|
||||
OAuth,
|
||||
Device,
|
||||
Playlist
|
||||
Bookshelf
|
||||
}
|
||||
|
||||
public enum HiddenType
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
namespace NzbDrone.Core.ImportLists.Goodreads
|
||||
{
|
||||
public class GoodreadsBookshelf : GoodreadsImportListBase<GoodreadsBookshelfSettings>
|
||||
public class GoodreadsBookshelf : GoodreadsImportListBase<GoodreadsBookshelfImportListSettings>
|
||||
{
|
||||
public GoodreadsBookshelf(IImportListStatusService importListStatusService,
|
||||
IConfigService configService,
|
||||
|
|
@ -27,7 +27,7 @@ public GoodreadsBookshelf(IImportListStatusService importListStatusService,
|
|||
|
||||
public override IList<ImportListItemInfo> Fetch()
|
||||
{
|
||||
return CleanupListItems(Settings.PlaylistIds.SelectMany(x => Fetch(x)).ToList());
|
||||
return CleanupListItems(Settings.BookshelfIds.SelectMany(x => Fetch(x)).ToList());
|
||||
}
|
||||
|
||||
public IList<ImportListItemInfo> Fetch(string shelf)
|
||||
|
|
@ -57,13 +57,13 @@ public IList<ImportListItemInfo> Fetch(string shelf)
|
|||
|
||||
public override object RequestAction(string action, IDictionary<string, string> query)
|
||||
{
|
||||
if (action == "getPlaylists")
|
||||
if (action == "getBookshelves")
|
||||
{
|
||||
if (Settings.AccessToken.IsNullOrWhiteSpace())
|
||||
{
|
||||
return new
|
||||
{
|
||||
playlists = new List<object>()
|
||||
shelves = new List<object>()
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -83,12 +83,18 @@ public override object RequestAction(string action, IDictionary<string, string>
|
|||
shelves.AddRange(curr);
|
||||
}
|
||||
|
||||
var helptext = new
|
||||
{
|
||||
shelfIds = $"Import books from {Settings.UserName}'s shelves:"
|
||||
};
|
||||
|
||||
return new
|
||||
{
|
||||
options = new
|
||||
{
|
||||
helptext,
|
||||
user = Settings.UserName,
|
||||
playlists = shelves.OrderBy(p => p.Name)
|
||||
shelves = shelves.OrderBy(p => p.Name)
|
||||
.Select(p => new
|
||||
{
|
||||
id = p.Name,
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
using System.Collections.Generic;
|
||||
using FluentValidation;
|
||||
using NzbDrone.Core.Annotations;
|
||||
|
||||
namespace NzbDrone.Core.ImportLists.Goodreads
|
||||
{
|
||||
public class GoodreadsBookshelfImportListSettingsValidator : GoodreadsSettingsBaseValidator<GoodreadsBookshelfImportListSettings>
|
||||
{
|
||||
public GoodreadsBookshelfImportListSettingsValidator()
|
||||
: base()
|
||||
{
|
||||
RuleFor(c => c.BookshelfIds).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public class GoodreadsBookshelfImportListSettings : GoodreadsSettingsBase<GoodreadsBookshelfImportListSettings>
|
||||
{
|
||||
public GoodreadsBookshelfImportListSettings()
|
||||
{
|
||||
BookshelfIds = new string[] { };
|
||||
}
|
||||
|
||||
[FieldDefinition(1, Label = "Bookshelves", Type = FieldType.Bookshelf)]
|
||||
public IEnumerable<string> BookshelfIds { get; set; }
|
||||
|
||||
protected override AbstractValidator<GoodreadsBookshelfImportListSettings> Validator => new GoodreadsBookshelfImportListSettingsValidator();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using FluentValidation;
|
||||
using NzbDrone.Core.Annotations;
|
||||
|
||||
namespace NzbDrone.Core.ImportLists.Goodreads
|
||||
{
|
||||
public class GoodreadsBookshelfSettingsValidator : GoodreadsSettingsBaseValidator<GoodreadsBookshelfSettings>
|
||||
{
|
||||
public GoodreadsBookshelfSettingsValidator()
|
||||
: base()
|
||||
{
|
||||
RuleFor(c => c.PlaylistIds).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public class GoodreadsBookshelfSettings : GoodreadsSettingsBase<GoodreadsBookshelfSettings>
|
||||
{
|
||||
public GoodreadsBookshelfSettings()
|
||||
{
|
||||
PlaylistIds = new string[] { };
|
||||
}
|
||||
|
||||
[FieldDefinition(1, Label = "Bookshelves", Type = FieldType.Playlist)]
|
||||
public IEnumerable<string> PlaylistIds { get; set; }
|
||||
|
||||
protected override AbstractValidator<GoodreadsBookshelfSettings> Validator => new GoodreadsBookshelfSettingsValidator();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,18 +5,17 @@
|
|||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.MetadataSource;
|
||||
using NzbDrone.Core.MetadataSource.Goodreads;
|
||||
using NzbDrone.Core.Parser;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.ImportLists.Goodreads
|
||||
{
|
||||
public class GoodreadsOwnedBooksSettings : GoodreadsSettingsBase<GoodreadsOwnedBooksSettings>
|
||||
public class GoodreadsOwnedBooksImportListSettings : GoodreadsSettingsBase<GoodreadsOwnedBooksImportListSettings>
|
||||
{
|
||||
}
|
||||
|
||||
public class GoodreadsOwnedBooks : GoodreadsImportListBase<GoodreadsOwnedBooksSettings>
|
||||
public class GoodreadsOwnedBooks : GoodreadsImportListBase<GoodreadsOwnedBooksImportListSettings>
|
||||
{
|
||||
public GoodreadsOwnedBooks(IImportListStatusService importListStatusService,
|
||||
IConfigService configService,
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Core.MetadataSource.Goodreads;
|
||||
using NzbDrone.Core.Validation;
|
||||
|
||||
namespace NzbDrone.Core.Notifications.Goodreads
|
||||
{
|
||||
public class GoodreadsBookshelf : GoodreadsNotificationBase<GoodreadsBookshelfNotificationSettings>
|
||||
{
|
||||
public GoodreadsBookshelf(IHttpClient httpClient,
|
||||
Logger logger)
|
||||
: base(httpClient, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "Goodreads Bookshelves";
|
||||
public override string Link => "https://goodreads.com/";
|
||||
|
||||
public override void OnReleaseImport(BookDownloadMessage message)
|
||||
{
|
||||
var bookId = message.Book.Editions.Value.Single(x => x.Monitored).ForeignEditionId;
|
||||
RemoveBookFromShelves(bookId, Settings.RemoveIds);
|
||||
AddToShelves(bookId, Settings.AddIds);
|
||||
}
|
||||
|
||||
public override object RequestAction(string action, IDictionary<string, string> query)
|
||||
{
|
||||
if (action == "getBookshelves")
|
||||
{
|
||||
if (Settings.AccessToken.IsNullOrWhiteSpace())
|
||||
{
|
||||
return new
|
||||
{
|
||||
shelves = new List<object>()
|
||||
};
|
||||
}
|
||||
|
||||
Settings.Validate().Filter("AccessToken").ThrowOnError();
|
||||
|
||||
var shelves = new List<UserShelfResource>();
|
||||
var page = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var curr = GetShelfList(++page);
|
||||
if (curr == null || curr.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
shelves.AddRange(curr);
|
||||
}
|
||||
|
||||
_logger.Trace($"Name: {query["name"]} {query["name"] == "removeIds"}");
|
||||
|
||||
var helptext = new
|
||||
{
|
||||
addIds = $"Add imported book to {Settings.UserName}'s shelves:",
|
||||
removeIds = $"Remove imported book from {Settings.UserName}'s shelves:"
|
||||
};
|
||||
|
||||
return new
|
||||
{
|
||||
options = new
|
||||
{
|
||||
helptext,
|
||||
user = Settings.UserName,
|
||||
shelves = shelves.OrderBy(p => p.Name)
|
||||
.Select(p => new
|
||||
{
|
||||
id = p.Name,
|
||||
name = p.Name
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return base.RequestAction(action, query);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<UserShelfResource> GetShelfList(int page)
|
||||
{
|
||||
try
|
||||
{
|
||||
var builder = RequestBuilder()
|
||||
.SetSegment("route", $"shelf/list.xml")
|
||||
.AddQueryParam("user_id", Settings.UserId)
|
||||
.AddQueryParam("page", page);
|
||||
|
||||
var httpResponse = OAuthExecute(builder);
|
||||
|
||||
return httpResponse.Deserialize<PaginatedList<UserShelfResource>>("shelves").List;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn(ex, "Error fetching bookshelves from Goodreads");
|
||||
return new List<UserShelfResource>();
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveBookFromShelves(string bookId, IEnumerable<string> shelves)
|
||||
{
|
||||
foreach (var shelf in shelves)
|
||||
{
|
||||
var req = RequestBuilder()
|
||||
.Post()
|
||||
.SetSegment("route", "shelf/add_to_shelf.xml")
|
||||
.AddFormParameter("name", shelf)
|
||||
.AddFormParameter("book_id", bookId)
|
||||
.AddFormParameter("a", "remove");
|
||||
|
||||
// in case not found in shelf
|
||||
req.SuppressHttpError = true;
|
||||
|
||||
OAuthExecute(req);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddToShelves(string bookId, IEnumerable<string> shelves)
|
||||
{
|
||||
var req = RequestBuilder()
|
||||
.Post()
|
||||
.SetSegment("route", "shelf/add_books_to_shelves.xml")
|
||||
.AddFormParameter("bookids", bookId)
|
||||
.AddFormParameter("shelves", shelves.ConcatToString());
|
||||
|
||||
OAuthExecute(req);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentValidation;
|
||||
using NzbDrone.Core.Annotations;
|
||||
using NzbDrone.Core.Validation;
|
||||
|
||||
namespace NzbDrone.Core.Notifications.Goodreads
|
||||
{
|
||||
public class GoodreadsBookshelfNotificationSettingsValidator : GoodreadsSettingsBaseValidator<GoodreadsBookshelfNotificationSettings>
|
||||
{
|
||||
public GoodreadsBookshelfNotificationSettingsValidator()
|
||||
: base()
|
||||
{
|
||||
RuleFor(c => c.RemoveIds).NotEmpty().When(c => !c.AddIds.Any());
|
||||
RuleFor(c => c.AddIds).NotEmpty().When(c => !c.RemoveIds.Any());
|
||||
}
|
||||
}
|
||||
|
||||
public class GoodreadsBookshelfNotificationSettings : GoodreadsSettingsBase<GoodreadsBookshelfNotificationSettings>
|
||||
{
|
||||
private static readonly GoodreadsBookshelfNotificationSettingsValidator Validator = new GoodreadsBookshelfNotificationSettingsValidator();
|
||||
|
||||
public GoodreadsBookshelfNotificationSettings()
|
||||
{
|
||||
RemoveIds = new string[] { };
|
||||
AddIds = new string[] { };
|
||||
}
|
||||
|
||||
[FieldDefinition(1, Label = "Remove from Bookshelves", Type = FieldType.Bookshelf)]
|
||||
public IEnumerable<string> RemoveIds { get; set; }
|
||||
|
||||
[FieldDefinition(1, Label = "Add to Bookshelves", Type = FieldType.Bookshelf)]
|
||||
public IEnumerable<string> AddIds { get; set; }
|
||||
|
||||
public override NzbDroneValidationResult Validate()
|
||||
{
|
||||
return new NzbDroneValidationResult(Validator.Validate(this));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Web;
|
||||
using System.Xml.Linq;
|
||||
using System.Xml.XPath;
|
||||
using FluentValidation.Results;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Common.OAuth;
|
||||
using NzbDrone.Common.Serializer;
|
||||
using NzbDrone.Core.Exceptions;
|
||||
using NzbDrone.Core.ImportLists.Goodreads;
|
||||
using NzbDrone.Core.MetadataSource.Goodreads;
|
||||
|
||||
namespace NzbDrone.Core.Notifications.Goodreads
|
||||
{
|
||||
public abstract class GoodreadsNotificationBase<TSettings> : NotificationBase<TSettings>
|
||||
where TSettings : GoodreadsSettingsBase<TSettings>, new()
|
||||
{
|
||||
protected readonly IHttpClient _httpClient;
|
||||
protected readonly Logger _logger;
|
||||
|
||||
protected GoodreadsNotificationBase(IHttpClient httpClient,
|
||||
Logger logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public override string Link => "https://goodreads.com/";
|
||||
|
||||
public override ValidationResult Test()
|
||||
{
|
||||
var failures = new List<ValidationFailure>();
|
||||
|
||||
failures.AddIfNotNull(TestConnection());
|
||||
|
||||
return new ValidationResult(failures);
|
||||
}
|
||||
|
||||
public override object RequestAction(string action, IDictionary<string, string> query)
|
||||
{
|
||||
if (action == "startOAuth")
|
||||
{
|
||||
if (query["callbackUrl"].IsNullOrWhiteSpace())
|
||||
{
|
||||
throw new BadRequestException("QueryParam callbackUrl invalid.");
|
||||
}
|
||||
|
||||
var oAuthRequest = OAuthRequest.ForRequestToken(null, null, query["callbackUrl"]);
|
||||
oAuthRequest.RequestUrl = Settings.OAuthRequestTokenUrl;
|
||||
var qscoll = OAuthQuery(oAuthRequest);
|
||||
|
||||
var url = string.Format("{0}?oauth_token={1}&oauth_callback={2}", Settings.OAuthUrl, qscoll["oauth_token"], query["callbackUrl"]);
|
||||
|
||||
return new
|
||||
{
|
||||
OauthUrl = url,
|
||||
RequestTokenSecret = qscoll["oauth_token_secret"]
|
||||
};
|
||||
}
|
||||
else if (action == "getOAuthToken")
|
||||
{
|
||||
if (query["oauth_token"].IsNullOrWhiteSpace())
|
||||
{
|
||||
throw new BadRequestException("QueryParam oauth_token invalid.");
|
||||
}
|
||||
|
||||
if (query["requestTokenSecret"].IsNullOrWhiteSpace())
|
||||
{
|
||||
throw new BadRequestException("Missing requestTokenSecret.");
|
||||
}
|
||||
|
||||
var oAuthRequest = OAuthRequest.ForAccessToken(null, null, query["oauth_token"], query["requestTokenSecret"], "");
|
||||
oAuthRequest.RequestUrl = Settings.OAuthAccessTokenUrl;
|
||||
var qscoll = OAuthQuery(oAuthRequest);
|
||||
|
||||
Settings.AccessToken = qscoll["oauth_token"];
|
||||
Settings.AccessTokenSecret = qscoll["oauth_token_secret"];
|
||||
|
||||
var user = GetUser();
|
||||
|
||||
return new
|
||||
{
|
||||
Settings.AccessToken,
|
||||
Settings.AccessTokenSecret,
|
||||
RequestTokenSecret = "",
|
||||
UserId = user.Item1,
|
||||
UserName = user.Item2
|
||||
};
|
||||
}
|
||||
|
||||
return new { };
|
||||
}
|
||||
|
||||
protected HttpRequestBuilder RequestBuilder()
|
||||
{
|
||||
return new HttpRequestBuilder("https://www.goodreads.com/{route}").KeepAlive();
|
||||
}
|
||||
|
||||
protected Common.Http.HttpResponse OAuthExecute(HttpRequestBuilder builder)
|
||||
{
|
||||
var auth = OAuthRequest.ForProtectedResource(builder.Method.ToString(), null, null, Settings.AccessToken, Settings.AccessTokenSecret);
|
||||
|
||||
var request = builder.Build();
|
||||
request.LogResponseContent = true;
|
||||
|
||||
// we need the url without the query to sign
|
||||
auth.RequestUrl = request.Url.SetQuery(null).FullUri;
|
||||
|
||||
if (builder.Method == HttpMethod.GET)
|
||||
{
|
||||
auth.Parameters = builder.QueryParams.ToDictionary(x => x.Key, x => x.Value);
|
||||
}
|
||||
else if (builder.Method == HttpMethod.POST)
|
||||
{
|
||||
auth.Parameters = builder.FormData.ToDictionary(x => x.Name, x => Encoding.UTF8.GetString(x.ContentData));
|
||||
}
|
||||
|
||||
var header = GetAuthorizationHeader(auth);
|
||||
request.Headers.Add("Authorization", header);
|
||||
|
||||
return _httpClient.Execute(request);
|
||||
}
|
||||
|
||||
private ValidationFailure TestConnection()
|
||||
{
|
||||
try
|
||||
{
|
||||
GetUser();
|
||||
return null;
|
||||
}
|
||||
catch (Common.Http.HttpException ex)
|
||||
{
|
||||
_logger.Warn(ex, "Goodreads Authentication Error");
|
||||
return new ValidationFailure(string.Empty, $"Goodreads authentication error: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn(ex, "Unable to connect to Goodreads");
|
||||
|
||||
return new ValidationFailure(string.Empty, "Unable to connect to Goodreads, check the log for more details");
|
||||
}
|
||||
}
|
||||
|
||||
private Tuple<string, string> GetUser()
|
||||
{
|
||||
var builder = RequestBuilder().SetSegment("route", "api/auth_user");
|
||||
|
||||
var httpResponse = OAuthExecute(builder);
|
||||
|
||||
string userId = null;
|
||||
string userName = null;
|
||||
|
||||
var content = httpResponse.Content;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
var user = XDocument.Parse(content).XPathSelectElement("GoodreadsResponse/user");
|
||||
userId = user.AttributeAsString("id");
|
||||
userName = user.ElementAsString("name");
|
||||
}
|
||||
|
||||
return Tuple.Create(userId, userName);
|
||||
}
|
||||
|
||||
private string GetAuthorizationHeader(OAuthRequest oAuthRequest)
|
||||
{
|
||||
var request = new Common.Http.HttpRequest(Settings.SigningUrl)
|
||||
{
|
||||
Method = HttpMethod.POST,
|
||||
};
|
||||
request.Headers.Set("Content-Type", "application/json");
|
||||
|
||||
var payload = oAuthRequest.ToJson();
|
||||
_logger.Trace(payload);
|
||||
request.SetContent(payload);
|
||||
|
||||
var response = _httpClient.Post<AuthorizationHeader>(request).Resource;
|
||||
|
||||
return response.Authorization;
|
||||
}
|
||||
|
||||
private NameValueCollection OAuthQuery(OAuthRequest oAuthRequest)
|
||||
{
|
||||
var auth = GetAuthorizationHeader(oAuthRequest);
|
||||
var request = new Common.Http.HttpRequest(oAuthRequest.RequestUrl);
|
||||
request.Headers.Add("Authorization", auth);
|
||||
var response = _httpClient.Get(request);
|
||||
|
||||
return HttpUtility.ParseQueryString(response.Content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
using FluentValidation;
|
||||
using NzbDrone.Core.Annotations;
|
||||
using NzbDrone.Core.ThingiProvider;
|
||||
using NzbDrone.Core.Validation;
|
||||
|
||||
namespace NzbDrone.Core.Notifications.Goodreads
|
||||
{
|
||||
public class GoodreadsSettingsBaseValidator<TSettings> : AbstractValidator<TSettings>
|
||||
where TSettings : GoodreadsSettingsBase<TSettings>
|
||||
{
|
||||
public GoodreadsSettingsBaseValidator()
|
||||
{
|
||||
RuleFor(c => c.AccessToken).NotEmpty();
|
||||
RuleFor(c => c.AccessTokenSecret).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class GoodreadsSettingsBase<TSettings> : IProviderConfig
|
||||
where TSettings : GoodreadsSettingsBase<TSettings>
|
||||
{
|
||||
public GoodreadsSettingsBase()
|
||||
{
|
||||
SignIn = "startOAuth";
|
||||
}
|
||||
|
||||
public string SigningUrl => "https://auth.servarr.com/v1/goodreads/sign";
|
||||
public string OAuthUrl => "https://www.goodreads.com/oauth/authorize";
|
||||
public string OAuthRequestTokenUrl => "https://www.goodreads.com/oauth/request_token";
|
||||
public string OAuthAccessTokenUrl => "https://www.goodreads.com/oauth/access_token";
|
||||
|
||||
[FieldDefinition(0, Label = "Access Token", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)]
|
||||
public string AccessToken { get; set; }
|
||||
|
||||
[FieldDefinition(0, Label = "Access Token Secret", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)]
|
||||
public string AccessTokenSecret { get; set; }
|
||||
|
||||
[FieldDefinition(0, Label = "Request Token Secret", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)]
|
||||
public string RequestTokenSecret { get; set; }
|
||||
|
||||
[FieldDefinition(0, Label = "User Id", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)]
|
||||
public string UserId { get; set; }
|
||||
|
||||
[FieldDefinition(0, Label = "User Name", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)]
|
||||
public string UserName { get; set; }
|
||||
|
||||
[FieldDefinition(99, Label = "Authenticate with Goodreads", Type = FieldType.OAuth)]
|
||||
public string SignIn { get; set; }
|
||||
|
||||
public bool IsValid => !string.IsNullOrWhiteSpace(AccessTokenSecret);
|
||||
|
||||
public abstract NzbDroneValidationResult Validate();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Http;
|
||||
|
||||
namespace NzbDrone.Core.Notifications.Goodreads
|
||||
{
|
||||
public class GoodreadsOwnedBooks : GoodreadsNotificationBase<GoodreadsOwnedBooksNotificationSettings>
|
||||
{
|
||||
public GoodreadsOwnedBooks(IHttpClient httpClient,
|
||||
Logger logger)
|
||||
: base(httpClient, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "Goodreads Owned Books";
|
||||
public override string Link => "https://goodreads.com/";
|
||||
|
||||
public override void OnReleaseImport(BookDownloadMessage message)
|
||||
{
|
||||
var bookId = message.Book.Editions.Value.Single(x => x.Monitored).ForeignEditionId;
|
||||
AddOwnedBook(bookId);
|
||||
}
|
||||
|
||||
private void AddOwnedBook(string bookId)
|
||||
{
|
||||
var req = RequestBuilder()
|
||||
.Post()
|
||||
.SetSegment("route", "owned_books.xml")
|
||||
.AddFormParameter("owned_book[book_id]", bookId)
|
||||
.AddFormParameter("owned_book[condition_code]", Settings.Condition)
|
||||
.AddFormParameter("owned_book[original_purchase_date]", DateTime.Now.ToString("O"));
|
||||
|
||||
if (Settings.Description.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
req.AddFormParameter("owned_book[condition_description]", Settings.Description);
|
||||
}
|
||||
|
||||
if (Settings.Location.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
req.AddFormParameter("owned_book[original_purchase_location]", Settings.Location);
|
||||
}
|
||||
|
||||
OAuthExecute(req);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
using NzbDrone.Core.Annotations;
|
||||
using NzbDrone.Core.Validation;
|
||||
|
||||
namespace NzbDrone.Core.Notifications.Goodreads
|
||||
{
|
||||
public enum OwnedBookCondition
|
||||
{
|
||||
BrandNew = 10,
|
||||
LikeNew = 20,
|
||||
VeryGood = 30,
|
||||
Good = 40,
|
||||
Acceptable = 50,
|
||||
Poor = 60
|
||||
}
|
||||
|
||||
public class GoodreadsOwnedBooksNotificationSettings : GoodreadsSettingsBase<GoodreadsOwnedBooksNotificationSettings>
|
||||
{
|
||||
private static readonly GoodreadsSettingsBaseValidator<GoodreadsOwnedBooksNotificationSettings> Validator = new GoodreadsSettingsBaseValidator<GoodreadsOwnedBooksNotificationSettings>();
|
||||
|
||||
public GoodreadsOwnedBooksNotificationSettings()
|
||||
{
|
||||
}
|
||||
|
||||
[FieldDefinition(1, Label = "Condition", Type = FieldType.Select, SelectOptions = typeof(OwnedBookCondition))]
|
||||
public int Condition { get; set; } = (int)OwnedBookCondition.BrandNew;
|
||||
|
||||
[FieldDefinition(1, Label = "Condition Description", Type = FieldType.Textbox)]
|
||||
public string Description { get; set; }
|
||||
|
||||
[FieldDefinition(1, Label = "Purchase Location", HelpText = "Will be displayed on Goodreads website", Type = FieldType.Textbox)]
|
||||
public string Location { get; set; }
|
||||
|
||||
public override NzbDroneValidationResult Validate()
|
||||
{
|
||||
return new NzbDroneValidationResult(Validator.Validate(this));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue