| | | 1 | | using System; |
| | | 2 | | using System.Collections.Generic; |
| | | 3 | | using System.IO; |
| | | 4 | | using System.Linq; |
| | | 5 | | using System.Threading; |
| | | 6 | | using System.Threading.Tasks; |
| | | 7 | | using Jellyfin.Extensions; |
| | | 8 | | using MediaBrowser.Controller.Entities; |
| | | 9 | | using MediaBrowser.Controller.Providers; |
| | | 10 | | using MediaBrowser.Model.Drawing; |
| | | 11 | | using MediaBrowser.Model.Entities; |
| | | 12 | | using MediaBrowser.Model.IO; |
| | | 13 | | using Microsoft.Extensions.Logging; |
| | | 14 | | using SharpCompress.Archives; |
| | | 15 | | |
| | | 16 | | namespace MediaBrowser.Providers.Books; |
| | | 17 | | |
| | | 18 | | /// <summary> |
| | | 19 | | /// The ComicImageProvider tries to find either an image named "cover" or, in case that |
| | | 20 | | /// fails, just takes the first image inside the archive, hoping that it is the cover. |
| | | 21 | | /// </summary> |
| | | 22 | | public class ComicImageProvider : IDynamicImageProvider |
| | | 23 | | { |
| | 22 | 24 | | private readonly string[] _comicBookExtensions = [".cb7", ".cbr", ".cbt", ".cbz"]; |
| | 22 | 25 | | private readonly string[] _coverExtensions = [".png", ".jpeg", ".jpg", ".webp", ".bmp", ".gif"]; |
| | | 26 | | |
| | | 27 | | private readonly ILogger<ComicImageProvider> _logger; |
| | | 28 | | |
| | | 29 | | /// <summary> |
| | | 30 | | /// Initializes a new instance of the <see cref="ComicImageProvider"/> class. |
| | | 31 | | /// </summary> |
| | | 32 | | /// <param name="logger">Instance of the <see cref="ILogger{ComicImageProvider}"/> interface.</param> |
| | | 33 | | public ComicImageProvider(ILogger<ComicImageProvider> logger) |
| | | 34 | | { |
| | 22 | 35 | | _logger = logger; |
| | 22 | 36 | | } |
| | | 37 | | |
| | | 38 | | /// <inheritdoc /> |
| | 0 | 39 | | public string Name => "Comic Book Archive Cover Extractor"; |
| | | 40 | | |
| | | 41 | | /// <inheritdoc /> |
| | | 42 | | public async Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken) |
| | | 43 | | { |
| | 0 | 44 | | var extension = Path.GetExtension(item.Path); |
| | | 45 | | |
| | 0 | 46 | | if (_comicBookExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) |
| | | 47 | | { |
| | 0 | 48 | | return await LoadCoverAsync(item, cancellationToken).ConfigureAwait(false); |
| | | 49 | | } |
| | | 50 | | |
| | 0 | 51 | | return new DynamicImageResponse { HasImage = false }; |
| | 0 | 52 | | } |
| | | 53 | | |
| | | 54 | | /// <inheritdoc /> |
| | | 55 | | public IEnumerable<ImageType> GetSupportedImages(BaseItem item) |
| | | 56 | | { |
| | 0 | 57 | | yield return ImageType.Primary; |
| | 0 | 58 | | } |
| | | 59 | | |
| | | 60 | | /// <inheritdoc /> |
| | | 61 | | public bool Supports(BaseItem item) |
| | | 62 | | { |
| | 59 | 63 | | return item is Book; |
| | | 64 | | } |
| | | 65 | | |
| | | 66 | | /// <summary> |
| | | 67 | | /// Tries to load a cover from the CBZ archive. Returns a response |
| | | 68 | | /// with no image if nothing is found. |
| | | 69 | | /// </summary> |
| | | 70 | | /// <param name="item">Item to check for covers.</param> |
| | | 71 | | /// <param name="cancellationToken">The cancellation token.</param> |
| | | 72 | | private async Task<DynamicImageResponse> LoadCoverAsync(BaseItem item, CancellationToken cancellationToken) |
| | | 73 | | { |
| | 0 | 74 | | var memoryStream = new MemoryStream(); |
| | | 75 | | |
| | | 76 | | try |
| | | 77 | | { |
| | | 78 | | ImageFormat imageFormat; |
| | | 79 | | |
| | 0 | 80 | | using (Stream stream = AsyncFile.OpenRead(item.Path)) |
| | | 81 | | { |
| | 0 | 82 | | var archive = await ArchiveFactory.OpenAsyncArchive(stream, cancellationToken: cancellationToken).Config |
| | 0 | 83 | | await using (archive.ConfigureAwait(false)) |
| | | 84 | | { |
| | | 85 | | // throw exception to log results if no cover is found |
| | 0 | 86 | | (var cover, imageFormat) = await FindCoverEntryInArchiveAsync(archive).ConfigureAwait(false) |
| | 0 | 87 | | ?? throw new InvalidOperationException("no supported cover found"); |
| | | 88 | | |
| | | 89 | | // copy the cover to memory stream |
| | 0 | 90 | | var coverStream = await cover.OpenEntryStreamAsync(cancellationToken).ConfigureAwait(false); |
| | 0 | 91 | | await using (coverStream.ConfigureAwait(false)) |
| | | 92 | | { |
| | 0 | 93 | | await coverStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); |
| | | 94 | | } |
| | | 95 | | } |
| | 0 | 96 | | } |
| | | 97 | | |
| | | 98 | | // reset stream position after copying |
| | 0 | 99 | | memoryStream.Position = 0; |
| | | 100 | | |
| | 0 | 101 | | return new DynamicImageResponse { HasImage = true, Stream = memoryStream, Format = imageFormat }; |
| | | 102 | | } |
| | 0 | 103 | | catch (Exception e) |
| | | 104 | | { |
| | 0 | 105 | | _logger.LogError(e, "failed to load cover from {Path}", item.Path); |
| | 0 | 106 | | return new DynamicImageResponse { HasImage = false }; |
| | | 107 | | } |
| | 0 | 108 | | } |
| | | 109 | | |
| | | 110 | | /// <summary> |
| | | 111 | | /// Tries to find the entry containing the cover. |
| | | 112 | | /// </summary> |
| | | 113 | | /// <param name="archive">The archive to search.</param> |
| | | 114 | | /// <returns>The search result.</returns> |
| | | 115 | | private async ValueTask<(IArchiveEntry CoverEntry, ImageFormat ImageFormat)?> FindCoverEntryInArchiveAsync(IAsyncArc |
| | | 116 | | { |
| | | 117 | | IArchiveEntry? cover; |
| | | 118 | | |
| | | 119 | | // only some comics will explicitly name their cover file |
| | | 120 | | // in many cases the cover will simply be the first image in the archive |
| | 0 | 121 | | foreach (var extension in _coverExtensions) |
| | | 122 | | { |
| | 0 | 123 | | cover = await archive.EntriesAsync.FirstOrDefaultAsync(e => e.Key == "cover" + extension).ConfigureAwait(fal |
| | | 124 | | |
| | 0 | 125 | | if (cover is not null) |
| | | 126 | | { |
| | 0 | 127 | | var imageFormat = GetImageFormat(extension); |
| | | 128 | | |
| | 0 | 129 | | return (cover, imageFormat); |
| | | 130 | | } |
| | 0 | 131 | | } |
| | | 132 | | |
| | 0 | 133 | | cover = await archive.EntriesAsync.OrderBy(x => x.Key) |
| | 0 | 134 | | .FirstOrDefaultAsync(x => _coverExtensions.Contains(Path.GetExtension(x.Key), StringComparison.OrdinalIgnore |
| | 0 | 135 | | .ConfigureAwait(false); |
| | | 136 | | |
| | 0 | 137 | | if (cover is not null) |
| | | 138 | | { |
| | 0 | 139 | | var imageFormat = GetImageFormat(Path.GetExtension(cover.Key ?? string.Empty)); |
| | | 140 | | |
| | 0 | 141 | | return (cover, imageFormat); |
| | | 142 | | } |
| | | 143 | | |
| | 0 | 144 | | return null; |
| | 0 | 145 | | } |
| | | 146 | | |
| | 0 | 147 | | private static ImageFormat GetImageFormat(string extension) => extension.ToLowerInvariant() switch |
| | 0 | 148 | | { |
| | 0 | 149 | | ".jpg" => ImageFormat.Jpg, |
| | 0 | 150 | | ".jpeg" => ImageFormat.Jpg, |
| | 0 | 151 | | ".png" => ImageFormat.Png, |
| | 0 | 152 | | ".webp" => ImageFormat.Webp, |
| | 0 | 153 | | ".bmp" => ImageFormat.Bmp, |
| | 0 | 154 | | ".gif" => ImageFormat.Gif, |
| | 0 | 155 | | ".svg" => ImageFormat.Svg, |
| | 0 | 156 | | _ => throw new ArgumentException($"unsupported extension: {extension}"), |
| | 0 | 157 | | }; |
| | | 158 | | } |