From 821aa90b14bfb31b873e142e3d65d377a94b0ef0 Mon Sep 17 00:00:00 2001 From: ta264 Date: Sun, 12 Jul 2020 21:25:19 +0100 Subject: [PATCH] New: Goodreads Shelves + Owned Books notifications --- .../{PlaylistInput.css => BookshelfInput.css} | 2 +- .../{PlaylistInput.js => BookshelfInput.js} | 18 +- ...onnector.js => BookshelfInputConnector.js} | 22 +- .../src/Components/Form/FormInputGroup.js | 6 +- .../Components/Form/ProviderFieldFormGroup.js | 4 +- frontend/src/Helpers/Props/inputTypes.js | 4 +- .../Annotations/FieldDefinitionAttribute.cs | 2 +- .../{ => Bookshelf}/GoodreadsBookshelf.cs | 16 +- .../GoodreadsBookshelfImportListSettings.cs | 28 +++ .../Goodreads/GoodreadsBookshelfSettings.cs | 28 --- .../{ => OwnedBooks}/GoodreadsOwnedBooks.cs | 5 +- .../Goodreads/Bookshelf/GoodreadsBookshelf.cs | 136 ++++++++++++ .../GoodreadsBookshelfNotificationSettings.cs | 40 ++++ .../Goodreads/GoodreadsNotificationBase.cs | 198 ++++++++++++++++++ .../Goodreads/GoodreadsSettingsBase.cs | 53 +++++ .../OwnedBooks/GoodreadsOwnedBooks.cs | 48 +++++ ...GoodreadsOwnedBooksNotificationSettings.cs | 38 ++++ 17 files changed, 586 insertions(+), 62 deletions(-) rename frontend/src/Components/Form/{PlaylistInput.css => BookshelfInput.css} (86%) rename frontend/src/Components/Form/{PlaylistInput.js => BookshelfInput.js} (93%) rename frontend/src/Components/Form/{PlaylistInputConnector.js => BookshelfInputConnector.js} (79%) rename src/NzbDrone.Core/ImportLists/Goodreads/{ => Bookshelf}/GoodreadsBookshelf.cs (90%) create mode 100644 src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs delete mode 100644 src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsBookshelfSettings.cs rename src/NzbDrone.Core/ImportLists/Goodreads/{ => OwnedBooks}/GoodreadsOwnedBooks.cs (93%) create mode 100644 src/NzbDrone.Core/Notifications/Goodreads/Bookshelf/GoodreadsBookshelf.cs create mode 100644 src/NzbDrone.Core/Notifications/Goodreads/Bookshelf/GoodreadsBookshelfNotificationSettings.cs create mode 100644 src/NzbDrone.Core/Notifications/Goodreads/GoodreadsNotificationBase.cs create mode 100644 src/NzbDrone.Core/Notifications/Goodreads/GoodreadsSettingsBase.cs create mode 100644 src/NzbDrone.Core/Notifications/Goodreads/OwnedBooks/GoodreadsOwnedBooks.cs create mode 100644 src/NzbDrone.Core/Notifications/Goodreads/OwnedBooks/GoodreadsOwnedBooksNotificationSettings.cs diff --git a/frontend/src/Components/Form/PlaylistInput.css b/frontend/src/Components/Form/BookshelfInput.css similarity index 86% rename from frontend/src/Components/Form/PlaylistInput.css rename to frontend/src/Components/Form/BookshelfInput.css index 078d3beac..f47809621 100644 --- a/frontend/src/Components/Form/PlaylistInput.css +++ b/frontend/src/Components/Form/BookshelfInput.css @@ -1,4 +1,4 @@ -.playlistInputWrapper { +.bookshelfInputWrapper { display: flex; flex-direction: column; } diff --git a/frontend/src/Components/Form/PlaylistInput.js b/frontend/src/Components/Form/BookshelfInput.js similarity index 93% rename from frontend/src/Components/Form/PlaylistInput.js rename to frontend/src/Components/Form/BookshelfInput.js index b8dc903d4..24056304f 100644 --- a/frontend/src/Components/Form/PlaylistInput.js +++ b/frontend/src/Components/Form/BookshelfInput.js @@ -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 &&
- Authenticate with Goodreads to retrieve bookshelves to import. + Authenticate with Goodreads to retrieve bookshelves.
} @@ -125,7 +126,7 @@ class PlaylistInput extends Component { { isPopulated && !isFetching && user && !!items.length &&
- Select bookshelves to import from Goodreads user {user}. + {helptext} 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 ( - @@ -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); diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index c4711fc31..54521ddc4 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -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; diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js index dca32aa1e..e6f58be75 100644 --- a/frontend/src/Components/Form/ProviderFieldFormGroup.js +++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js @@ -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': diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index f55bd3248..30c93f6d7 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -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, diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index e02acd67d..453dc84c1 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -37,7 +37,7 @@ namespace NzbDrone.Core.Annotations Captcha, OAuth, Device, - Playlist + Bookshelf } public enum HiddenType diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsBookshelf.cs b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs similarity index 90% rename from src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsBookshelf.cs rename to src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs index 68b0b3802..ffc5d1f2a 100644 --- a/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsBookshelf.cs +++ b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs @@ -12,7 +12,7 @@ using NzbDrone.Core.Validation; namespace NzbDrone.Core.ImportLists.Goodreads { - public class GoodreadsBookshelf : GoodreadsImportListBase + public class GoodreadsBookshelf : GoodreadsImportListBase { public GoodreadsBookshelf(IImportListStatusService importListStatusService, IConfigService configService, @@ -27,7 +27,7 @@ namespace NzbDrone.Core.ImportLists.Goodreads public override IList Fetch() { - return CleanupListItems(Settings.PlaylistIds.SelectMany(x => Fetch(x)).ToList()); + return CleanupListItems(Settings.BookshelfIds.SelectMany(x => Fetch(x)).ToList()); } public IList Fetch(string shelf) @@ -57,13 +57,13 @@ namespace NzbDrone.Core.ImportLists.Goodreads public override object RequestAction(string action, IDictionary query) { - if (action == "getPlaylists") + if (action == "getBookshelves") { if (Settings.AccessToken.IsNullOrWhiteSpace()) { return new { - playlists = new List() + shelves = new List() }; } @@ -83,12 +83,18 @@ namespace NzbDrone.Core.ImportLists.Goodreads 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, diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs new file mode 100644 index 000000000..a19777527 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using FluentValidation; +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.ImportLists.Goodreads +{ + public class GoodreadsBookshelfImportListSettingsValidator : GoodreadsSettingsBaseValidator + { + public GoodreadsBookshelfImportListSettingsValidator() + : base() + { + RuleFor(c => c.BookshelfIds).NotEmpty(); + } + } + + public class GoodreadsBookshelfImportListSettings : GoodreadsSettingsBase + { + public GoodreadsBookshelfImportListSettings() + { + BookshelfIds = new string[] { }; + } + + [FieldDefinition(1, Label = "Bookshelves", Type = FieldType.Bookshelf)] + public IEnumerable BookshelfIds { get; set; } + + protected override AbstractValidator Validator => new GoodreadsBookshelfImportListSettingsValidator(); + } +} diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsBookshelfSettings.cs b/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsBookshelfSettings.cs deleted file mode 100644 index 8f74e5ba7..000000000 --- a/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsBookshelfSettings.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Collections.Generic; -using FluentValidation; -using NzbDrone.Core.Annotations; - -namespace NzbDrone.Core.ImportLists.Goodreads -{ - public class GoodreadsBookshelfSettingsValidator : GoodreadsSettingsBaseValidator - { - public GoodreadsBookshelfSettingsValidator() - : base() - { - RuleFor(c => c.PlaylistIds).NotEmpty(); - } - } - - public class GoodreadsBookshelfSettings : GoodreadsSettingsBase - { - public GoodreadsBookshelfSettings() - { - PlaylistIds = new string[] { }; - } - - [FieldDefinition(1, Label = "Bookshelves", Type = FieldType.Playlist)] - public IEnumerable PlaylistIds { get; set; } - - protected override AbstractValidator Validator => new GoodreadsBookshelfSettingsValidator(); - } -} diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsOwnedBooks.cs b/src/NzbDrone.Core/ImportLists/Goodreads/OwnedBooks/GoodreadsOwnedBooks.cs similarity index 93% rename from src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsOwnedBooks.cs rename to src/NzbDrone.Core/ImportLists/Goodreads/OwnedBooks/GoodreadsOwnedBooks.cs index eaf3e7b5e..ec059fffb 100644 --- a/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsOwnedBooks.cs +++ b/src/NzbDrone.Core/ImportLists/Goodreads/OwnedBooks/GoodreadsOwnedBooks.cs @@ -5,18 +5,17 @@ using NLog; 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 + public class GoodreadsOwnedBooksImportListSettings : GoodreadsSettingsBase { } - public class GoodreadsOwnedBooks : GoodreadsImportListBase + public class GoodreadsOwnedBooks : GoodreadsImportListBase { public GoodreadsOwnedBooks(IImportListStatusService importListStatusService, IConfigService configService, diff --git a/src/NzbDrone.Core/Notifications/Goodreads/Bookshelf/GoodreadsBookshelf.cs b/src/NzbDrone.Core/Notifications/Goodreads/Bookshelf/GoodreadsBookshelf.cs new file mode 100644 index 000000000..2c832238c --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Goodreads/Bookshelf/GoodreadsBookshelf.cs @@ -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 + { + 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 query) + { + if (action == "getBookshelves") + { + if (Settings.AccessToken.IsNullOrWhiteSpace()) + { + return new + { + shelves = new List() + }; + } + + Settings.Validate().Filter("AccessToken").ThrowOnError(); + + var shelves = new List(); + 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 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>("shelves").List; + } + catch (Exception ex) + { + _logger.Warn(ex, "Error fetching bookshelves from Goodreads"); + return new List(); + } + } + + private void RemoveBookFromShelves(string bookId, IEnumerable 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 shelves) + { + var req = RequestBuilder() + .Post() + .SetSegment("route", "shelf/add_books_to_shelves.xml") + .AddFormParameter("bookids", bookId) + .AddFormParameter("shelves", shelves.ConcatToString()); + + OAuthExecute(req); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Goodreads/Bookshelf/GoodreadsBookshelfNotificationSettings.cs b/src/NzbDrone.Core/Notifications/Goodreads/Bookshelf/GoodreadsBookshelfNotificationSettings.cs new file mode 100644 index 000000000..8a0afa14b --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Goodreads/Bookshelf/GoodreadsBookshelfNotificationSettings.cs @@ -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 + { + 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 + { + 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 RemoveIds { get; set; } + + [FieldDefinition(1, Label = "Add to Bookshelves", Type = FieldType.Bookshelf)] + public IEnumerable AddIds { get; set; } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Goodreads/GoodreadsNotificationBase.cs b/src/NzbDrone.Core/Notifications/Goodreads/GoodreadsNotificationBase.cs new file mode 100644 index 000000000..aae715ab4 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Goodreads/GoodreadsNotificationBase.cs @@ -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 : NotificationBase + where TSettings : GoodreadsSettingsBase, 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(); + + failures.AddIfNotNull(TestConnection()); + + return new ValidationResult(failures); + } + + public override object RequestAction(string action, IDictionary 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 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(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); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Goodreads/GoodreadsSettingsBase.cs b/src/NzbDrone.Core/Notifications/Goodreads/GoodreadsSettingsBase.cs new file mode 100644 index 000000000..ac4d3d807 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Goodreads/GoodreadsSettingsBase.cs @@ -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 : AbstractValidator + where TSettings : GoodreadsSettingsBase + { + public GoodreadsSettingsBaseValidator() + { + RuleFor(c => c.AccessToken).NotEmpty(); + RuleFor(c => c.AccessTokenSecret).NotEmpty(); + } + } + + public abstract class GoodreadsSettingsBase : IProviderConfig + where TSettings : GoodreadsSettingsBase + { + 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(); + } +} diff --git a/src/NzbDrone.Core/Notifications/Goodreads/OwnedBooks/GoodreadsOwnedBooks.cs b/src/NzbDrone.Core/Notifications/Goodreads/OwnedBooks/GoodreadsOwnedBooks.cs new file mode 100644 index 000000000..268958c7e --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Goodreads/OwnedBooks/GoodreadsOwnedBooks.cs @@ -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 + { + 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); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Goodreads/OwnedBooks/GoodreadsOwnedBooksNotificationSettings.cs b/src/NzbDrone.Core/Notifications/Goodreads/OwnedBooks/GoodreadsOwnedBooksNotificationSettings.cs new file mode 100644 index 000000000..dbdddfc8e --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Goodreads/OwnedBooks/GoodreadsOwnedBooksNotificationSettings.cs @@ -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 + { + private static readonly GoodreadsSettingsBaseValidator Validator = new GoodreadsSettingsBaseValidator(); + + 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)); + } + } +}