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; /// /// 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. /// 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 _logger; /// /// Initializes a new instance of the class. /// /// Instance of the interface. public ComicImageProvider(ILogger logger) { _logger = logger; } /// public string Name => "Comic Book Archive Cover Extractor"; /// public async Task 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 }; } /// public IEnumerable GetSupportedImages(BaseItem item) { yield return ImageType.Primary; } /// public bool Supports(BaseItem item) { return item is Book; } /// /// Tries to load a cover from the CBZ archive. Returns a response /// with no image if nothing is found. /// /// Item to check for covers. /// The cancellation token. private async Task 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 }; } } /// /// Tries to find the entry containing the cover. /// /// The archive to search. /// The search result. 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}"), }; }