#nullable disable #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Net; using Microsoft.Extensions.Logging; using Episode = MediaBrowser.Controller.Entities.TV.Episode; using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum; using Person = MediaBrowser.Controller.Entities.Person; using Season = MediaBrowser.Controller.Entities.TV.Season; namespace MediaBrowser.Providers.Manager { /// /// Class ImageSaver. /// public class ImageSaver { /// /// The _config. /// private readonly IServerConfigurationManager _config; /// /// The _directory watchers. /// private readonly ILibraryMonitor _libraryMonitor; private readonly IFileSystem _fileSystem; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The config. /// The directory watchers. /// The file system. /// The logger. public ImageSaver(IServerConfigurationManager config, ILibraryMonitor libraryMonitor, IFileSystem fileSystem, ILogger logger) { _config = config; _libraryMonitor = libraryMonitor; _fileSystem = fileSystem; _logger = logger; } private bool EnableExtraThumbsDuplication { get { var config = _config.GetConfiguration("xbmcmetadata"); return config.EnableExtraThumbsDuplication; } } /// /// Saves the image. /// /// The item. /// The source. /// Type of the MIME. /// The type. /// Index of the image. /// The cancellation token. /// Task. /// mimeType. public Task SaveImage(BaseItem item, Stream source, string mimeType, ImageType type, int? imageIndex, CancellationToken cancellationToken) { return SaveImage(item, source, mimeType, type, imageIndex, null, cancellationToken); } public async Task SaveImage(BaseItem item, Stream source, string mimeType, ImageType type, int? imageIndex, bool? saveLocallyWithMedia, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(mimeType); var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && item is not Audio; if (type != ImageType.Primary && item is Episode) { saveLocally = false; } if (!item.IsFileProtocol) { saveLocally = false; // If season is virtual under a physical series, save locally if (item is Season season) { var series = season.Series; if (series is not null && series.SupportsLocalMetadata && series.IsSaveLocalMetadataEnabled()) { saveLocally = true; } } } if (saveLocallyWithMedia.HasValue && !saveLocallyWithMedia.Value) { saveLocally = saveLocallyWithMedia.Value; } if (!imageIndex.HasValue && item.AllowsMultipleImages(type)) { imageIndex = item.GetImages(type).Count(); } var index = imageIndex ?? 0; var paths = GetSavePaths(item, type, imageIndex, mimeType, saveLocally); string[] retryPaths = []; if (saveLocally) { retryPaths = GetSavePaths(item, type, imageIndex, mimeType, false); } // If there are more than one output paths, the stream will need to be seekable if (paths.Length > 1 && !source.CanSeek) { var memoryStream = new MemoryStream(); await using (source.ConfigureAwait(false)) { await source.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); } source = memoryStream; } var currentImage = GetCurrentImage(item, type, index); var currentImageIsLocalFile = currentImage is not null && currentImage.IsLocalFile; var currentImagePath = currentImage?.Path; var savedPaths = new List(); await using (source.ConfigureAwait(false)) { for (int i = 0; i < paths.Length; i++) { if (i != 0) { source.Position = 0; } string retryPath = null; if (paths.Length == retryPaths.Length) { retryPath = retryPaths[i]; } var savedPath = await SaveImageToLocation(source, paths[i], retryPath, cancellationToken).ConfigureAwait(false); savedPaths.Add(savedPath); } } // Set the path into the item SetImagePath(item, type, imageIndex, savedPaths[0]); // Delete the current path if (currentImageIsLocalFile && !savedPaths.Contains(currentImagePath, StringComparison.OrdinalIgnoreCase) && (saveLocally || currentImagePath.Contains(_config.ApplicationPaths.InternalMetadataPath, StringComparison.OrdinalIgnoreCase))) { var currentPath = currentImagePath; _logger.LogInformation("Deleting previous image {0}", currentPath); _libraryMonitor.ReportFileSystemChangeBeginning(currentPath); try { _fileSystem.DeleteFile(currentPath); // Remove containing directory if empty var folder = Path.GetDirectoryName(currentPath); if (!_fileSystem.GetFiles(folder).Any()) { Directory.Delete(folder); } } catch (FileNotFoundException) { } finally { _libraryMonitor.ReportFileSystemChangeComplete(currentPath, false); } } } public async Task SaveImage(Stream source, string path) { await SaveImageToLocation(source, path, path, CancellationToken.None).ConfigureAwait(false); } private async Task SaveImageToLocation(Stream source, string path, string retryPath, CancellationToken cancellationToken) { try { await SaveImageToLocation(source, path, cancellationToken).ConfigureAwait(false); return path; } catch (UnauthorizedAccessException) { var retry = !string.IsNullOrWhiteSpace(retryPath) && !string.Equals(path, retryPath, StringComparison.OrdinalIgnoreCase); if (retry) { _logger.LogError("UnauthorizedAccessException - Access to path {0} is denied. Will retry saving to {1}", path, retryPath); } else { throw; } } catch (IOException ex) { var retry = !string.IsNullOrWhiteSpace(retryPath) && !string.Equals(path, retryPath, StringComparison.OrdinalIgnoreCase); if (retry) { _logger.LogError(ex, "IOException saving to {0}. Will retry saving to {1}", path, retryPath); } else { throw; } } await SaveImageToLocation(source, retryPath, cancellationToken).ConfigureAwait(false); return retryPath; } /// /// Saves the image to location. /// /// The source. /// The path. /// The cancellation token. /// Task. private async Task SaveImageToLocation(Stream source, string path, CancellationToken cancellationToken) { _logger.LogDebug("Saving image to {0}", path); var parentFolder = Path.GetDirectoryName(path); try { _libraryMonitor.ReportFileSystemChangeBeginning(path); _libraryMonitor.ReportFileSystemChangeBeginning(parentFolder); Directory.CreateDirectory(Path.GetDirectoryName(path)); _fileSystem.SetAttributes(path, false, false); var fileStreamOptions = AsyncFile.WriteOptions; fileStreamOptions.Mode = FileMode.Create; if (source.CanSeek) { fileStreamOptions.PreallocationSize = source.Length; } var fs = new FileStream(path, fileStreamOptions); await using (fs.ConfigureAwait(false)) { await source.CopyToAsync(fs, cancellationToken).ConfigureAwait(false); } if (_config.Configuration.SaveMetadataHidden) { SetHidden(path, true); } } finally { _libraryMonitor.ReportFileSystemChangeComplete(path, false); _libraryMonitor.ReportFileSystemChangeComplete(parentFolder, false); } } private void SetHidden(string path, bool hidden) { try { _fileSystem.SetHidden(path, hidden); } catch (Exception ex) { _logger.LogError(ex, "Error setting hidden attribute on {0}", path); } } /// /// Gets the save paths. /// /// The item. /// The type. /// Index of the image. /// Type of the MIME. /// if set to true [save locally]. /// IEnumerable{System.String}. private string[] GetSavePaths(BaseItem item, ImageType type, int? imageIndex, string mimeType, bool saveLocally) { if (!saveLocally || (_config.Configuration.ImageSavingConvention == ImageSavingConvention.Legacy)) { return new[] { GetStandardSavePath(item, type, imageIndex, mimeType, saveLocally) }; } return GetCompatibleSavePaths(item, type, imageIndex, mimeType); } /// /// Gets the current image path. /// /// The item. /// The type. /// Index of the image. /// System.String. /// /// imageIndex /// or /// imageIndex. /// private ItemImageInfo GetCurrentImage(BaseItem item, ImageType type, int imageIndex) { return item.GetImageInfo(type, imageIndex); } /// /// Sets the image path. /// /// The item. /// The type. /// Index of the image. /// The path. /// imageIndex /// or /// imageIndex. /// private void SetImagePath(BaseItem item, ImageType type, int? imageIndex, string path) { item.SetImagePath(type, imageIndex ?? 0, _fileSystem.GetFileInfo(path)); } /// /// Gets the save path. /// /// The item. /// The type. /// Index of the image. /// Type of the MIME. /// if set to true [save locally]. /// System.String. /// /// imageIndex /// or /// imageIndex. /// private string GetStandardSavePath(BaseItem item, ImageType type, int? imageIndex, string mimeType, bool saveLocally) { var season = item as Season; var extension = MimeTypes.ToExtension(mimeType); if (string.IsNullOrWhiteSpace(extension)) { throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Unable to determine image file extension from mime type {0}", mimeType)); } if (string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase)) { extension = ".jpg"; } extension = extension.ToLowerInvariant(); if (type == ImageType.Primary && saveLocally) { if (season is not null && season.IndexNumber.HasValue) { var seriesFolder = season.SeriesPath; var seasonMarker = season.IndexNumber.Value == 0 ? "-specials" : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture); var imageFilename = "season" + seasonMarker + "-poster" + extension; return Path.Combine(seriesFolder, imageFilename); } } if (type == ImageType.Backdrop && saveLocally) { if (season is not null && season.IndexNumber.HasValue && (imageIndex is null || imageIndex == 0)) { var seriesFolder = season.SeriesPath; var seasonMarker = season.IndexNumber.Value == 0 ? "-specials" : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture); var imageFilename = "season" + seasonMarker + "-fanart" + extension; return Path.Combine(seriesFolder, imageFilename); } } if (type == ImageType.Thumb && saveLocally) { if (season is not null && season.IndexNumber.HasValue) { var seriesFolder = season.SeriesPath; var seasonMarker = season.IndexNumber.Value == 0 ? "-specials" : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture); var imageFilename = "season" + seasonMarker + "-landscape" + extension; return Path.Combine(seriesFolder, imageFilename); } if (item.IsInMixedFolder) { return GetSavePathForItemInMixedFolder(item, type, "landscape", extension); } return Path.Combine(item.ContainingFolderPath, "landscape" + extension); } if (type == ImageType.Banner && saveLocally) { if (season is not null && season.IndexNumber.HasValue) { var seriesFolder = season.SeriesPath; var seasonMarker = season.IndexNumber.Value == 0 ? "-specials" : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture); var imageFilename = "season" + seasonMarker + "-banner" + extension; return Path.Combine(seriesFolder, imageFilename); } } string filename; var folderName = item is MusicAlbum || item is MusicArtist || item is PhotoAlbum || item is Person || (saveLocally && _config.Configuration.ImageSavingConvention == ImageSavingConvention.Legacy) ? "folder" : "poster"; switch (type) { case ImageType.Art: filename = "clearart"; break; case ImageType.BoxRear: filename = "back"; break; case ImageType.Thumb: filename = "landscape"; break; case ImageType.Disc: filename = item is MusicAlbum ? "cdart" : "disc"; break; case ImageType.Primary: filename = saveLocally && item is Episode ? Path.GetFileNameWithoutExtension(item.Path) : folderName; break; case ImageType.Backdrop: filename = GetBackdropSaveFilename(item.GetImages(type), "backdrop", "backdrop", imageIndex); break; default: filename = type.ToString().ToLowerInvariant(); break; } string path = null; if (saveLocally) { if (type == ImageType.Primary && item is Episode) { path = Path.Combine(Path.GetDirectoryName(item.Path), filename + "-thumb" + extension); } else if (item.IsInMixedFolder) { path = GetSavePathForItemInMixedFolder(item, type, filename, extension); } if (string.IsNullOrEmpty(path)) { path = Path.Combine(item.ContainingFolderPath, filename + extension); } } // None of the save local conditions passed, so store it in our internal folders if (string.IsNullOrEmpty(path)) { if (string.IsNullOrEmpty(filename)) { filename = folderName; } path = Path.Combine(item.GetInternalMetadataPath(), filename + extension); } return path; } private string GetBackdropSaveFilename(IEnumerable images, string zeroIndexFilename, string numberedIndexPrefix, int? index) { if (index.HasValue && index.Value == 0) { return zeroIndexFilename; } var filenames = images.Select(i => Path.GetFileNameWithoutExtension(i.Path)).ToList(); var current = 1; while (filenames.Contains(numberedIndexPrefix + current.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase)) { current++; } return numberedIndexPrefix + current.ToString(CultureInfo.InvariantCulture); } /// /// Gets the compatible save paths. /// /// The item. /// The type. /// Index of the image. /// Type of the MIME. /// IEnumerable{System.String}. /// imageIndex. private string[] GetCompatibleSavePaths(BaseItem item, ImageType type, int? imageIndex, string mimeType) { var season = item as Season; var extension = MimeTypes.ToExtension(mimeType); // Backdrop paths if (type == ImageType.Backdrop) { if (!imageIndex.HasValue) { throw new ArgumentNullException(nameof(imageIndex)); } if (imageIndex.Value == 0) { if (item.IsInMixedFolder) { return new[] { GetSavePathForItemInMixedFolder(item, type, "fanart", extension) }; } if (season is not null && season.IndexNumber.HasValue) { var seriesFolder = season.SeriesPath; var seasonMarker = season.IndexNumber.Value == 0 ? "-specials" : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture); var imageFilename = "season" + seasonMarker + "-fanart" + extension; return new[] { Path.Combine(seriesFolder, imageFilename) }; } return new[] { Path.Combine(item.ContainingFolderPath, "fanart" + extension) }; } var outputIndex = imageIndex.Value; if (item.IsInMixedFolder) { return new[] { GetSavePathForItemInMixedFolder(item, type, "fanart" + outputIndex.ToString(CultureInfo.InvariantCulture), extension) }; } var extraFanartFilename = GetBackdropSaveFilename(item.GetImages(ImageType.Backdrop), "fanart", "fanart", outputIndex); var list = new List { Path.Combine(item.ContainingFolderPath, "extrafanart", extraFanartFilename + extension) }; if (EnableExtraThumbsDuplication) { list.Add(Path.Combine(item.ContainingFolderPath, "extrathumbs", "thumb" + outputIndex.ToString(CultureInfo.InvariantCulture) + extension)); } return list.ToArray(); } if (type == ImageType.Primary) { if (season is not null && season.IndexNumber.HasValue) { var seriesFolder = season.SeriesPath; var seasonMarker = season.IndexNumber.Value == 0 ? "-specials" : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture); var imageFilename = "season" + seasonMarker + "-poster" + extension; return new[] { Path.Combine(seriesFolder, imageFilename) }; } if (item is Episode) { var seasonFolder = Path.GetDirectoryName(item.Path); var imageFilename = Path.GetFileNameWithoutExtension(item.Path) + "-thumb" + extension; return new[] { Path.Combine(seasonFolder, imageFilename) }; } if (item.IsInMixedFolder || item is MusicVideo) { return new[] { GetSavePathForItemInMixedFolder(item, type, string.Empty, extension) }; } if (item is MusicAlbum || item is MusicArtist) { return new[] { Path.Combine(item.ContainingFolderPath, "folder" + extension) }; } return new[] { Path.Combine(item.ContainingFolderPath, "poster" + extension) }; } // All other paths are the same return new[] { GetStandardSavePath(item, type, imageIndex, mimeType, true) }; } /// /// Gets the save path for item in mixed folder. /// /// The item. /// The type. /// The image filename. /// The extension. /// System.String. private string GetSavePathForItemInMixedFolder(BaseItem item, ImageType type, string imageFilename, string extension) { if (type == ImageType.Primary) { imageFilename = "poster"; } var folder = Path.GetDirectoryName(item.Path); return Path.Combine(folder, Path.GetFileNameWithoutExtension(item.Path) + "-" + imageFilename + extension); } } }