diff --git a/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.js b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.js index 05339e04f..c8f9e0351 100644 --- a/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.js +++ b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.js @@ -49,6 +49,7 @@ function EditRootFolderModalContent(props) { urlBase, username, password, + library, outputFormat, outputProfile, useSsl @@ -100,7 +101,7 @@ function EditRootFolderModalContent(props) { - Calibre Library + Use Calibre + + Calibre Library + + + + Convert to format diff --git a/src/NzbDrone.Core/Books/Calibre/CalibreProxy.cs b/src/NzbDrone.Core/Books/Calibre/CalibreProxy.cs index 883536764..25c85c4ed 100644 --- a/src/NzbDrone.Core/Books/Calibre/CalibreProxy.cs +++ b/src/NzbDrone.Core/Books/Calibre/CalibreProxy.cs @@ -4,6 +4,8 @@ using System.Linq; using System.Net; using System.Threading.Tasks; +using FluentValidation; +using FluentValidation.Results; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Disk; @@ -13,12 +15,12 @@ using NzbDrone.Core.MediaCover; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Core.Validation; namespace NzbDrone.Core.Books.Calibre { public interface ICalibreProxy { - void GetLibraryInfo(CalibreSettings settings); CalibreImportJob AddBook(BookFile book, CalibreSettings settings); void AddFormat(BookFile file, CalibreSettings settings); void RemoveFormats(int calibreId, IEnumerable formats, CalibreSettings settings); @@ -27,10 +29,13 @@ public interface ICalibreProxy long ConvertBook(int calibreId, CalibreConversionOptions options, CalibreSettings settings); List GetAllBookFilePaths(CalibreSettings settings); CalibreBook GetBook(int calibreId, CalibreSettings settings); + void Test(CalibreSettings settings); } public class CalibreProxy : ICalibreProxy { + private const int PAGE_SIZE = 1000; + private readonly IHttpClient _httpClient; private readonly IMapCoversToLocal _mediaCoverService; private readonly IRemotePathMappingService _pathMapper; @@ -62,7 +67,7 @@ public CalibreImportJob AddBook(BookFile book, CalibreSettings settings) try { - var builder = GetBuilder($"cdb/add-book/{jobid}/{addDuplicates}/{filename}", settings); + var builder = GetBuilder($"cdb/add-book/{jobid}/{addDuplicates}/{filename}/{settings.Library}", settings); var request = builder.Build(); request.SetContent(body); @@ -171,7 +176,7 @@ public void SetFields(BookFile file, CalibreSettings settings) private void ExecuteSetFields(int id, CalibreChangesPayload payload, CalibreSettings settings) { - var builder = GetBuilder($"cdb/set-fields/{id}", settings) + var builder = GetBuilder($"cdb/set-fields/{id}/{settings.Library}", settings) .Post() .SetHeader("Content-Type", "application/json"); @@ -185,9 +190,9 @@ public CalibreBookData GetBookData(int calibreId, CalibreSettings settings) { try { - var builder = GetBuilder($"conversion/book-data/{calibreId}", settings); - - var request = builder.Build(); + var request = GetBuilder($"conversion/book-data/{calibreId}", settings) + .AddQueryParam("library_id", settings.Library) + .Build(); return _httpClient.Get(request).Resource; } @@ -201,9 +206,9 @@ public long ConvertBook(int calibreId, CalibreConversionOptions options, Calibre { try { - var builder = GetBuilder($"conversion/start/{calibreId}", settings); - - var request = builder.Build(); + var request = GetBuilder($"conversion/start/{calibreId}", settings) + .AddQueryParam("library_id", settings.Library) + .Build(); request.SetContent(options.ToJson()); var jobId = _httpClient.Post(request).Resource; @@ -223,7 +228,7 @@ public CalibreBook GetBook(int calibreId, CalibreSettings settings) { try { - var builder = GetBuilder($"ajax/book/{calibreId}", settings); + var builder = GetBuilder($"ajax/book/{calibreId}/{settings.Library}", settings); var request = builder.Build(); var book = _httpClient.Get(request).Resource; @@ -248,13 +253,13 @@ public List GetAllBookFilePaths(CalibreSettings settings) var ids = GetAllBookIds(settings); var result = new List(); - const int count = 100; var offset = 0; while (offset < ids.Count) { - var builder = GetBuilder($"ajax/books", settings); - builder.AddQueryParam("ids", ids.Skip(offset).Take(count).ConcatToString(",")); + var builder = GetBuilder($"ajax/books/{settings.Library}", settings); + builder.LogResponseContent = false; + builder.AddQueryParam("ids", ids.Skip(offset).Take(PAGE_SIZE).ConcatToString(",")); var request = builder.Build(); try @@ -279,7 +284,7 @@ public List GetAllBookFilePaths(CalibreSettings settings) throw new CalibreException("Unable to connect to calibre library: {0}", ex, ex.Message); } - offset += count; + offset += PAGE_SIZE; } return result; @@ -288,21 +293,20 @@ public List GetAllBookFilePaths(CalibreSettings settings) public List GetAllBookIds(CalibreSettings settings) { // the magic string is 'allbooks' converted to hex - var builder = GetBuilder($"/ajax/category/616c6c626f6f6b73", settings); - const int count = 100; + var builder = GetBuilder($"/ajax/category/616c6c626f6f6b73/{settings.Library}", settings); var offset = 0; var ids = new List(); while (true) { - var result = GetPaged(builder, count, offset); + var result = GetPaged(builder, PAGE_SIZE, offset); if (!result.Resource.BookIds.Any()) { break; } - offset += count; + offset += PAGE_SIZE; ids.AddRange(result.Resource.BookIds); } @@ -327,18 +331,13 @@ private HttpResponse GetPaged(HttpRequestBuilder builder, int count, int o } } - public void GetLibraryInfo(CalibreSettings settings) + private CalibreLibraryInfo GetLibraryInfo(CalibreSettings settings) { - try - { - var builder = GetBuilder($"ajax/library-info", settings); - var request = builder.Build(); - var response = _httpClient.Execute(request); - } - catch (HttpException ex) - { - throw new CalibreException("Unable to connect to calibre library: {0}", ex, ex.Message); - } + var builder = GetBuilder($"ajax/library-info", settings); + var request = builder.Build(); + var response = _httpClient.Get(request); + + return response.Resource; } private HttpRequestBuilder GetBuilder(string relativePath, CalibreSettings settings) @@ -361,8 +360,9 @@ private HttpRequestBuilder GetBuilder(string relativePath, CalibreSettings setti private async Task PollConvertStatus(long jobId, CalibreSettings settings) { - var builder = GetBuilder($"/conversion/status/{jobId}", settings); - var request = builder.Build(); + var request = GetBuilder($"/conversion/status/{jobId}", settings) + .AddQueryParam("library_id", settings.Library) + .Build(); while (true) { @@ -381,5 +381,93 @@ private async Task PollConvertStatus(long jobId, CalibreSettings settings) await Task.Delay(2000); } } + + public void Test(CalibreSettings settings) + { + var failures = new List { TestCalibre(settings) }; + var validationResult = new ValidationResult(failures); + var result = new NzbDroneValidationResult(validationResult.Errors); + + if (!result.IsValid || result.HasWarnings) + { + throw new ValidationException(result.Failures); + } + } + + private ValidationFailure TestCalibre(CalibreSettings settings) + { + var builder = GetBuilder("", settings); + builder.Accept(HttpAccept.Html); + builder.SuppressHttpError = true; + + var request = builder.Build(); + request.LogResponseContent = false; + HttpResponse response; + + try + { + response = _httpClient.Execute(request); + } + catch (WebException ex) + { + _logger.Error(ex, "Unable to connect to calibre"); + if (ex.Status == WebExceptionStatus.ConnectFailure) + { + return new NzbDroneValidationFailure("Host", "Unable to connect") + { + DetailedDescription = "Please verify the hostname and port." + }; + } + + return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + ex.Message); + } + + if (response.StatusCode == HttpStatusCode.NotFound) + { + return new ValidationFailure("Host", "Could not connect"); + } + + if (response.Content.Contains(@"guac-login")) + { + return new ValidationFailure("Port", "Bad port. This is the container's remote calibre GUI, not the calibre content server. Try mapping port 8081."); + } + + if (!response.Content.Contains(@"calibre")) + { + return new ValidationFailure("Port", "Not a valid calibre content server"); + } + + CalibreLibraryInfo libraryInfo; + try + { + libraryInfo = GetLibraryInfo(settings); + } + catch (HttpException e) + { + if (e.Response.StatusCode == HttpStatusCode.Unauthorized) + { + return new NzbDroneValidationFailure("Username", "Authentication failure") + { + DetailedDescription = "Please verify your username and password." + }; + } + else + { + return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + e.Message); + } + } + + if (settings.Library.IsNullOrWhiteSpace()) + { + settings.Library = libraryInfo.DefaultLibrary; + } + + if (!libraryInfo.LibraryMap.ContainsKey(settings.Library)) + { + return new ValidationFailure("Library", "Not a valid library in calibre"); + } + + return null; + } } } diff --git a/src/NzbDrone.Core/Books/Calibre/CalibreSettings.cs b/src/NzbDrone.Core/Books/Calibre/CalibreSettings.cs index 25f16900f..53b3ad684 100644 --- a/src/NzbDrone.Core/Books/Calibre/CalibreSettings.cs +++ b/src/NzbDrone.Core/Books/Calibre/CalibreSettings.cs @@ -36,6 +36,7 @@ public CalibreSettings() public string UrlBase { get; set; } public string Username { get; set; } public string Password { get; set; } + public string Library { get; set; } public string OutputFormat { get; set; } public int OutputProfile { get; set; } public bool UseSsl { get; set; } diff --git a/src/NzbDrone.Core/Books/Calibre/Resources/CalibreLibraryInfo.cs b/src/NzbDrone.Core/Books/Calibre/Resources/CalibreLibraryInfo.cs new file mode 100644 index 000000000..2f3f4952b --- /dev/null +++ b/src/NzbDrone.Core/Books/Calibre/Resources/CalibreLibraryInfo.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Books.Calibre +{ + public class CalibreLibraryInfo + { + [JsonProperty("library_map")] + public Dictionary LibraryMap { get; set; } + [JsonProperty("default_library")] + public string DefaultLibrary { get; set; } + } +} diff --git a/src/NzbDrone.Core/RootFolders/RootFolderService.cs b/src/NzbDrone.Core/RootFolders/RootFolderService.cs index f3060ebef..368d668b9 100644 --- a/src/NzbDrone.Core/RootFolders/RootFolderService.cs +++ b/src/NzbDrone.Core/RootFolders/RootFolderService.cs @@ -2,15 +2,11 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net; using System.Threading.Tasks; using NLog; using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; -using NzbDrone.Core.Books.Calibre; -using NzbDrone.Core.Exceptions; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Commands; using NzbDrone.Core.Messaging.Commands; @@ -34,19 +30,16 @@ public class RootFolderService : IRootFolderService { private readonly IRootFolderRepository _rootFolderRepository; private readonly IDiskProvider _diskProvider; - private readonly ICalibreProxy _calibreProxy; private readonly IManageCommandQueue _commandQueueManager; private readonly Logger _logger; public RootFolderService(IRootFolderRepository rootFolderRepository, - ICalibreProxy calibreProxy, IDiskProvider diskProvider, IManageCommandQueue commandQueueManager, Logger logger) { _rootFolderRepository = rootFolderRepository; _diskProvider = diskProvider; - _calibreProxy = calibreProxy; _commandQueueManager = commandQueueManager; _logger = logger; } @@ -98,12 +91,6 @@ private void VerifyRootFolder(RootFolder rootFolder) { throw new UnauthorizedAccessException(string.Format("Root folder path '{0}' is not writable by user '{1}'", rootFolder.Path, Environment.UserName)); } - - if (rootFolder.IsCalibreLibrary) - { - // This will throw on failure - _calibreProxy.GetLibraryInfo(rootFolder.CalibreSettings); - } } public RootFolder Add(RootFolder rootFolder) diff --git a/src/Readarr.Api.V1/RootFolders/RootFolderModule.cs b/src/Readarr.Api.V1/RootFolders/RootFolderModule.cs index 808d16eba..706da7ff8 100644 --- a/src/Readarr.Api.V1/RootFolders/RootFolderModule.cs +++ b/src/Readarr.Api.V1/RootFolders/RootFolderModule.cs @@ -16,8 +16,10 @@ namespace Readarr.Api.V1.RootFolders public class RootFolderModule : ReadarrRestModuleWithSignalR { private readonly IRootFolderService _rootFolderService; + private readonly ICalibreProxy _calibreProxy; public RootFolderModule(IRootFolderService rootFolderService, + ICalibreProxy calibreProxy, IBroadcastSignalRMessage signalRBroadcaster, RootFolderValidator rootFolderValidator, PathExistsValidator pathExistsValidator, @@ -30,6 +32,7 @@ public RootFolderModule(IRootFolderService rootFolderService, : base(signalRBroadcaster) { _rootFolderService = rootFolderService; + _calibreProxy = calibreProxy; GetResourceAll = GetRootFolders; GetResourceById = GetRootFolder; @@ -76,6 +79,11 @@ private int CreateRootFolder(RootFolderResource rootFolderResource) { var model = rootFolderResource.ToModel(); + if (model.IsCalibreLibrary) + { + _calibreProxy.Test(model.CalibreSettings); + } + return _rootFolderService.Add(model).Id; } diff --git a/src/Readarr.Api.V1/RootFolders/RootFolderResource.cs b/src/Readarr.Api.V1/RootFolders/RootFolderResource.cs index 946ebbc7a..aa723206b 100644 --- a/src/Readarr.Api.V1/RootFolders/RootFolderResource.cs +++ b/src/Readarr.Api.V1/RootFolders/RootFolderResource.cs @@ -21,6 +21,7 @@ public class RootFolderResource : RestResource public string UrlBase { get; set; } public string Username { get; set; } public string Password { get; set; } + public string Library { get; set; } public string OutputFormat { get; set; } public int OutputProfile { get; set; } public bool UseSsl { get; set; } @@ -55,6 +56,7 @@ public static RootFolderResource ToResource(this RootFolder model) UrlBase = model.CalibreSettings?.UrlBase, Username = model.CalibreSettings?.Username, Password = model.CalibreSettings?.Password, + Library = model.CalibreSettings?.Library, OutputFormat = model.CalibreSettings?.OutputFormat, OutputProfile = model.CalibreSettings?.OutputProfile ?? 0, UseSsl = model.CalibreSettings?.UseSsl ?? false, @@ -82,6 +84,7 @@ public static RootFolder ToModel(this RootFolderResource resource) UrlBase = resource.UrlBase, Username = resource.Username, Password = resource.Password, + Library = resource.Library, OutputFormat = resource.OutputFormat, OutputProfile = resource.OutputProfile, UseSsl = resource.UseSsl