From 35ab3a28fdc8aa375fb895aba00ae2a65e670c38 Mon Sep 17 00:00:00 2001 From: Taloth Saldono Date: Thu, 22 Jan 2015 23:12:35 +0100 Subject: [PATCH] New: MediaCover api now includes several resized variants to save bandwidth for mobile apps. banner-35.jpg (height) banner-70.jpg fanart-180.jpg (height) fanart-360.jpg poster-170.jpg (width) poster-340.jpg --- src/NzbDrone.Common/Crypto/HashProvider.cs | 2 +- src/NzbDrone.Common/Disk/DiskProviderBase.cs | 7 ++- src/NzbDrone.Common/Disk/IDiskProvider.cs | 3 +- .../Extensions/StreamExtensions.cs | 3 +- .../Checks/DeleteBadMediaCovers.cs | 10 +-- .../MediaCoverTests/ImageResizerFixture.cs | 50 +++++++++++++++ .../MediaCoverServiceFixture.cs | 62 ++++++++++++++++++- .../NzbDrone.Core.Test.csproj | 6 ++ .../Housekeepers/DeleteBadMediaCovers.cs | 2 +- src/NzbDrone.Core/MediaCover/ImageResizer.cs | 39 ++++++++++++ .../MediaCover/MediaCoverService.cs | 56 +++++++++++++++-- src/NzbDrone.Core/NzbDrone.Core.csproj | 5 ++ .../Update/UpdateVerification.cs | 2 +- src/NzbDrone.Core/packages.config | 1 + src/NzbDrone.sln | 9 +++ 15 files changed, 241 insertions(+), 16 deletions(-) create mode 100644 src/NzbDrone.Core.Test/MediaCoverTests/ImageResizerFixture.cs create mode 100644 src/NzbDrone.Core/MediaCover/ImageResizer.cs diff --git a/src/NzbDrone.Common/Crypto/HashProvider.cs b/src/NzbDrone.Common/Crypto/HashProvider.cs index 2d7cf86b6..04868cb8c 100644 --- a/src/NzbDrone.Common/Crypto/HashProvider.cs +++ b/src/NzbDrone.Common/Crypto/HashProvider.cs @@ -21,7 +21,7 @@ namespace NzbDrone.Common.Crypto { using (var md5 = MD5.Create()) { - using (var stream = _diskProvider.StreamFile(path)) + using (var stream = _diskProvider.OpenReadStream(path)) { return md5.ComputeHash(stream); } diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index f2b83226a..c50801cc9 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -421,7 +421,7 @@ namespace NzbDrone.Common.Disk return driveInfo.VolumeLabel; } - public FileStream StreamFile(string path) + public FileStream OpenReadStream(string path) { if (!FileExists(path)) { @@ -431,6 +431,11 @@ namespace NzbDrone.Common.Disk return new FileStream(path, FileMode.Open, FileAccess.Read); } + public FileStream OpenWriteStream(string path) + { + return new FileStream(path, FileMode.Create); + } + public List GetDrives() { return DriveInfo.GetDrives() diff --git a/src/NzbDrone.Common/Disk/IDiskProvider.cs b/src/NzbDrone.Common/Disk/IDiskProvider.cs index 1024dfba5..445a97fed 100644 --- a/src/NzbDrone.Common/Disk/IDiskProvider.cs +++ b/src/NzbDrone.Common/Disk/IDiskProvider.cs @@ -45,7 +45,8 @@ namespace NzbDrone.Common.Disk void EmptyFolder(string path); string[] GetFixedDrives(); string GetVolumeLabel(string path); - FileStream StreamFile(string path); + FileStream OpenReadStream(string path); + FileStream OpenWriteStream(string path); List GetDrives(); List GetDirectoryInfos(string path); List GetFileInfos(string path); diff --git a/src/NzbDrone.Common/Extensions/StreamExtensions.cs b/src/NzbDrone.Common/Extensions/StreamExtensions.cs index 6283f5fc0..a67860212 100644 --- a/src/NzbDrone.Common/Extensions/StreamExtensions.cs +++ b/src/NzbDrone.Common/Extensions/StreamExtensions.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; namespace NzbDrone.Common.Extensions { diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/DeleteBadMediaCovers.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/DeleteBadMediaCovers.cs index 368101286..14624fdb1 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/DeleteBadMediaCovers.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/DeleteBadMediaCovers.cs @@ -56,7 +56,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks Subject.Clean(); - Mocker.GetMock().Verify(c => c.StreamFile(It.IsAny()), Times.Never()); + Mocker.GetMock().Verify(c => c.OpenReadStream(It.IsAny()), Times.Never()); } @@ -67,7 +67,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks Subject.Clean(); - Mocker.GetMock().Verify(c => c.StreamFile(It.IsAny()), Times.Never()); + Mocker.GetMock().Verify(c => c.OpenReadStream(It.IsAny()), Times.Never()); } @@ -107,7 +107,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks _metaData.First().Type = MetadataType.SeriesImage; Mocker.GetMock() - .Setup(c => c.StreamFile(imagePath)) + .Setup(c => c.OpenReadStream(imagePath)) .Returns(new FileStream("Files\\html_image.jpg".AsOsAgnostic(), FileMode.Open, FileAccess.Read)); @@ -129,7 +129,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks _metaData.First().RelativePath = "Season\\image.jpg".AsOsAgnostic(); Mocker.GetMock() - .Setup(c => c.StreamFile(imagePath)) + .Setup(c => c.OpenReadStream(imagePath)) .Returns(new FileStream("Files\\emptyfile.txt".AsOsAgnostic(), FileMode.Open, FileAccess.Read)); @@ -149,7 +149,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks _metaData.First().RelativePath = "Season\\image.jpg".AsOsAgnostic(); Mocker.GetMock() - .Setup(c => c.StreamFile(imagePath)) + .Setup(c => c.OpenReadStream(imagePath)) .Returns(new FileStream("Files\\Queue.txt".AsOsAgnostic(), FileMode.Open, FileAccess.Read)); diff --git a/src/NzbDrone.Core.Test/MediaCoverTests/ImageResizerFixture.cs b/src/NzbDrone.Core.Test/MediaCoverTests/ImageResizerFixture.cs new file mode 100644 index 000000000..72d736a8b --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaCoverTests/ImageResizerFixture.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Crypto; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.MediaCoverTests +{ + [TestFixture] + public class ImageResizerFixture : CoreTest + { + [SetUp] + public void SetUp() + { + Mocker.GetMock() + .Setup(v => v.OpenReadStream(It.IsAny())) + .Returns(s => new FileStream(s, FileMode.Open)); + + Mocker.GetMock() + .Setup(v => v.OpenWriteStream(It.IsAny())) + .Returns(s => new FileStream(s, FileMode.Create)); + } + + [Test] + public void should_resize_image() + { + var mainFile = Path.Combine(TempFolder, "logo.png"); + var resizedFile = Path.Combine(TempFolder, "logo-170.png"); + + File.Copy(@"Files/1024.png", mainFile); + + Subject.Resize(mainFile, resizedFile, 170); + + var fileInfo = new FileInfo(resizedFile); + fileInfo.Exists.Should().BeTrue(); + fileInfo.Length.Should().BeInRange(1000, 30000); + + var image = System.Drawing.Image.FromFile(resizedFile); + image.Height.Should().Be(170); + image.Width.Should().Be(170); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/MediaCoverTests/MediaCoverServiceFixture.cs b/src/NzbDrone.Core.Test/MediaCoverTests/MediaCoverServiceFixture.cs index b59520dec..6e4a9c1d4 100644 --- a/src/NzbDrone.Core.Test/MediaCoverTests/MediaCoverServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaCoverTests/MediaCoverServiceFixture.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; using FluentAssertions; using Moq; using NUnit.Framework; @@ -7,17 +9,25 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.MediaCover; using NzbDrone.Core.Test.Framework; -using System.Linq; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Tv.Events; namespace NzbDrone.Core.Test.MediaCoverTests { [TestFixture] public class MediaCoverServiceFixture : CoreTest { + Series _series; + [SetUp] public void Setup() { Mocker.SetConstant(new AppFolderInfo(Mocker.Resolve())); + + _series = Builder.CreateNew() + .With(v => v.Id = 2) + .With(v => v.Images = new List { new MediaCover.MediaCover(MediaCoverTypes.Poster, "") }) + .Build(); } [Test] @@ -55,5 +65,55 @@ namespace NzbDrone.Core.Test.MediaCoverTests covers.Single().Url.Should().Be("/MediaCover/12/banner.jpg"); } + [Test] + public void should_resize_covers_if_main_downloaded() + { + Mocker.GetMock() + .Setup(v => v.AlreadyExists(It.IsAny(), It.IsAny())) + .Returns(false); + + Mocker.GetMock() + .Setup(v => v.FileExists(It.IsAny())) + .Returns(true); + + Subject.HandleAsync(new SeriesUpdatedEvent(_series)); + + Mocker.GetMock() + .Verify(v => v.Resize(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public void should_resize_covers_if_missing() + { + Mocker.GetMock() + .Setup(v => v.AlreadyExists(It.IsAny(), It.IsAny())) + .Returns(true); + + Mocker.GetMock() + .Setup(v => v.FileExists(It.IsAny())) + .Returns(false); + + Subject.HandleAsync(new SeriesUpdatedEvent(_series)); + + Mocker.GetMock() + .Verify(v => v.Resize(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public void should_not_resize_covers_if_exists() + { + Mocker.GetMock() + .Setup(v => v.AlreadyExists(It.IsAny(), It.IsAny())) + .Returns(true); + + Mocker.GetMock() + .Setup(v => v.FileExists(It.IsAny())) + .Returns(true); + + Subject.HandleAsync(new SeriesUpdatedEvent(_series)); + + Mocker.GetMock() + .Verify(v => v.Resize(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 84f43fbcf..25b96464a 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -74,6 +74,7 @@ + @@ -215,6 +216,7 @@ + @@ -348,6 +350,10 @@ + + Files\1024.png + Always + sqlite3.dll Always diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/DeleteBadMediaCovers.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/DeleteBadMediaCovers.cs index beac8cca2..4192504f8 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/DeleteBadMediaCovers.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/DeleteBadMediaCovers.cs @@ -69,7 +69,7 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers { var buffer = new byte[10]; - using (var imageStream = _diskProvider.StreamFile(path)) + using (var imageStream = _diskProvider.OpenReadStream(path)) { if (imageStream.Length < buffer.Length) return false; imageStream.Read(buffer, 0, buffer.Length); diff --git a/src/NzbDrone.Core/MediaCover/ImageResizer.cs b/src/NzbDrone.Core/MediaCover/ImageResizer.cs new file mode 100644 index 000000000..be97a3e2d --- /dev/null +++ b/src/NzbDrone.Core/MediaCover/ImageResizer.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ImageResizer; +using NzbDrone.Common.Disk; + +namespace NzbDrone.Core.MediaCover +{ + public interface IImageResizer + { + void Resize(string source, string destination, int height); + } + + public class ImageResizer : IImageResizer + { + private readonly IDiskProvider _diskProvider; + + public ImageResizer(IDiskProvider diskProvider) + { + _diskProvider = diskProvider; + } + + public void Resize(string source, string destination, int height) + { + using (var sourceStream = _diskProvider.OpenReadStream(source)) + { + using (var outputStream = _diskProvider.OpenWriteStream(destination)) + { + var settings = new Instructions(); + settings.Height = height; + + var job = new ImageJob(sourceStream, outputStream, settings); + + ImageBuilder.Current.Build(job); + } + } + } + } +} diff --git a/src/NzbDrone.Core/MediaCover/MediaCoverService.cs b/src/NzbDrone.Core/MediaCover/MediaCoverService.cs index 45bc5945f..439494454 100644 --- a/src/NzbDrone.Core/MediaCover/MediaCoverService.cs +++ b/src/NzbDrone.Core/MediaCover/MediaCoverService.cs @@ -17,7 +17,7 @@ namespace NzbDrone.Core.MediaCover public interface IMapCoversToLocal { void ConvertToLocalUrls(int seriesId, IEnumerable covers); - string GetCoverPath(int seriesId, MediaCoverTypes mediaCoverTypes); + string GetCoverPath(int seriesId, MediaCoverTypes mediaCoverTypes, int? height = null); } public class MediaCoverService : @@ -25,6 +25,7 @@ namespace NzbDrone.Core.MediaCover IHandleAsync, IMapCoversToLocal { + private readonly IImageResizer _resizer; private readonly IHttpClient _httpClient; private readonly IDiskProvider _diskProvider; private readonly ICoverExistsSpecification _coverExistsSpecification; @@ -34,7 +35,8 @@ namespace NzbDrone.Core.MediaCover private readonly string _coverRootFolder; - public MediaCoverService(IHttpClient httpClient, + public MediaCoverService(IImageResizer resizer, + IHttpClient httpClient, IDiskProvider diskProvider, IAppFolderInfo appFolderInfo, ICoverExistsSpecification coverExistsSpecification, @@ -42,6 +44,7 @@ namespace NzbDrone.Core.MediaCover IEventAggregator eventAggregator, Logger logger) { + _resizer = resizer; _httpClient = httpClient; _diskProvider = diskProvider; _coverExistsSpecification = coverExistsSpecification; @@ -52,9 +55,11 @@ namespace NzbDrone.Core.MediaCover _coverRootFolder = appFolderInfo.GetMediaCoverPath(); } - public string GetCoverPath(int seriesId, MediaCoverTypes coverTypes) + public string GetCoverPath(int seriesId, MediaCoverTypes coverTypes, int? height = null) { - return Path.Combine(GetSeriesCoverPath(seriesId), coverTypes.ToString().ToLower() + ".jpg"); + var heightSuffix = height.HasValue ? "-" + height.ToString() : ""; + + return Path.Combine(GetSeriesCoverPath(seriesId), coverTypes.ToString().ToLower() + heightSuffix + ".jpg"); } public void ConvertToLocalUrls(int seriesId, IEnumerable covers) @@ -88,6 +93,11 @@ namespace NzbDrone.Core.MediaCover if (!_coverExistsSpecification.AlreadyExists(cover.Url, fileName)) { DownloadCover(series, cover); + EnsureResizedCovers(series, cover, true); + } + else + { + EnsureResizedCovers(series, cover, false); } } catch (WebException e) @@ -109,6 +119,44 @@ namespace NzbDrone.Core.MediaCover _httpClient.DownloadFile(cover.Url, fileName); } + private void EnsureResizedCovers(Series series, MediaCover cover, bool forceResize) + { + int[] heights; + + switch (cover.CoverType) + { + default: + return; + + case MediaCoverTypes.Poster: + case MediaCoverTypes.Headshot: + heights = new[] { 500, 250 }; + break; + + case MediaCoverTypes.Banner: + heights = new[] { 70, 35 }; + break; + + case MediaCoverTypes.Fanart: + case MediaCoverTypes.Screenshot: + heights = new[] { 360, 180 }; + break; + } + + foreach (var height in heights) + { + var mainFileName = GetCoverPath(series.Id, cover.CoverType); + var resizeFileName = GetCoverPath(series.Id, cover.CoverType, height); + + if (forceResize || !_diskProvider.FileExists(resizeFileName)) + { + _logger.Debug("Resizing {0}-{1} for {2}", cover.CoverType, height, series); + + _resizer.Resize(mainFileName, resizeFileName, height); + } + } + } + public void HandleAsync(SeriesUpdatedEvent message) { EnsureCovers(message.Series); diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 67d2e3cda..5e7455b53 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -72,6 +72,10 @@ False ..\Libraries\Growl.CoreLibrary.dll + + False + ..\packages\ImageResizer.3.4.3\lib\ImageResizer.dll + False ..\packages\Newtonsoft.Json.6.0.6\lib\net40\Newtonsoft.Json.dll @@ -522,6 +526,7 @@ + diff --git a/src/NzbDrone.Core/Update/UpdateVerification.cs b/src/NzbDrone.Core/Update/UpdateVerification.cs index bafbb4e5d..0f2131928 100644 --- a/src/NzbDrone.Core/Update/UpdateVerification.cs +++ b/src/NzbDrone.Core/Update/UpdateVerification.cs @@ -19,7 +19,7 @@ namespace NzbDrone.Core.Update public Boolean Verify(UpdatePackage updatePackage, String packagePath) { - using (var fileStream = _diskProvider.StreamFile(packagePath)) + using (var fileStream = _diskProvider.OpenReadStream(packagePath)) { var hash = fileStream.SHA256Hash(); diff --git a/src/NzbDrone.Core/packages.config b/src/NzbDrone.Core/packages.config index 2c2ad304f..b7a2989ee 100644 --- a/src/NzbDrone.Core/packages.config +++ b/src/NzbDrone.Core/packages.config @@ -3,6 +3,7 @@ + diff --git a/src/NzbDrone.sln b/src/NzbDrone.sln index a7642982d..a33c70359 100644 --- a/src/NzbDrone.sln +++ b/src/NzbDrone.sln @@ -34,10 +34,16 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceUninstall", "ServiceHelpers\ServiceUninstall\ServiceUninstall.csproj", "{700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Core", "NzbDrone.Core\NzbDrone.Core.csproj", "{FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}" + ProjectSection(ProjectDependencies) = postProject + {0CC493D7-0A9F-4199-9615-0A977945D716} = {0CC493D7-0A9F-4199-9615-0A977945D716} + EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Update", "NzbDrone.Update\NzbDrone.Update.csproj", "{4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Common", "NzbDrone.Common\NzbDrone.Common.csproj", "{F2BE0FDF-6E47-4827-A420-DD4EF82407F8}" + ProjectSection(ProjectDependencies) = postProject + {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB} = {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB} + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".nuget", ".nuget", "{1E6B3CBE-1578-41C1-9BF9-78D818740BE9}" ProjectSection(SolutionItems) = preProject @@ -81,6 +87,9 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LogentriesCore", "LogentriesCore\LogentriesCore.csproj", "{90D6E9FC-7B88-4E1B-B018-8FA742274558}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LogentriesNLog", "LogentriesNLog\LogentriesNLog.csproj", "{9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}" + ProjectSection(ProjectDependencies) = postProject + {90D6E9FC-7B88-4E1B-B018-8FA742274558} = {90D6E9FC-7B88-4E1B-B018-8FA742274558} + EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TVDBSharp", "TVDBSharp\TVDBSharp.csproj", "{0CC493D7-0A9F-4199-9615-0A977945D716}" EndProject