using MediaBrowser.Common.Extensions; using MediaBrowser.Common.IO; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Logging; using MediaBrowser.Model.Serialization; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Drawing; using System.Drawing.Drawing2D; using System.Drawing.Imaging; using System.IO; using System.Linq; using System.Threading.Tasks; namespace MediaBrowser.Controller.Drawing { /// /// Class ImageManager /// public class ImageManager : IDisposable { /// /// Gets the image size cache. /// /// The image size cache. private FileSystemRepository ImageSizeCache { get; set; } /// /// Gets or sets the resized image cache. /// /// The resized image cache. private FileSystemRepository ResizedImageCache { get; set; } /// /// Gets the cropped image cache. /// /// The cropped image cache. private FileSystemRepository CroppedImageCache { get; set; } /// /// Gets the cropped image cache. /// /// The cropped image cache. private FileSystemRepository EnhancedImageCache { get; set; } /// /// The cached imaged sizes /// private readonly ConcurrentDictionary> _cachedImagedSizes = new ConcurrentDictionary>(); /// /// The _logger /// private readonly ILogger _logger; /// /// The _protobuf serializer /// private readonly IProtobufSerializer _protobufSerializer; /// /// The _kernel /// private readonly Kernel _kernel; /// /// Initializes a new instance of the class. /// /// The kernel. /// The protobuf serializer. /// The logger. public ImageManager(Kernel kernel, IProtobufSerializer protobufSerializer, ILogger logger, IServerApplicationPaths appPaths) { _protobufSerializer = protobufSerializer; _logger = logger; _kernel = kernel; ImageSizeCache = new FileSystemRepository(Path.Combine(appPaths.ImageCachePath, "image-sizes")); ResizedImageCache = new FileSystemRepository(Path.Combine(appPaths.ImageCachePath, "resized-images")); CroppedImageCache = new FileSystemRepository(Path.Combine(appPaths.ImageCachePath, "cropped-images")); EnhancedImageCache = new FileSystemRepository(Path.Combine(appPaths.ImageCachePath, "enhanced-images")); } /// /// Processes an image by resizing to target dimensions /// /// The entity that owns the image /// The image type /// The image index (currently only used with backdrops) /// if set to true [crop whitespace]. /// The last date modified of the original image file /// The stream to save the new image to /// Use if a fixed width is required. Aspect ratio will be preserved. /// Use if a fixed height is required. Aspect ratio will be preserved. /// Use if a max width is required. Aspect ratio will be preserved. /// Use if a max height is required. Aspect ratio will be preserved. /// Quality level, from 0-100. Currently only applies to JPG. The default value should suffice. /// Task. /// entity public async Task ProcessImage(BaseItem entity, ImageType imageType, int imageIndex, bool cropWhitespace, DateTime dateModified, Stream toStream, int? width, int? height, int? maxWidth, int? maxHeight, int? quality) { if (entity == null) { throw new ArgumentNullException("entity"); } if (toStream == null) { throw new ArgumentNullException("toStream"); } var originalImagePath = GetImagePath(entity, imageType, imageIndex); if (cropWhitespace) { try { originalImagePath = GetCroppedImage(originalImagePath, dateModified); } catch (Exception ex) { // We have to have a catch-all here because some of the .net image methods throw a plain old Exception _logger.ErrorException("Error cropping image", ex); } } try { // Enhance if we have enhancers var ehnancedImagePath = await GetEnhancedImage(originalImagePath, dateModified, entity, imageType, imageIndex).ConfigureAwait(false); // If the path changed update dateModified if (!ehnancedImagePath.Equals(originalImagePath, StringComparison.OrdinalIgnoreCase)) { dateModified = File.GetLastWriteTimeUtc(ehnancedImagePath); originalImagePath = ehnancedImagePath; } } catch { _logger.Error("Error enhancing image"); } var originalImageSize = await GetImageSize(originalImagePath, dateModified).ConfigureAwait(false); // Determine the output size based on incoming parameters var newSize = DrawingUtils.Resize(originalImageSize, width, height, maxWidth, maxHeight); if (!quality.HasValue) { quality = 90; } var cacheFilePath = GetCacheFilePath(originalImagePath, newSize, quality.Value, dateModified); // Grab the cache file if it already exists try { using (var fileStream = new FileStream(cacheFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous)) { await fileStream.CopyToAsync(toStream).ConfigureAwait(false); } return; } catch (FileNotFoundException) { // Cache file doesn't exist. No biggie. } using (var fileStream = File.OpenRead(originalImagePath)) { using (var originalImage = Bitmap.FromStream(fileStream, true, false)) { var newWidth = Convert.ToInt32(newSize.Width); var newHeight = Convert.ToInt32(newSize.Height); // Graphics.FromImage will throw an exception if the PixelFormat is Indexed, so we need to handle that here var thumbnail = !ImageExtensions.IsPixelFormatSupportedByGraphicsObject(originalImage.PixelFormat) ? new Bitmap(originalImage, newWidth, newHeight) : new Bitmap(newWidth, newHeight, originalImage.PixelFormat); // Preserve the original resolution thumbnail.SetResolution(originalImage.HorizontalResolution, originalImage.VerticalResolution); var thumbnailGraph = Graphics.FromImage(thumbnail); thumbnailGraph.CompositingQuality = CompositingQuality.HighQuality; thumbnailGraph.SmoothingMode = SmoothingMode.HighQuality; thumbnailGraph.InterpolationMode = InterpolationMode.HighQualityBicubic; thumbnailGraph.PixelOffsetMode = PixelOffsetMode.HighQuality; thumbnailGraph.CompositingMode = CompositingMode.SourceOver; thumbnailGraph.DrawImage(originalImage, 0, 0, newWidth, newHeight); var outputFormat = originalImage.RawFormat; using (var memoryStream = new MemoryStream { }) { // Save to the memory stream thumbnail.Save(outputFormat, memoryStream, quality.Value); var bytes = memoryStream.ToArray(); var outputTask = Task.Run(async () => await toStream.WriteAsync(bytes, 0, bytes.Length)); // Save to the cache location using (var cacheFileStream = new FileStream(cacheFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous)) { // Save to the filestream await cacheFileStream.WriteAsync(bytes, 0, bytes.Length); } await outputTask.ConfigureAwait(false); } thumbnailGraph.Dispose(); thumbnail.Dispose(); } } } /// /// Gets the cache file path based on a set of parameters /// /// The path to the original image file /// The size to output the image in /// Quality level, from 0-100. Currently only applies to JPG. The default value should suffice. /// The last modified date of the image /// System.String. private string GetCacheFilePath(string originalPath, ImageSize outputSize, int quality, DateTime dateModified) { var filename = originalPath; filename += "width=" + outputSize.Width; filename += "height=" + outputSize.Height; filename += "quality=" + quality; filename += "datemodified=" + dateModified.Ticks; return ResizedImageCache.GetResourcePath(filename, Path.GetExtension(originalPath)); } /// /// Gets image dimensions /// /// The image path. /// The date modified. /// Task{ImageSize}. /// imagePath public Task GetImageSize(string imagePath, DateTime dateModified) { if (string.IsNullOrEmpty(imagePath)) { throw new ArgumentNullException("imagePath"); } var name = imagePath + "datemodified=" + dateModified.Ticks; return _cachedImagedSizes.GetOrAdd(name, keyName => GetImageSizeTask(keyName, imagePath)); } /// /// Gets cached image dimensions, or results null if non-existant /// /// Name of the key. /// The image path. /// Task{ImageSize}. private Task GetImageSizeTask(string keyName, string imagePath) { return Task.Run(() => GetImageSize(keyName, imagePath)); } /// /// Gets the size of the image. /// /// Name of the key. /// The image path. /// ImageSize. private ImageSize GetImageSize(string keyName, string imagePath) { // Now check the file system cache var fullCachePath = ImageSizeCache.GetResourcePath(keyName, ".pb"); try { var result = _protobufSerializer.DeserializeFromFile(fullCachePath); return new ImageSize { Width = result[0], Height = result[1] }; } catch (FileNotFoundException) { // Cache file doesn't exist no biggie } var size = ImageHeader.GetDimensions(imagePath, _logger); var imageSize = new ImageSize { Width = size.Width, Height = size.Height }; // Update the file system cache CacheImageSize(fullCachePath, size.Width, size.Height); return imageSize; } /// /// Caches image dimensions /// /// The cache path. /// The width. /// The height. private void CacheImageSize(string cachePath, int width, int height) { var output = new[] { width, height }; _protobufSerializer.SerializeToFile(output, cachePath); } /// /// Gets the image path. /// /// The item. /// Type of the image. /// Index of the image. /// System.String. /// item /// public string GetImagePath(BaseItem item, ImageType imageType, int imageIndex) { if (item == null) { throw new ArgumentNullException("item"); } if (imageType == ImageType.Backdrop) { if (item.BackdropImagePaths == null) { throw new InvalidOperationException(string.Format("Item {0} does not have any Backdrops.", item.Name)); } return item.BackdropImagePaths[imageIndex]; } if (imageType == ImageType.Screenshot) { if (item.ScreenshotImagePaths == null) { throw new InvalidOperationException(string.Format("Item {0} does not have any Screenshots.", item.Name)); } return item.ScreenshotImagePaths[imageIndex]; } if (imageType == ImageType.Chapter) { var video = (Video)item; if (video.Chapters == null) { throw new InvalidOperationException(string.Format("Item {0} does not have any Chapters.", item.Name)); } return video.Chapters[imageIndex].ImagePath; } return item.GetImage(imageType); } /// /// Gets the image date modified. /// /// The item. /// Type of the image. /// Index of the image. /// DateTime. /// item public DateTime GetImageDateModified(BaseItem item, ImageType imageType, int imageIndex) { if (item == null) { throw new ArgumentNullException("item"); } var imagePath = GetImagePath(item, imageType, imageIndex); return GetImageDateModified(item, imagePath); } /// /// Gets the image date modified. /// /// The item. /// The image path. /// DateTime. /// item public DateTime GetImageDateModified(BaseItem item, string imagePath) { if (item == null) { throw new ArgumentNullException("item"); } if (string.IsNullOrEmpty(imagePath)) { throw new ArgumentNullException("imagePath"); } var metaFileEntry = item.ResolveArgs.GetMetaFileByPath(imagePath); // If we didn't the metafile entry, check the Season if (!metaFileEntry.HasValue) { var episode = item as Episode; if (episode != null && episode.Season != null) { episode.Season.ResolveArgs.GetMetaFileByPath(imagePath); } } // See if we can avoid a file system lookup by looking for the file in ResolveArgs return metaFileEntry == null ? File.GetLastWriteTimeUtc(imagePath) : metaFileEntry.Value.LastWriteTimeUtc; } /// /// Crops whitespace from an image, caches the result, and returns the cached path /// /// The original image path. /// The date modified. /// System.String. private string GetCroppedImage(string originalImagePath, DateTime dateModified) { var name = originalImagePath; name += "datemodified=" + dateModified.Ticks; var croppedImagePath = CroppedImageCache.GetResourcePath(name, Path.GetExtension(originalImagePath)); if (!CroppedImageCache.ContainsFilePath(croppedImagePath)) { using (var fileStream = File.OpenRead(originalImagePath)) { using (var originalImage = (Bitmap)Bitmap.FromStream(fileStream, true, false)) { var outputFormat = originalImage.RawFormat; using (var croppedImage = originalImage.CropWhitespace()) { using (var cacheFileStream = new FileStream(croppedImagePath, FileMode.Create)) { croppedImage.Save(outputFormat, cacheFileStream, 100); } } } } } return croppedImagePath; } /// /// Runs an image through the image enhancers, caches the result, and returns the cached path /// /// The original image path. /// The date modified of the original image file. /// The item. /// Type of the image. /// Index of the image. /// System.String. /// originalImagePath public async Task GetEnhancedImage(string originalImagePath, DateTime dateModified, BaseItem item, ImageType imageType, int imageIndex) { if (string.IsNullOrEmpty(originalImagePath)) { throw new ArgumentNullException("originalImagePath"); } if (item == null) { throw new ArgumentNullException("item"); } var supportedEnhancers = _kernel.ImageEnhancers.Where(i => i.Supports(item, imageType)).ToList(); // No enhancement - don't cache if (supportedEnhancers.Count == 0) { return originalImagePath; } var cacheGuid = GetImageCacheTag(originalImagePath, dateModified, supportedEnhancers, item, imageType); // All enhanced images are saved as png to allow transparency var enhancedImagePath = EnhancedImageCache.GetResourcePath(cacheGuid + ".png"); if (!EnhancedImageCache.ContainsFilePath(enhancedImagePath)) { using (var fileStream = File.OpenRead(originalImagePath)) { using (var originalImage = Image.FromStream(fileStream, true, false)) { //Pass the image through registered enhancers using (var newImage = await ExecuteImageEnhancers(supportedEnhancers, originalImage, item, imageType, imageIndex).ConfigureAwait(false)) { //And then save it in the cache newImage.Save(enhancedImagePath, ImageFormat.Png); } } } } return enhancedImagePath; } /// /// Gets the image cache tag. /// /// The item. /// Type of the image. /// The image path. /// Guid. /// item public Guid GetImageCacheTag(BaseItem item, ImageType imageType, string imagePath) { if (item == null) { throw new ArgumentNullException("item"); } if (string.IsNullOrEmpty(imagePath)) { throw new ArgumentNullException("imagePath"); } var dateModified = GetImageDateModified(item, imagePath); var supportedEnhancers = _kernel.ImageEnhancers.Where(i => i.Supports(item, imageType)); return GetImageCacheTag(imagePath, dateModified, supportedEnhancers, item, imageType); } /// /// Gets the image cache tag. /// /// The original image path. /// The date modified of the original image file. /// The image enhancers. /// The item. /// Type of the image. /// Guid. /// item public Guid GetImageCacheTag(string originalImagePath, DateTime dateModified, IEnumerable imageEnhancers, BaseItem item, ImageType imageType) { if (item == null) { throw new ArgumentNullException("item"); } if (imageEnhancers == null) { throw new ArgumentNullException("imageEnhancers"); } if (string.IsNullOrEmpty(originalImagePath)) { throw new ArgumentNullException("originalImagePath"); } // Cache name is created with supported enhancers combined with the last config change so we pick up new config changes var cacheKeys = imageEnhancers.Select(i => i.GetType().Name + i.LastConfigurationChange(item, imageType).Ticks).ToList(); cacheKeys.Add(originalImagePath + dateModified.Ticks); return string.Join("|", cacheKeys.ToArray()).GetMD5(); } /// /// Executes the image enhancers. /// /// The image enhancers. /// The original image. /// The item. /// Type of the image. /// Index of the image. /// Task{EnhancedImage}. private async Task ExecuteImageEnhancers(IEnumerable imageEnhancers, Image originalImage, BaseItem item, ImageType imageType, int imageIndex) { var result = originalImage; // Run the enhancers sequentially in order of priority foreach (var enhancer in imageEnhancers) { var typeName = enhancer.GetType().Name; _logger.Debug("Running {0} for {1}", typeName, item.Path ?? item.Name ?? "--Unknown--"); try { result = await enhancer.EnhanceImageAsync(item, result, imageType, imageIndex).ConfigureAwait(false); } catch (Exception ex) { _logger.ErrorException("{0} failed enhancing {1}", ex, typeName, item.Name); throw; } } return result; } public void Dispose() { Dispose(true); } /// /// Releases unmanaged and - optionally - managed resources. /// /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected void Dispose(bool dispose) { if (dispose) { ImageSizeCache.Dispose(); ResizedImageCache.Dispose(); CroppedImageCache.Dispose(); EnhancedImageCache.Dispose(); } } } }