diff options
Diffstat (limited to 'MediaBrowser.Providers/Books/ComicImageProvider.cs')
| -rw-r--r-- | MediaBrowser.Providers/Books/ComicImageProvider.cs | 158 |
1 files changed, 158 insertions, 0 deletions
diff --git a/MediaBrowser.Providers/Books/ComicImageProvider.cs b/MediaBrowser.Providers/Books/ComicImageProvider.cs new file mode 100644 index 0000000000..34936cff13 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicImageProvider.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using SharpCompress.Archives; + +namespace MediaBrowser.Providers.Books; + +/// <summary> +/// The ComicImageProvider tries to find either an image named "cover" or, in case that +/// fails, just takes the first image inside the archive, hoping that it is the cover. +/// </summary> +public class ComicImageProvider : IDynamicImageProvider +{ + private readonly string[] _comicBookExtensions = [".cb7", ".cbr", ".cbt", ".cbz"]; + private readonly string[] _coverExtensions = [".png", ".jpeg", ".jpg", ".webp", ".bmp", ".gif"]; + + private readonly ILogger<ComicImageProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="ComicImageProvider"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{ComicImageProvider}"/> interface.</param> + public ComicImageProvider(ILogger<ComicImageProvider> logger) + { + _logger = logger; + } + + /// <inheritdoc /> + public string Name => "Comic Book Archive Cover Extractor"; + + /// <inheritdoc /> + public async Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken) + { + var extension = Path.GetExtension(item.Path); + + if (_comicBookExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + return await LoadCoverAsync(item, cancellationToken).ConfigureAwait(false); + } + + return new DynamicImageResponse { HasImage = false }; + } + + /// <inheritdoc /> + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + { + yield return ImageType.Primary; + } + + /// <inheritdoc /> + public bool Supports(BaseItem item) + { + return item is Book; + } + + /// <summary> + /// Tries to load a cover from the CBZ archive. Returns a response + /// with no image if nothing is found. + /// </summary> + /// <param name="item">Item to check for covers.</param> + /// <param name="cancellationToken">The cancellation token.</param> + private async Task<DynamicImageResponse> LoadCoverAsync(BaseItem item, CancellationToken cancellationToken) + { + var memoryStream = new MemoryStream(); + + try + { + ImageFormat imageFormat; + + using (Stream stream = AsyncFile.OpenRead(item.Path)) + { + var archive = await ArchiveFactory.OpenAsyncArchive(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + await using (archive.ConfigureAwait(false)) + { + // throw exception to log results if no cover is found + (var cover, imageFormat) = await FindCoverEntryInArchiveAsync(archive).ConfigureAwait(false) + ?? throw new InvalidOperationException("no supported cover found"); + + // copy the cover to memory stream + var coverStream = await cover.OpenEntryStreamAsync(cancellationToken).ConfigureAwait(false); + await using (coverStream.ConfigureAwait(false)) + { + await coverStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); + } + } + } + + // reset stream position after copying + memoryStream.Position = 0; + + return new DynamicImageResponse { HasImage = true, Stream = memoryStream, Format = imageFormat }; + } + catch (Exception e) + { + _logger.LogError(e, "failed to load cover from {Path}", item.Path); + return new DynamicImageResponse { HasImage = false }; + } + } + + /// <summary> + /// Tries to find the entry containing the cover. + /// </summary> + /// <param name="archive">The archive to search.</param> + /// <returns>The search result.</returns> + private async ValueTask<(IArchiveEntry CoverEntry, ImageFormat ImageFormat)?> FindCoverEntryInArchiveAsync(IAsyncArchive archive) + { + IArchiveEntry? cover; + + // only some comics will explicitly name their cover file + // in many cases the cover will simply be the first image in the archive + foreach (var extension in _coverExtensions) + { + cover = await archive.EntriesAsync.FirstOrDefaultAsync(e => e.Key == "cover" + extension).ConfigureAwait(false); + + if (cover is not null) + { + var imageFormat = GetImageFormat(extension); + + return (cover, imageFormat); + } + } + + cover = await archive.EntriesAsync.OrderBy(x => x.Key) + .FirstOrDefaultAsync(x => _coverExtensions.Contains(Path.GetExtension(x.Key), StringComparison.OrdinalIgnoreCase)) + .ConfigureAwait(false); + + if (cover is not null) + { + var imageFormat = GetImageFormat(Path.GetExtension(cover.Key ?? string.Empty)); + + return (cover, imageFormat); + } + + return null; + } + + private static ImageFormat GetImageFormat(string extension) => extension.ToLowerInvariant() switch + { + ".jpg" => ImageFormat.Jpg, + ".jpeg" => ImageFormat.Jpg, + ".png" => ImageFormat.Png, + ".webp" => ImageFormat.Webp, + ".bmp" => ImageFormat.Bmp, + ".gif" => ImageFormat.Gif, + ".svg" => ImageFormat.Svg, + _ => throw new ArgumentException($"unsupported extension: {extension}"), + }; +} |
