diff --git a/src/NzbDrone.Core/MediaCover/MediaCoverProxy.cs b/src/NzbDrone.Core/MediaCover/MediaCoverProxy.cs new file mode 100644 index 000000000..a82c7224d --- /dev/null +++ b/src/NzbDrone.Core/MediaCover/MediaCoverProxy.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.IO; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; + +namespace NzbDrone.Core.MediaCover +{ + public interface IMediaCoverProxy + { + string RegisterUrl(string url); + + string GetUrl(string hash); + byte[] GetImage(string hash); + } + + public class MediaCoverProxy : IMediaCoverProxy + { + private readonly IHttpClient _httpClient; + private readonly IConfigFileProvider _configFileProvider; + private readonly ICached _cache; + + public MediaCoverProxy(IHttpClient httpClient, IConfigFileProvider configFileProvider, ICacheManager cacheManager) + { + _httpClient = httpClient; + _configFileProvider = configFileProvider; + _cache = cacheManager.GetCache(GetType()); + } + + public string RegisterUrl(string url) + { + var hash = url.SHA256Hash(); + + _cache.Set(hash, url, TimeSpan.FromHours(24)); + + _cache.ClearExpired(); + + var fileName = Path.GetFileName(url); + return _configFileProvider.UrlBase + @"/MediaCoverProxy/" + hash + "/" + fileName; + } + + public string GetUrl(string hash) + { + var result = _cache.Find(hash); + + if (result == null) + { + throw new KeyNotFoundException("Url no longer in cache"); + } + + return result; + } + + public byte[] GetImage(string hash) + { + var url = GetUrl(hash); + + var request = new HttpRequest(url); + + return _httpClient.Get(request).ResponseData; + } + } +} diff --git a/src/NzbDrone.Core/MediaCover/MediaCoverService.cs b/src/NzbDrone.Core/MediaCover/MediaCoverService.cs index 638309e42..ca12a8b9a 100644 --- a/src/NzbDrone.Core/MediaCover/MediaCoverService.cs +++ b/src/NzbDrone.Core/MediaCover/MediaCoverService.cs @@ -31,6 +31,7 @@ namespace NzbDrone.Core.MediaCover { private const string USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 10; SM-G975U Build/QP1A.190711.020)"; + private readonly IMediaCoverProxy _mediaCoverProxy; private readonly IImageResizer _resizer; private readonly IBookService _bookService; private readonly IHttpClient _httpClient; @@ -46,7 +47,8 @@ namespace NzbDrone.Core.MediaCover // So limit the number of concurrent resizing tasks private static SemaphoreSlim _semaphore = new SemaphoreSlim((int)Math.Ceiling(Environment.ProcessorCount / 2.0)); - public MediaCoverService(IImageResizer resizer, + public MediaCoverService(IMediaCoverProxy mediaCoverProxy, + IImageResizer resizer, IBookService bookService, IHttpClient httpClient, IDiskProvider diskProvider, @@ -56,6 +58,7 @@ namespace NzbDrone.Core.MediaCover IEventAggregator eventAggregator, Logger logger) { + _mediaCoverProxy = mediaCoverProxy; _resizer = resizer; _bookService = bookService; _httpClient = httpClient; @@ -82,28 +85,39 @@ namespace NzbDrone.Core.MediaCover public void ConvertToLocalUrls(int entityId, MediaCoverEntity coverEntity, IEnumerable covers) { - foreach (var mediaCover in covers) + if (entityId == 0) { - if (mediaCover.CoverType == MediaCoverTypes.Unknown) + // Author isn't in Readarr yet, map via a proxy to circument referrer issues + foreach (var mediaCover in covers) { - continue; + mediaCover.Url = _mediaCoverProxy.RegisterUrl(mediaCover.Url); } - - var filePath = GetCoverPath(entityId, coverEntity, mediaCover.CoverType, mediaCover.Extension, null); - - if (coverEntity == MediaCoverEntity.Book) + } + else + { + foreach (var mediaCover in covers) { - mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/Books/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + GetExtension(mediaCover.CoverType, mediaCover.Extension); - } - else - { - mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + GetExtension(mediaCover.CoverType, mediaCover.Extension); - } + if (mediaCover.CoverType == MediaCoverTypes.Unknown) + { + continue; + } - if (_diskProvider.FileExists(filePath)) - { - var lastWrite = _diskProvider.FileGetLastWrite(filePath); - mediaCover.Url += "?lastWrite=" + lastWrite.Ticks; + var filePath = GetCoverPath(entityId, coverEntity, mediaCover.CoverType, mediaCover.Extension, null); + + if (coverEntity == MediaCoverEntity.Book) + { + mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/Books/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + GetExtension(mediaCover.CoverType, mediaCover.Extension); + } + else + { + mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + GetExtension(mediaCover.CoverType, mediaCover.Extension); + } + + if (_diskProvider.FileExists(filePath)) + { + var lastWrite = _diskProvider.FileGetLastWrite(filePath); + mediaCover.Url += "?lastWrite=" + lastWrite.Ticks; + } } } } diff --git a/src/Readarr.Api.V1/Author/AuthorLookupController.cs b/src/Readarr.Api.V1/Author/AuthorLookupController.cs index d02669c97..c71210c6c 100644 --- a/src/Readarr.Api.V1/Author/AuthorLookupController.cs +++ b/src/Readarr.Api.V1/Author/AuthorLookupController.cs @@ -11,10 +11,12 @@ namespace Readarr.Api.V1.Author public class AuthorLookupController : Controller { private readonly ISearchForNewAuthor _searchProxy; + private readonly IMapCoversToLocal _coverMapper; - public AuthorLookupController(ISearchForNewAuthor searchProxy) + public AuthorLookupController(ISearchForNewAuthor searchProxy, IMapCoversToLocal coverMapper) { _searchProxy = searchProxy; + _coverMapper = coverMapper; } [HttpGet] @@ -24,12 +26,16 @@ namespace Readarr.Api.V1.Author return MapToResource(searchResults).ToList(); } - private static IEnumerable MapToResource(IEnumerable author) + private IEnumerable MapToResource(IEnumerable author) { foreach (var currentAuthor in author) { var resource = currentAuthor.ToResource(); + + _coverMapper.ConvertToLocalUrls(resource.Id, MediaCoverEntity.Author, resource.Images); + var poster = currentAuthor.Metadata.Value.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster); + if (poster != null) { resource.RemotePoster = poster.Url; diff --git a/src/Readarr.Api.V1/Books/BookLookupController.cs b/src/Readarr.Api.V1/Books/BookLookupController.cs index 1b8f52643..260990919 100644 --- a/src/Readarr.Api.V1/Books/BookLookupController.cs +++ b/src/Readarr.Api.V1/Books/BookLookupController.cs @@ -11,10 +11,12 @@ namespace Readarr.Api.V1.Books public class BookLookupController : Controller { private readonly ISearchForNewBook _searchProxy; + private readonly IMapCoversToLocal _coverMapper; - public BookLookupController(ISearchForNewBook searchProxy) + public BookLookupController(ISearchForNewBook searchProxy, IMapCoversToLocal coverMapper) { _searchProxy = searchProxy; + _coverMapper = coverMapper; } [HttpGet] @@ -24,12 +26,16 @@ namespace Readarr.Api.V1.Books return MapToResource(searchResults).ToList(); } - private static IEnumerable MapToResource(IEnumerable books) + private IEnumerable MapToResource(IEnumerable books) { foreach (var currentBook in books) { var resource = currentBook.ToResource(); + + _coverMapper.ConvertToLocalUrls(resource.Id, MediaCoverEntity.Book, resource.Images); + var cover = currentBook.Editions.Value.Single(x => x.Monitored).Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Cover); + if (cover != null) { resource.RemoteCover = cover.Url; diff --git a/src/Readarr.Api.V1/Search/SearchController.cs b/src/Readarr.Api.V1/Search/SearchController.cs index e09e0182a..bc8af999b 100644 --- a/src/Readarr.Api.V1/Search/SearchController.cs +++ b/src/Readarr.Api.V1/Search/SearchController.cs @@ -14,10 +14,12 @@ namespace Readarr.Api.V1.Search public class SearchController : Controller { private readonly ISearchForNewEntity _searchProxy; + private readonly IMapCoversToLocal _coverMapper; - public SearchController(ISearchForNewEntity searchProxy) + public SearchController(ISearchForNewEntity searchProxy, IMapCoversToLocal coverMapper) { _searchProxy = searchProxy; + _coverMapper = coverMapper; } [HttpGet] @@ -27,7 +29,7 @@ namespace Readarr.Api.V1.Search return MapToResource(searchResults).ToList(); } - private static IEnumerable MapToResource(IEnumerable results) + private IEnumerable MapToResource(IEnumerable results) { var id = 1; foreach (var result in results) @@ -35,28 +37,32 @@ namespace Readarr.Api.V1.Search var resource = new SearchResource(); resource.Id = id++; - if (result is NzbDrone.Core.Books.Author) + if (result is NzbDrone.Core.Books.Author author) { - var author = (NzbDrone.Core.Books.Author)result; resource.Author = author.ToResource(); resource.ForeignId = author.ForeignAuthorId; + _coverMapper.ConvertToLocalUrls(resource.Author.Id, MediaCoverEntity.Author, resource.Author.Images); + var poster = author.Metadata.Value.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster); + if (poster != null) { resource.Author.RemotePoster = poster.Url; } } - else if (result is NzbDrone.Core.Books.Book) + else if (result is NzbDrone.Core.Books.Book book) { - var book = (NzbDrone.Core.Books.Book)result; resource.Book = book.ToResource(); resource.Book.Overview = book.Editions.Value.Single(x => x.Monitored).Overview; resource.Book.Author = book.Author.Value.ToResource(); resource.Book.Editions = book.Editions.Value.ToResource(); resource.ForeignId = book.ForeignBookId; + _coverMapper.ConvertToLocalUrls(resource.Book.Id, MediaCoverEntity.Book, resource.Book.Images); + var cover = book.Editions.Value.Single(x => x.Monitored).Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Cover); + if (cover != null) { resource.Book.RemoteCover = cover.Url; diff --git a/src/Readarr.Api.V1/Search/SearchResource.cs b/src/Readarr.Api.V1/Search/SearchResource.cs index eb5642ad7..c1a311949 100644 --- a/src/Readarr.Api.V1/Search/SearchResource.cs +++ b/src/Readarr.Api.V1/Search/SearchResource.cs @@ -4,8 +4,7 @@ using Readarr.Http.REST; namespace Readarr.Api.V1.Search { - public class - SearchResource : RestResource + public class SearchResource : RestResource { public string ForeignId { get; set; } public AuthorResource Author { get; set; } diff --git a/src/Readarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs b/src/Readarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs index 4789fa340..180ed7d96 100644 --- a/src/Readarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs +++ b/src/Readarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs @@ -6,6 +6,6 @@ namespace Readarr.Http.Frontend.Mappers { string Map(string resourceUrl); bool CanHandle(string resourceUrl); - FileStreamResult GetResponse(string resourceUrl); + IActionResult GetResponse(string resourceUrl); } } diff --git a/src/Readarr.Http/Frontend/Mappers/MediaCoverMapper.cs b/src/Readarr.Http/Frontend/Mappers/MediaCoverMapper.cs index ab272f1c9..eb9450486 100644 --- a/src/Readarr.Http/Frontend/Mappers/MediaCoverMapper.cs +++ b/src/Readarr.Http/Frontend/Mappers/MediaCoverMapper.cs @@ -43,7 +43,7 @@ namespace Readarr.Http.Frontend.Mappers public override bool CanHandle(string resourceUrl) { - return resourceUrl.StartsWith("/MediaCover", StringComparison.InvariantCultureIgnoreCase); + return resourceUrl.StartsWith("/MediaCover/", StringComparison.InvariantCultureIgnoreCase); } } } diff --git a/src/Readarr.Http/Frontend/Mappers/MediaCoverProxyMapper.cs b/src/Readarr.Http/Frontend/Mappers/MediaCoverProxyMapper.cs new file mode 100644 index 000000000..9f938ce93 --- /dev/null +++ b/src/Readarr.Http/Frontend/Mappers/MediaCoverProxyMapper.cs @@ -0,0 +1,55 @@ +using System; +using System.Net; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; +using NzbDrone.Core.MediaCover; + +namespace Readarr.Http.Frontend.Mappers +{ + public class MediaCoverProxyMapper : IMapHttpRequestsToDisk + { + private readonly Regex _regex = new Regex(@"/MediaCoverProxy/(?\w+)/(?(.+)\.(jpg|png|gif))"); + + private readonly IMediaCoverProxy _mediaCoverProxy; + private readonly IContentTypeProvider _mimeTypeProvider; + + public MediaCoverProxyMapper(IMediaCoverProxy mediaCoverProxy) + { + _mediaCoverProxy = mediaCoverProxy; + _mimeTypeProvider = new FileExtensionContentTypeProvider(); + } + + public string Map(string resourceUrl) + { + return null; + } + + public bool CanHandle(string resourceUrl) + { + return resourceUrl.StartsWith("/MediaCoverProxy/", StringComparison.InvariantCultureIgnoreCase); + } + + public IActionResult GetResponse(string resourceUrl) + { + var match = _regex.Match(resourceUrl); + + if (!match.Success) + { + return new StatusCodeResult((int)HttpStatusCode.NotFound); + } + + var hash = match.Groups["hash"].Value; + var filename = match.Groups["filename"].Value; + + var imageData = _mediaCoverProxy.GetImage(hash); + + if (!_mimeTypeProvider.TryGetContentType(filename, out var contentType)) + { + contentType = "application/octet-stream"; + } + + return new FileContentResult(imageData, contentType); + } + } +} diff --git a/src/Readarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs b/src/Readarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs index a475412b6..e0f955b56 100644 --- a/src/Readarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs +++ b/src/Readarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs @@ -30,7 +30,7 @@ namespace Readarr.Http.Frontend.Mappers public abstract bool CanHandle(string resourceUrl); - public FileStreamResult GetResponse(string resourceUrl) + public IActionResult GetResponse(string resourceUrl) { var filePath = Map(resourceUrl); diff --git a/src/Readarr.Http/Frontend/StaticResourceController.cs b/src/Readarr.Http/Frontend/StaticResourceController.cs index da21b15d5..3670676de 100644 --- a/src/Readarr.Http/Frontend/StaticResourceController.cs +++ b/src/Readarr.Http/Frontend/StaticResourceController.cs @@ -57,7 +57,7 @@ namespace Readarr.Http.Frontend if (result != null) { - if (result.ContentType == "text/html") + if ((result as FileResult)?.ContentType == "text/html") { Response.Headers.DisableCache(); }