< Summary - Jellyfin

Information
Class: MediaBrowser.Providers.Books.ComicImageProvider
Assembly: MediaBrowser.Providers
File(s): /srv/git/jellyfin/MediaBrowser.Providers/Books/ComicImageProvider.cs
Line coverage
9%
Covered lines: 5
Uncovered lines: 49
Coverable lines: 54
Total lines: 158
Line coverage: 9.2%
Branch coverage
0%
Covered branches: 0
Total branches: 48
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 6/11/2026 - 12:16:04 AM Line coverage: 11.1% (5/45) Branch coverage: 0% (0/48) Total lines: 1466/16/2026 - 12:14:30 AM Line coverage: 9.2% (5/54) Branch coverage: 0% (0/48) Total lines: 158 6/11/2026 - 12:16:04 AM Line coverage: 11.1% (5/45) Branch coverage: 0% (0/48) Total lines: 1466/16/2026 - 12:14:30 AM Line coverage: 9.2% (5/54) Branch coverage: 0% (0/48) Total lines: 158

Coverage delta

Coverage delta 2 -2

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Name()100%210%
GetImage()0%620%
GetSupportedImages()100%210%
Supports(...)100%11100%
LoadCoverAsync()0%620%
FindCoverEntryInArchiveAsync()0%7280%
GetImageFormat(...)0%1332360%

File(s)

/srv/git/jellyfin/MediaBrowser.Providers/Books/ComicImageProvider.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.IO;
 4using System.Linq;
 5using System.Threading;
 6using System.Threading.Tasks;
 7using Jellyfin.Extensions;
 8using MediaBrowser.Controller.Entities;
 9using MediaBrowser.Controller.Providers;
 10using MediaBrowser.Model.Drawing;
 11using MediaBrowser.Model.Entities;
 12using MediaBrowser.Model.IO;
 13using Microsoft.Extensions.Logging;
 14using SharpCompress.Archives;
 15
 16namespace 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>
 22public class ComicImageProvider : IDynamicImageProvider
 23{
 2224    private readonly string[] _comicBookExtensions = [".cb7", ".cbr", ".cbt", ".cbz"];
 2225    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    {
 2235        _logger = logger;
 2236    }
 37
 38    /// <inheritdoc />
 039    public string Name => "Comic Book Archive Cover Extractor";
 40
 41    /// <inheritdoc />
 42    public async Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken)
 43    {
 044        var extension = Path.GetExtension(item.Path);
 45
 046        if (_comicBookExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
 47        {
 048            return await LoadCoverAsync(item, cancellationToken).ConfigureAwait(false);
 49        }
 50
 051        return new DynamicImageResponse { HasImage = false };
 052    }
 53
 54    /// <inheritdoc />
 55    public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
 56    {
 057        yield return ImageType.Primary;
 058    }
 59
 60    /// <inheritdoc />
 61    public bool Supports(BaseItem item)
 62    {
 5963        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    {
 074        var memoryStream = new MemoryStream();
 75
 76        try
 77        {
 78            ImageFormat imageFormat;
 79
 080            using (Stream stream = AsyncFile.OpenRead(item.Path))
 81            {
 082                var archive = await ArchiveFactory.OpenAsyncArchive(stream, cancellationToken: cancellationToken).Config
 083                await using (archive.ConfigureAwait(false))
 84                {
 85                    // throw exception to log results if no cover is found
 086                    (var cover, imageFormat) = await FindCoverEntryInArchiveAsync(archive).ConfigureAwait(false)
 087                        ?? throw new InvalidOperationException("no supported cover found");
 88
 89                    // copy the cover to memory stream
 090                    var coverStream = await cover.OpenEntryStreamAsync(cancellationToken).ConfigureAwait(false);
 091                    await using (coverStream.ConfigureAwait(false))
 92                    {
 093                        await coverStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
 94                    }
 95                }
 096            }
 97
 98            // reset stream position after copying
 099            memoryStream.Position = 0;
 100
 0101            return new DynamicImageResponse { HasImage = true, Stream = memoryStream, Format = imageFormat };
 102        }
 0103        catch (Exception e)
 104        {
 0105            _logger.LogError(e, "failed to load cover from {Path}", item.Path);
 0106            return new DynamicImageResponse { HasImage = false };
 107        }
 0108    }
 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
 0121        foreach (var extension in _coverExtensions)
 122        {
 0123            cover = await archive.EntriesAsync.FirstOrDefaultAsync(e => e.Key == "cover" + extension).ConfigureAwait(fal
 124
 0125            if (cover is not null)
 126            {
 0127                var imageFormat = GetImageFormat(extension);
 128
 0129                return (cover, imageFormat);
 130            }
 0131        }
 132
 0133        cover = await archive.EntriesAsync.OrderBy(x => x.Key)
 0134            .FirstOrDefaultAsync(x => _coverExtensions.Contains(Path.GetExtension(x.Key), StringComparison.OrdinalIgnore
 0135            .ConfigureAwait(false);
 136
 0137        if (cover is not null)
 138        {
 0139            var imageFormat = GetImageFormat(Path.GetExtension(cover.Key ?? string.Empty));
 140
 0141            return (cover, imageFormat);
 142        }
 143
 0144        return null;
 0145    }
 146
 0147    private static ImageFormat GetImageFormat(string extension) => extension.ToLowerInvariant() switch
 0148    {
 0149        ".jpg" => ImageFormat.Jpg,
 0150        ".jpeg" => ImageFormat.Jpg,
 0151        ".png" => ImageFormat.Png,
 0152        ".webp" => ImageFormat.Webp,
 0153        ".bmp" => ImageFormat.Bmp,
 0154        ".gif" => ImageFormat.Gif,
 0155        ".svg" => ImageFormat.Svg,
 0156        _ => throw new ArgumentException($"unsupported extension: {extension}"),
 0157    };
 158}