diff --git a/Directory.Packages.props b/Directory.Packages.props
index 09524549ef..2131a1484e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -67,6 +67,7 @@
+
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
new file mode 100644
index 0000000000..4ef618b0eb
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
@@ -0,0 +1,246 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using Jellyfin.Extensions.Json;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Providers.Books.ComicBookInfo.Models;
+using Microsoft.Extensions.Logging;
+using SharpCompress.Archives.Zip;
+
+namespace MediaBrowser.Providers.Books.ComicBookInfo;
+
+///
+/// ComicBookInfo provider.
+///
+public class ComicBookInfoProvider : IComicProvider
+{
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ /// Instance of the interface.
+ public ComicBookInfoProvider(IFileSystem fileSystem, ILogger logger)
+ {
+ _fileSystem = fileSystem;
+ _logger = logger;
+ }
+
+ ///
+ public async ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
+ {
+ var path = GetComicBookFile(info.Path)?.FullName;
+
+ if (path is null)
+ {
+ _logger.LogError("could not load comic: {Path}", info.Path);
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ try
+ {
+ Stream stream = File.OpenRead(path);
+
+ // not yet async: https://github.com/adamhathcock/sharpcompress/pull/565
+ await using (stream.ConfigureAwait(false))
+ using (var archive = ZipArchive.Open(stream))
+ {
+ if (!archive.IsComplete)
+ {
+ _logger.LogError("incomplete comic archive: {Path}", info.Path);
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ var volume = archive.Volumes.First();
+
+ if (volume.Comment is null)
+ {
+ _logger.LogInformation("missing ComicBookInfo in archive comment: {Path}", info.Path);
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ var comicBookMetadata = JsonSerializer.Deserialize(volume.Comment, JsonDefaults.Options);
+
+ if (comicBookMetadata is null)
+ {
+ _logger.LogError("ComicBookInfo deserialization failure: {Path}", info.Path);
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ return SaveMetadata(comicBookMetadata);
+ }
+ }
+ catch (Exception)
+ {
+ _logger.LogError("failed to load ComicBookInfo metadata: {Path}", info.Path);
+ return new MetadataResult { HasMetadata = false };
+ }
+ }
+
+ ///
+ public bool HasItemChanged(BaseItem item)
+ {
+ var file = GetComicBookFile(item.Path);
+
+ if (file is null)
+ {
+ return false;
+ }
+
+ return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
+ }
+
+ private MetadataResult SaveMetadata(ComicBookInfoFormat comic)
+ {
+ if (comic.Metadata is null)
+ {
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ var book = ReadComicBookMetadata(comic.Metadata);
+
+ if (book is null)
+ {
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ var metadataResult = new MetadataResult { Item = book, HasMetadata = true };
+
+ if (comic.Metadata.Language is not null)
+ {
+ metadataResult.ResultLanguage = ReadCultureInfoInto(comic.Metadata.Language);
+ }
+
+ if (comic.Metadata.Credits.Count > 0)
+ {
+ ReadPeopleMetadata(comic.Metadata, metadataResult);
+ }
+
+ return metadataResult;
+ }
+
+ private FileSystemMetadata? GetComicBookFile(string path)
+ {
+ var fileInfo = _fileSystem.GetFileSystemInfo(path);
+
+ if (fileInfo.IsDirectory)
+ {
+ return null;
+ }
+
+ // only parse files that are known to have ComicBookInfo metadata
+ return fileInfo.Extension.Equals(".cbz", StringComparison.OrdinalIgnoreCase) ? fileInfo : null;
+ }
+
+ private static Book? ReadComicBookMetadata(ComicBookInfoMetadata comic)
+ {
+ var book = new Book();
+ var hasFoundMetadata = false;
+
+ hasFoundMetadata |= ReadStringInto(comic.Title, title => book.Name = title);
+ hasFoundMetadata |= ReadStringInto(comic.Series, series => book.SeriesName = series);
+ hasFoundMetadata |= ReadStringInto(comic.Genre, genre => book.AddGenre(genre));
+ hasFoundMetadata |= ReadStringInto(comic.Comments, overview => book.Overview = overview);
+ hasFoundMetadata |= ReadStringInto(comic.Publisher, publisher => book.SetStudios([publisher]));
+
+ if (comic.PublicationYear is not null)
+ {
+ book.ProductionYear = comic.PublicationYear;
+ hasFoundMetadata = true;
+ }
+
+ if (comic.Issue is not null)
+ {
+ book.IndexNumber = comic.Issue;
+ hasFoundMetadata = true;
+ }
+
+ if (comic.Tags.Count > 0)
+ {
+ book.Tags = comic.Tags.ToArray();
+ hasFoundMetadata = true;
+ }
+
+ if (comic.PublicationYear is not null && comic.PublicationMonth is not null)
+ {
+ book.PremiereDate = ReadTwoPartDateInto(comic.PublicationYear.Value, comic.PublicationMonth.Value);
+ hasFoundMetadata = true;
+ }
+
+ return hasFoundMetadata ? book : null;
+ }
+
+ private static void ReadPeopleMetadata(ComicBookInfoMetadata comic, MetadataResult metadataResult)
+ {
+ foreach (var person in comic.Credits)
+ {
+ if (person.Person is null || person.Role is null)
+ {
+ continue;
+ }
+
+ if (person.Person.Contains(',', StringComparison.InvariantCultureIgnoreCase))
+ {
+ var name = person.Person.Split(',');
+ person.Person = name[1].Trim(' ') + " " + name[0].Trim(' ');
+ }
+
+ if (!Enum.TryParse(person.Role, out PersonKind personKind))
+ {
+ personKind = PersonKind.Unknown;
+ }
+
+ if (string.Equals("Colorer", person.Role, StringComparison.OrdinalIgnoreCase))
+ {
+ personKind = PersonKind.Colorist;
+ }
+
+ metadataResult.AddPerson(new PersonInfo { Name = person.Person, Type = personKind });
+ }
+ }
+
+ private static string? ReadCultureInfoInto(string language)
+ {
+ try
+ {
+ return CultureInfo.GetCultureInfo(language).DisplayName;
+ }
+ catch (CultureNotFoundException)
+ {
+ return null;
+ }
+ }
+
+ private static bool ReadStringInto(string? data, Action commitResult)
+ {
+ if (!string.IsNullOrWhiteSpace(data))
+ {
+ commitResult(data);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static DateTime? ReadTwoPartDateInto(int year, int month)
+ {
+ try
+ {
+ // use first day of the month because this format doesn't include a day
+ return new DateTime(year, month, 1);
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs
new file mode 100644
index 0000000000..fe7aa40456
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs
@@ -0,0 +1,21 @@
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Providers.Books.ComicBookInfo.Models;
+
+///
+/// ComicBookInfo credit.
+///
+public class ComicBookInfoCredit
+{
+ ///
+ /// Gets or sets the person name.
+ ///
+ [JsonPropertyName("person")]
+ public string? Person { get; set; }
+
+ ///
+ /// Gets or sets the role.
+ ///
+ [JsonPropertyName("role")]
+ public string? Role { get; set; }
+}
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs
new file mode 100644
index 0000000000..5c4e3d948f
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Providers.Books.ComicBookInfo.Models;
+
+///
+/// ComicBookInfo format.
+///
+public class ComicBookInfoFormat
+{
+ ///
+ /// Gets or sets the app ID.
+ ///
+ [JsonPropertyName("appID")]
+ public string? AppId { get; set; }
+
+ ///
+ /// Gets or sets the last modified timestamp.
+ ///
+ [JsonPropertyName("lastModified")]
+ public string? LastModified { get; set; }
+
+ ///
+ /// Gets or sets the metadata.
+ ///
+ [JsonPropertyName("ComicBookInfo/1.0")]
+ public ComicBookInfoMetadata? Metadata { get; set; }
+}
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs
new file mode 100644
index 0000000000..42e1b3d4f6
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Providers.Books.ComicBookInfo.Models;
+
+///
+/// ComicBookInfo metadata.
+///
+public class ComicBookInfoMetadata
+{
+ ///
+ /// Gets or sets the series.
+ ///
+ [JsonPropertyName("series")]
+ public string? Series { get; set; }
+
+ ///
+ /// Gets or sets the title.
+ ///
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ ///
+ /// Gets or sets the publisher.
+ ///
+ [JsonPropertyName("publisher")]
+ public string? Publisher { get; set; }
+
+ ///
+ /// Gets or sets the publication month.
+ ///
+ [JsonPropertyName("publicationMonth")]
+ public int? PublicationMonth { get; set; }
+
+ ///
+ /// Gets or sets the publication year.
+ ///
+ [JsonPropertyName("publicationYear")]
+ public int? PublicationYear { get; set; }
+
+ ///
+ /// Gets or sets the issue number.
+ ///
+ [JsonPropertyName("issue")]
+ public int? Issue { get; set; }
+
+ ///
+ /// Gets or sets the number of issues.
+ ///
+ [JsonPropertyName("numberOfIssues")]
+ public int? NumberOfIssues { get; set; }
+
+ ///
+ /// Gets or sets the volume number.
+ ///
+ [JsonPropertyName("volume")]
+ public int? Volume { get; set; }
+
+ ///
+ /// Gets or sets the number of volumes.
+ ///
+ [JsonPropertyName("numberOfVolumes")]
+ public int? NumberOfVolumes { get; set; }
+
+ ///
+ /// Gets or sets the rating.
+ ///
+ [JsonPropertyName("rating")]
+ public int? Rating { get; set; }
+
+ ///
+ /// Gets or sets the genre.
+ ///
+ [JsonPropertyName("genre")]
+ public string? Genre { get; set; }
+
+ ///
+ /// Gets or sets the language.
+ ///
+ [JsonPropertyName("language")]
+ public string? Language { get; set; }
+
+ ///
+ /// Gets or sets the country.
+ ///
+ [JsonPropertyName("country")]
+ public string? Country { get; set; }
+
+ ///
+ /// Gets or sets the list of credits.
+ ///
+ [JsonPropertyName("credits")]
+ public IReadOnlyList Credits { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets the list of tags.
+ ///
+ [JsonPropertyName("tags")]
+ public IReadOnlyList Tags { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets the comments.
+ ///
+ [JsonPropertyName("comments")]
+ public string? Comments { get; set; }
+}
diff --git a/MediaBrowser.Providers/Books/ComicImageProvider.cs b/MediaBrowser.Providers/Books/ComicImageProvider.cs
new file mode 100644
index 0000000000..01ab22a520
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicImageProvider.cs
@@ -0,0 +1,146 @@
+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 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 Task GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken)
+ {
+ var extension = Path.GetExtension(item.Path);
+
+ if (_comicBookExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
+ {
+ return LoadCover(item);
+ }
+
+ return Task.FromResult(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.
+ private async Task LoadCover(BaseItem item)
+ {
+ var memoryStream = new MemoryStream();
+
+ try
+ {
+ ImageFormat imageFormat;
+
+ using (Stream stream = File.OpenRead(item.Path))
+ using (var archive = ArchiveFactory.Open(stream))
+ {
+ // throw exception to log results if no cover is found
+ (var cover, imageFormat) = FindCoverEntryInArchive(archive) ?? throw new InvalidOperationException("no supported cover found");
+
+ // copy the cover to memory stream
+ await cover.OpenEntryStream().CopyToAsync(memoryStream).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 (IArchiveEntry CoverEntry, ImageFormat ImageFormat)? FindCoverEntryInArchive(IArchive 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 = archive.Entries.FirstOrDefault(e => e.Key == "cover" + extension);
+
+ if (cover is not null)
+ {
+ var imageFormat = GetImageFormat(extension);
+
+ return (cover, imageFormat);
+ }
+ }
+
+ cover = archive.Entries.OrderBy(x => x.Key).FirstOrDefault(x => _coverExtensions.Contains(Path.GetExtension(x.Key), StringComparison.OrdinalIgnoreCase));
+
+ 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}"),
+ };
+}
diff --git a/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs b/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs
new file mode 100644
index 0000000000..429a2cf6d5
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs
@@ -0,0 +1,218 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Xml.Linq;
+using System.Xml.XPath;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using SharpCompress;
+
+namespace MediaBrowser.Providers.Books.ComicInfo;
+
+///
+/// ComicInfo reader.
+///
+public class ComicInfoReader
+{
+ ///
+ /// Filename to check for comic metadata either next to the comic file or inside the archive.
+ ///
+ public const string ComicRackMetaFile = "ComicInfo.xml";
+
+ ///
+ /// Read comic book metadata.
+ ///
+ /// The XDocument to read for comic metadata.
+ /// The resulting book.
+ public Book? ReadComicBookMetadata(XDocument xml)
+ {
+ var book = new Book();
+ var hasFoundMetadata = false;
+
+ // this value is only used internally since Jellyfin has no manga flag
+ var isManga = false;
+
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Title", title => book.Name = title);
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Manga", manga => isManga = manga.Equals("Yes", StringComparison.OrdinalIgnoreCase));
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Series", series => book.SeriesName = series);
+ hasFoundMetadata |= ReadIntInto(xml, "ComicInfo/Number", issue => book.IndexNumber = issue);
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Summary", summary => book.Overview = summary);
+ hasFoundMetadata |= ReadIntInto(xml, "ComicInfo/Year", year => book.ProductionYear = year);
+ hasFoundMetadata |= ReadThreePartDateInto(xml, "ComicInfo/Year", "ComicInfo/Month", "ComicInfo/Day", dateTime => book.PremiereDate = dateTime);
+ hasFoundMetadata |= ReadCommaSeparatedStringsInto(xml, "ComicInfo/Genre", genres => genres.ForEach(genre => book.AddGenre(genre)));
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Publisher", publisher => book.SetStudios([publisher]));
+
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/AlternateSeries", title =>
+ {
+ if (isManga)
+ {
+ // Software like ComicTagger (https://github.com/comictagger/comictagger) will use
+ // this field for the series name in the original language when tagging manga.
+ book.OriginalTitle = title;
+ }
+ else
+ {
+ // Some US comics can be part of cross-over story arcs. This field is then used to
+ // specify an alternate series.
+ }
+ });
+
+ return hasFoundMetadata ? book : null;
+ }
+
+ ///
+ /// Read people metadata.
+ ///
+ /// The XDocument to read for people metadata.
+ /// The metadata result to update.
+ public void ReadPeopleMetadata(XDocument xml, MetadataResult metadataResult)
+ {
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/Writer", authors =>
+ {
+ authors.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Author }));
+ });
+
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/Penciller", pencillers =>
+ {
+ pencillers.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Penciller }));
+ });
+
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/Inker", inkers =>
+ {
+ inkers.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Inker }));
+ });
+
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/Letterer", letterers =>
+ {
+ letterers.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Letterer }));
+ });
+
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/CoverArtist", artists =>
+ {
+ artists.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.CoverArtist }));
+ });
+
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/Colourist", colorists =>
+ {
+ colorists.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Colorist }));
+ });
+ }
+
+ ///
+ /// Read culture information.
+ ///
+ /// the XDocument to read for metadata.
+ /// The path to search.
+ /// The action to take after parsing all metadata.
+ public void ReadCultureInfoInto(XDocument xml, string xPath, Action commitResult)
+ {
+ string? culture = null;
+
+ if (!ReadStringInto(xml, xPath, value => culture = value))
+ {
+ return;
+ }
+
+ try
+ {
+ // culture cannot be null here as the method would have returned earlier
+ commitResult(new CultureInfo(culture!));
+ }
+ catch (CultureNotFoundException)
+ {
+ }
+ }
+
+ private static bool ReadStringInto(XDocument xml, string xPath, Action commitResult)
+ {
+ var resultElement = xml.XPathSelectElement(xPath);
+
+ if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.Value))
+ {
+ commitResult(resultElement.Value);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool ReadCommaSeparatedStringsInto(XDocument xml, string xPath, Action> commitResult)
+ {
+ var resultElement = xml.XPathSelectElement(xPath);
+
+ if (resultElement is null || string.IsNullOrWhiteSpace(resultElement.Value))
+ {
+ return false;
+ }
+
+ try
+ {
+ var splits = resultElement.Value.Split(",").Select(p => p.Trim()).ToArray();
+ if (splits.Length < 1)
+ {
+ return false;
+ }
+
+ commitResult(splits);
+ return true;
+ }
+ catch (ArgumentNullException)
+ {
+ return false;
+ }
+ }
+
+ private static bool ReadIntInto(XDocument xml, string xPath, Action commitResult)
+ {
+ var resultElement = xml.XPathSelectElement(xPath);
+
+ if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.Value))
+ {
+ return ParseInt(resultElement.Value, commitResult);
+ }
+
+ return false;
+ }
+
+ private static bool ReadThreePartDateInto(XDocument xml, string yearXPath, string monthXPath, string dayXPath, Action commitResult)
+ {
+ int year = 0;
+ int month = 0;
+ int day = 0;
+ var parsed = false;
+
+ parsed |= ReadIntInto(xml, yearXPath, num => year = num);
+ parsed |= ReadIntInto(xml, monthXPath, num => month = num);
+ parsed |= ReadIntInto(xml, dayXPath, num => day = num);
+
+ if (!parsed)
+ {
+ return false;
+ }
+
+ try
+ {
+ var dateTime = new DateTime(year, month, day);
+
+ commitResult(dateTime);
+ return true;
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return false;
+ }
+ }
+
+ private static bool ParseInt(string input, Action commitResult)
+ {
+ if (int.TryParse(input, out var parsed))
+ {
+ commitResult(parsed);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
new file mode 100644
index 0000000000..62fca925c8
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
@@ -0,0 +1,100 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml;
+using System.Xml.Linq;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Books.ComicInfo;
+
+///
+/// Handles metadata for comics which is saved as an XML document. This XML document is not part
+/// of the comic itself but an external file.
+///
+public class ExternalComicInfoProvider : IComicProvider
+{
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger _logger;
+ private readonly ComicInfoReader _utilities = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ /// Instance of the interface.
+ public ExternalComicInfoProvider(IFileSystem fileSystem, ILogger logger)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ }
+
+ ///
+ public async ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
+ {
+ var comicInfoXml = await LoadXml(info, cancellationToken).ConfigureAwait(false);
+
+ if (comicInfoXml is null)
+ {
+ _logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file.", info.Path);
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ var book = _utilities.ReadComicBookMetadata(comicInfoXml);
+
+ if (book is null)
+ {
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ var metadataResult = new MetadataResult { Item = book, HasMetadata = true };
+
+ _utilities.ReadPeopleMetadata(comicInfoXml, metadataResult);
+ _utilities.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
+
+ return metadataResult;
+ }
+
+ ///
+ public bool HasItemChanged(BaseItem item)
+ {
+ var file = GetXmlFilePath(item.Path);
+
+ return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
+ }
+
+ private async Task LoadXml(ItemInfo info, CancellationToken cancellationToken)
+ {
+ var path = GetXmlFilePath(info.Path).FullName;
+
+ if (path is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ using var reader = XmlReader.Create(path, new XmlReaderSettings { Async = true });
+ var comicInfoXml = XDocument.LoadAsync(reader, LoadOptions.None, cancellationToken);
+
+ return await comicInfoXml.ConfigureAwait(false);
+ }
+ catch (Exception e)
+ {
+ _logger.LogInformation(e, "Could not load external xml from {Path}. This could mean there is no separate ComicInfo metadata file for this comic or the metadata is bundled within the comic.", path);
+ return null;
+ }
+ }
+
+ private FileSystemMetadata GetXmlFilePath(string path)
+ {
+ var fileInfo = _fileSystem.GetFileSystemInfo(path);
+ var directoryInfo = fileInfo.IsDirectory ? fileInfo : _fileSystem.GetDirectoryInfo(Path.GetDirectoryName(path)!);
+ var file = _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, Path.GetFileNameWithoutExtension(path) + ".xml"));
+
+ return file.Exists ? file : _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, ComicInfoReader.ComicRackMetaFile));
+ }
+}
diff --git a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
new file mode 100644
index 0000000000..eff248b8d4
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
@@ -0,0 +1,121 @@
+using System;
+using System.IO.Compression;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Books.ComicInfo;
+
+///
+/// Handles metadata for comics which is saved as an XML document inside the comic itself.
+///
+public class InternalComicInfoProvider : IComicProvider
+{
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger _logger;
+ private readonly ComicInfoReader _utilities = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ /// Instance of the interface.
+ public InternalComicInfoProvider(IFileSystem fileSystem, ILogger logger)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ }
+
+ ///
+ public async ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
+ {
+ var comicInfoXml = await LoadXml(info, cancellationToken).ConfigureAwait(false);
+
+ if (comicInfoXml is null)
+ {
+ _logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file. No internal XML in comic archive.", info.Path);
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ var book = _utilities.ReadComicBookMetadata(comicInfoXml);
+
+ if (book is null)
+ {
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ var metadataResult = new MetadataResult { Item = book, HasMetadata = true };
+
+ _utilities.ReadPeopleMetadata(comicInfoXml, metadataResult);
+ _utilities.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
+
+ return metadataResult;
+ }
+
+ ///
+ public bool HasItemChanged(BaseItem item)
+ {
+ var file = GetComicBookFile(item.Path);
+
+ if (file is null)
+ {
+ return false;
+ }
+
+ return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
+ }
+
+ private async Task LoadXml(ItemInfo info, CancellationToken cancellationToken)
+ {
+ var path = GetComicBookFile(info.Path)?.FullName;
+
+ if (path is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ // open the comic archive and try to get the ComicInfo.xml entry
+ using var comicBookFile = await ZipFile.OpenReadAsync(path, cancellationToken).ConfigureAwait(false);
+ var container = comicBookFile.GetEntry(ComicInfoReader.ComicRackMetaFile);
+
+ if (container is null)
+ {
+ return null;
+ }
+
+ using var containerStream = await container.OpenAsync(cancellationToken).ConfigureAwait(false);
+ var comicInfoXml = XDocument.LoadAsync(containerStream, LoadOptions.None, cancellationToken);
+
+ return await comicInfoXml.ConfigureAwait(false);
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "could not load internal XML from {Path}", path);
+ return null;
+ }
+ }
+
+ private FileSystemMetadata? GetComicBookFile(string path)
+ {
+ var fileInfo = _fileSystem.GetFileSystemInfo(path);
+
+ if (fileInfo.IsDirectory)
+ {
+ return null;
+ }
+
+ // only parse files that are known to have internal metadata
+ if (!string.Equals(fileInfo.Extension, ".cbz", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ return fileInfo;
+ }
+}
diff --git a/MediaBrowser.Providers/Books/ComicProvider.cs b/MediaBrowser.Providers/Books/ComicProvider.cs
new file mode 100644
index 0000000000..d59c58c330
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicProvider.cs
@@ -0,0 +1,59 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+
+namespace MediaBrowser.Providers.Books;
+
+///
+/// Comic provider.
+///
+public class ComicProvider : ILocalMetadataProvider, IHasItemChangeMonitor
+{
+ private readonly IEnumerable _comicProviders;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The list of comic providers.
+ public ComicProvider(IEnumerable comicProviders)
+ {
+ _comicProviders = comicProviders;
+ }
+
+ ///
+ public string Name => "Comic Provider";
+
+ ///
+ public async Task> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
+ {
+ foreach (IComicProvider comicProvider in _comicProviders)
+ {
+ var metadata = await comicProvider.ReadMetadata(info, directoryService, cancellationToken).ConfigureAwait(false);
+
+ if (metadata.HasMetadata)
+ {
+ return metadata;
+ }
+ }
+
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ ///
+ public bool HasChanged(BaseItem item, IDirectoryService directoryService)
+ {
+ foreach (IComicProvider iComicFileProvider in _comicProviders)
+ {
+ var fileChanged = iComicFileProvider.HasItemChanged(item);
+
+ if (fileChanged)
+ {
+ return fileChanged;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/MediaBrowser.Providers/Books/ComicServiceRegistrator.cs b/MediaBrowser.Providers/Books/ComicServiceRegistrator.cs
new file mode 100644
index 0000000000..0d096241d6
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicServiceRegistrator.cs
@@ -0,0 +1,23 @@
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Providers.Books.ComicBookInfo;
+using MediaBrowser.Providers.Books.ComicInfo;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace MediaBrowser.Providers.Books;
+
+///
+public class ComicServiceRegistrator : IPluginServiceRegistrator
+{
+ ///
+ public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
+ {
+ // register the generic local metadata provider for comic files
+ serviceCollection.AddSingleton();
+
+ // register the actual implementations of the local metadata provider for comic files
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ }
+}
diff --git a/MediaBrowser.Providers/Books/IComicProvider.cs b/MediaBrowser.Providers/Books/IComicProvider.cs
new file mode 100644
index 0000000000..06c8bd1136
--- /dev/null
+++ b/MediaBrowser.Providers/Books/IComicProvider.cs
@@ -0,0 +1,28 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+
+namespace MediaBrowser.Providers.Books;
+
+///
+/// Comic provider interface.
+///
+public interface IComicProvider
+{
+ ///
+ /// Read the item metadata.
+ ///
+ /// The item information.
+ /// Instance of the interface.
+ /// The cancellation token.
+ /// The metadata result.
+ ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken);
+
+ ///
+ /// Determine whether the item has changed.
+ ///
+ /// The item.
+ /// Item change status.
+ bool HasItemChanged(BaseItem item);
+}
diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
index ed0c63b97f..cbf050c5df 100644
--- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj
+++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
@@ -22,6 +22,7 @@
+