New: Goodreads Shelves + Owned Books notifications

This commit is contained in:
ta264
2020-07-12 21:25:19 +01:00
parent 3504cbe9cd
commit 821aa90b14
17 changed files with 586 additions and 62 deletions
@@ -37,7 +37,7 @@ namespace NzbDrone.Core.Annotations
Captcha,
OAuth,
Device,
Playlist
Bookshelf
}
public enum HiddenType
@@ -12,7 +12,7 @@ using NzbDrone.Core.Validation;
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 @@ namespace NzbDrone.Core.ImportLists.Goodreads
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 @@ namespace NzbDrone.Core.ImportLists.Goodreads
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 @@ 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,
@@ -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 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<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));
}
}
}