Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/Chaptarr.Core.Test/Books/CalibreProxySetFieldsFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using NLog;
using NUnit.Framework;
using NzbDrone.Common.Cache;
using NzbDrone.Common.Http;
using NzbDrone.Core.Books;
using NzbDrone.Core.Books.Calibre;
using NzbDrone.Core.MediaFiles;

namespace Chaptarr.Core.Test.Books
{
[TestFixture]
public class CalibreProxySetFieldsFixture
{
private sealed class ReachedHttpException : Exception
{
}

// Any HTTP call means the payload was built; stop there.
private class HttpClientProxy : DispatchProxy
{
protected override object Invoke(MethodInfo targetMethod, object[] args)
{
throw new ReachedHttpException();
}
}

[Test]
public void should_build_payload_when_provider_metadata_is_missing()
{
var book = new Book
{
Id = 1,
Title = "City of Lost Souls",
Genres = null,
LazySeriesLinks = null
};
var edition = new Edition
{
Id = 2,
BookId = book.Id,
Book = book,
Title = book.Title,
Images = null,
Ratings = null
};
var file = new BookFile
{
CalibreId = 2766,
Path = "/data/calibre/library/Cassandra Clare/City of Lost Souls (2766)/City of Lost Souls.epub",
Edition = edition,
Author = new Author { Name = "Cassandra Clare" }
};

var subject = new CalibreProxy(
DispatchProxy.Create<IHttpClient, HttpClientProxy>(),
null, null, null, null, null, new CacheManager(),
LogManager.GetLogger(nameof(CalibreProxySetFieldsFixture)));

Assert.Throws<ReachedHttpException>(() => subject.SetFields(file, new CalibreSettings { Host = "calibre", Port = 8181 }, false));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using NLog;
using NUnit.Framework;
using NzbDrone.Core.Books;
using NzbDrone.Core.Books.Calibre;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.BookImport;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.RootFolders;

namespace Chaptarr.Core.Test.MediaFiles
{
[TestFixture]
public class ImportApprovedBooksCalibreDestinationFixture
{
private static readonly MethodInfo GetSettingsMethod = typeof(ImportApprovedBooks).GetMethod(
"GetCalibreDestinationSettings",
BindingFlags.NonPublic | BindingFlags.Instance);

private class StubProxy<T> : DispatchProxy
where T : class
{
public Dictionary<string, Func<object[], object>> Handlers { get; } = new();

public static T Create(Dictionary<string, Func<object[], object>> handlers = null)
{
var proxy = DispatchProxy.Create<T, StubProxy<T>>();
var state = (StubProxy<T>)(object)proxy;
foreach (var handler in handlers ?? new Dictionary<string, Func<object[], object>>())
{
state.Handlers[handler.Key] = handler.Value;
}

return proxy;
}

protected override object Invoke(MethodInfo targetMethod, object[] args)
{
if (Handlers.TryGetValue(targetMethod.Name, out var handler))
{
return handler(args);
}

throw new NotImplementedException($"Test proxy does not implement {typeof(T).Name}.{targetMethod.Name}");
}
}

private const string CalibreRoot = "/data/calibre/library";
private const string PlainRoot = "/data/books";

[Test]
public void should_route_ebook_into_calibre_library_root_through_calibre()
{
var settings = new CalibreSettings { Host = "calibre", Port = 8181 };

var result = GetSettings(BuildFile("ebook"), CalibreRoot + "/Author/Book/Book.epub",
new RootFolder { Path = CalibreRoot, IsCalibreLibrary = true, CalibreSettings = settings });

Assert.That(result, Is.SameAs(settings));
}

[Test]
public void should_use_normal_transfer_for_plain_root_folder()
{
var result = GetSettings(BuildFile("ebook"), PlainRoot + "/Author/Book/Book.epub",
new RootFolder { Path = PlainRoot, IsCalibreLibrary = false });

Assert.That(result, Is.Null);
}

[Test]
public void should_use_normal_transfer_when_calibre_root_has_no_settings()
{
var result = GetSettings(BuildFile("ebook"), CalibreRoot + "/Author/Book/Book.epub",
new RootFolder { Path = CalibreRoot, IsCalibreLibrary = true, CalibreSettings = null });

Assert.That(result, Is.Null);
}

[Test]
public void should_never_send_audiobooks_to_calibre()
{
var result = GetSettings(BuildFile("audiobook"), CalibreRoot + "/Author/Book/Book.m4b",
new RootFolder { Path = CalibreRoot, IsCalibreLibrary = true, CalibreSettings = new CalibreSettings() });

Assert.That(result, Is.Null);
}

private static BookFile BuildFile(string mediaType)
{
return new BookFile { Path = "/downloads/Book.epub", MediaType = mediaType };
}

private static CalibreSettings GetSettings(BookFile bookFile, string destination, RootFolder rootFolder)
{
var mover = StubProxy<IMoveBookFiles>.Create(new Dictionary<string, Func<object[], object>>
{
[nameof(IMoveBookFiles.GetImportDestinationPath)] = _ => destination
});
var rootFolders = StubProxy<IRootFolderService>.Create(new Dictionary<string, Func<object[], object>>
{
[nameof(IRootFolderService.GetBestRootFolder)] = args =>
{
Assert.That(args[0], Is.EqualTo(destination));
return rootFolder;
}
});

var subject = new ImportApprovedBooks(
null, null, null, null, null, null, null, null, mover, null, null,
null, null, null, null, null, null,
LogManager.GetLogger(nameof(ImportApprovedBooksCalibreDestinationFixture)),
rootFolderService: rootFolders,
calibre: StubProxy<ICalibreProxy>.Create());

return (CalibreSettings)GetSettingsMethod.Invoke(subject, new object[] { bookFile, new LocalBook { Path = bookFile.Path } });
}
}
}
11 changes: 7 additions & 4 deletions src/NzbDrone.Core/Books/Calibre/CalibreProxy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,10 @@ public void SetFields(BookFile file, CalibreSettings settings, bool updateCover
{
var edition = file.Edition;
var book = edition.Book;
var serieslink = book.SeriesLinks.OrderBy(x => x.SeriesPosition).FirstOrDefault(x => x.Series.Value.Title.IsNotNullOrWhiteSpace());
// Provider metadata can leave series links, images, genres and ratings unset; none of
// them may abort the push, because the book is already in Calibre by the time this runs.
var seriesLinks = book.SeriesLinks ?? new List<SeriesBookLink>();
var serieslink = seriesLinks.OrderBy(x => x.SeriesPosition).FirstOrDefault(x => x.Series?.Value?.Title.IsNotNullOrWhiteSpace() == true);

var series = serieslink?.Series.Value;
double? seriesIndex = null;
Expand All @@ -225,7 +228,7 @@ public void SetFields(BookFile file, CalibreSettings settings, bool updateCover

_logger.Trace("Book: {0} Series: {1}, Position: {2}", book, series?.Title, seriesIndex);

var cover = edition.Images.FirstOrDefault(x => x.CoverType == MediaCoverTypes.Cover);
var cover = (edition.Images ?? new List<NzbDrone.Core.MediaCover.MediaCover>()).FirstOrDefault(x => x.CoverType == MediaCoverTypes.Cover);
string image = null;
if (cover != null)
{
Expand All @@ -242,7 +245,7 @@ public void SetFields(BookFile file, CalibreSettings settings, bool updateCover
}

var textInfo = CultureInfo.InvariantCulture.TextInfo;
var genres = book.Genres.Select(x => textInfo.ToTitleCase(x.Replace('-', ' '))).ToList();
var genres = (book.Genres ?? new List<string>()).Where(x => x.IsNotNullOrWhiteSpace()).Select(x => textInfo.ToTitleCase(x.Replace('-', ' '))).ToList();

var payload = new CalibreChangesPayload
{
Expand All @@ -257,7 +260,7 @@ public void SetFields(BookFile file, CalibreSettings settings, bool updateCover
Languages = edition.Language.CanonicalizeLanguage(),
Tags = genres,
Comments = edition.Overview,
Rating = (int)(edition.Ratings.Value * 2),
Rating = (int)((edition.Ratings?.Value ?? 0) * 2),
Identifiers = new Dictionary<string, string>
{
{ "isbn", edition.Isbn13 },
Expand Down
74 changes: 69 additions & 5 deletions src/NzbDrone.Core/MediaFiles/BookImport/ImportApprovedBooks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.Books;
using NzbDrone.Core.Books.Calibre;
using NzbDrone.Core.RootFolders;
using NzbDrone.Core.Books.Commands;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFormats;
Expand Down Expand Up @@ -90,6 +91,8 @@ private sealed class PendingFileCommit
private readonly IContainmentValidator _containmentValidator;
private readonly IMapCoversToLocal _coverMapper;
private readonly ICustomFormatCalculationService _customFormatCalculationService;
private readonly IRootFolderService _rootFolderService;
private readonly ICalibreProxy _calibre;
private readonly Logger _logger;

public ImportApprovedBooks(
Expand Down Expand Up @@ -117,7 +120,9 @@ public ImportApprovedBooks(
IContainmentValidator containmentValidator = null,
IMapCoversToLocal coverMapper = null,
ICustomFormatCalculationService customFormatCalculationService = null,
IConversionJobService conversionJobService = null)
IConversionJobService conversionJobService = null,
IRootFolderService rootFolderService = null,
ICalibreProxy calibre = null)
{
_mediaFileService = mediaFileService;
_metadataTagService = metadataTagService;
Expand All @@ -143,6 +148,8 @@ public ImportApprovedBooks(
_containmentValidator = containmentValidator;
_coverMapper = coverMapper;
_customFormatCalculationService = customFormatCalculationService;
_rootFolderService = rootFolderService;
_calibre = calibre;
_logger = logger;
}

Expand Down Expand Up @@ -1315,10 +1322,37 @@ private void CleanupNewBookInstanceIfEmpty(bool createdNewBookInstance, Book new
_logger.Debug("[PERFORMANCE] Old file staging took {0}ms", stageStopwatch.ElapsedMilliseconds);
}

var calibreSettings = relocateExistingFile == null
? GetCalibreDestinationSettings(bookFile, localBook)
: null;

try
{
var transferStopwatch = Stopwatch.StartNew();
if (copyOnly)
if (calibreSettings != null)
{
// A Calibre library root is owned by Calibre: add the book through the
// content server so it lands in metadata.db and Calibre picks the folder.
// AddAndConvert reads the source and sets Path to Calibre's copy.
var source = localBook.Path;
bookFile.Path = source;
bookFile = _calibre.AddAndConvert(bookFile, calibreSettings);

if (!copyOnly)
{
// Calibre now holds the book. Failing the import here would leave it
// untracked and a retry would add it to Calibre a second time.
try
{
_diskProvider.DeleteFile(source);
}
catch (Exception ex)
{
_logger.Warn(ex, "[CALIBRE-IMPORT] Added to Calibre but could not remove source file: {0}", source);
}
}
}
else if (copyOnly)
{
bookFile = _bookFileMover.CopyBookFile(bookFile, localBook);
}
Expand Down Expand Up @@ -1365,17 +1399,22 @@ private void CleanupNewBookInstanceIfEmpty(bool createdNewBookInstance, Book new
{
writeTagsForRelocatedFileAfterPersistence = true;
}
else
else if (calibreSettings == null)
{
// Calibre already wrote metadata into its own copy.
TryWriteTags(bookFile, true, "TRANSFER");
}
}

// Import extras (best-effort; do not fail the core import if extras post-processing fails)
// Import extras (best-effort; do not fail the core import if extras post-processing fails).
// Calibre manages its own folders, so release extras are not copied into them.
var extrasStopwatch = Stopwatch.StartNew();
try
{
_extraService.ImportTrack(localBook, bookFile, copyOnly);
if (calibreSettings == null)
{
_extraService.ImportTrack(localBook, bookFile, copyOnly);
}
}
catch (Exception ex)
{
Expand Down Expand Up @@ -3389,6 +3428,31 @@ private void TryWriteTags(BookFile bookFile, bool newDownload, string route)

// Transfer logic is handled by IMoveBookFiles (BookFileMovingService)

// Returns the Calibre settings when this ebook's import destination is inside a root folder
// marked as a Calibre library; null means the normal move/copy path applies.
private CalibreSettings GetCalibreDestinationSettings(BookFile bookFile, LocalBook localBook)
{
if (_rootFolderService == null || _calibre == null || !string.Equals(bookFile.MediaType, "ebook", StringComparison.OrdinalIgnoreCase))
{
return null;
}

var destination = _bookFileMover.GetImportDestinationPath(bookFile, localBook);
if (destination.IsNullOrWhiteSpace())
{
return null;
}

var rootFolder = _rootFolderService.GetBestRootFolder(destination);
if (rootFolder?.IsCalibreLibrary == true && rootFolder.CalibreSettings != null)
{
_logger.Debug("[CALIBRE-IMPORT] Destination {0} is in Calibre library root {1}; adding through Calibre", destination, rootFolder.Path);
return rootFolder.CalibreSettings;
}

return null;
}

private bool ShouldMoveFile(LocalBook localBook, Author author)
{
// Only used for new downloads. If file already resides under the configured root
Expand Down