parent
3504cbe9cd
commit
821aa90b14
@ -1,4 +1,4 @@
|
||||
.playlistInputWrapper {
|
||||
.bookshelfInputWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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 new issue