aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.config/dotnet-tools.json2
-rw-r--r--.github/pull_request_template.md6
-rw-r--r--Directory.Packages.props1
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-GB.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-HK.json4
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs66
-rw-r--r--MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs246
-rw-r--r--MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs21
-rw-r--r--MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs27
-rw-r--r--MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs107
-rw-r--r--MediaBrowser.Providers/Books/ComicImageProvider.cs146
-rw-r--r--MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs212
-rw-r--r--MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs99
-rw-r--r--MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs120
-rw-r--r--MediaBrowser.Providers/Books/ComicProvider.cs59
-rw-r--r--MediaBrowser.Providers/Books/ComicServiceRegistrator.cs23
-rw-r--r--MediaBrowser.Providers/Books/IComicProvider.cs28
-rw-r--r--MediaBrowser.Providers/MediaBrowser.Providers.csproj1
18 files changed, 1133 insertions, 39 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 5fc7834fcc..74e66d3adb 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
- "version": "10.0.8",
+ "version": "10.0.9",
"commands": [
"dotnet-ef"
]
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index dc93d2c84e..d6833ea2be 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,11 +1,15 @@
<!--
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
-For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our documentation.
+For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://jellyfin.org/docs/general/contributing/issues/ page.
-->
**Changes**
<!-- Describe your changes here in 1-5 sentences. -->
+**Code assistance**
+<!-- If code assistance was used, describe how it contributed
+e.g., code generated by LLM, explanation of code base, debugging guidance. -->
+
**Issues**
<!-- Tag any issues that this PR solves here.
ex. Fixes # -->
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1c26dd34e8..1c48a1ec24 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -67,6 +67,7 @@
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
+ <PackageVersion Include="SharpCompress" Version="0.38.0" />
<PackageVersion Include="SharpFuzz" Version="2.2.0" />
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json
index be152b515f..298d60d277 100644
--- a/Emby.Server.Implementations/Localization/Core/en-GB.json
+++ b/Emby.Server.Implementations/Localization/Core/en-GB.json
@@ -106,5 +106,7 @@
"TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
"TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.",
"CleanupUserDataTask": "User data cleanup task",
- "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days."
+ "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days.",
+ "LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}",
+ "Original": "Original"
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json
index 8b9665cf9a..1098880cf3 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-HK.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json
@@ -106,5 +106,7 @@
"TaskMoveTrickplayImagesDescription": "根據媒體櫃設定,將現有嘅 Trickplay(快轉預覽)檔案搬去對應位置。",
"TaskMoveTrickplayImages": "搬移快轉預覽圖嘅位置",
"CleanupUserDataTask": "清理使用者資料嘅任務",
- "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。"
+ "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。",
+ "LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞",
+ "Original": "原始"
}
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index ff8d84d45e..320e65231c 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -444,6 +444,13 @@ namespace MediaBrowser.Controller.MediaEncoding
|| state.VideoStream.VideoRangeType == VideoRangeType.HLG);
}
+ private static bool IsDeinterlaceAvailable(EncodingJobInfo state)
+ {
+ var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
+ var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
+ return doDeintH264 || doDeintHevc;
+ }
+
private bool IsVideoStreamHevcRext(EncodingJobInfo state)
{
var videoStream = state.VideoStream;
@@ -3850,9 +3857,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase);
var isV4l2Encoder = vidEncoder.Contains("h264_v4l2m2m", StringComparison.OrdinalIgnoreCase);
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doToneMap = IsSwTonemapAvailable(state, options);
var requireDoviReshaping = doToneMap && state.VideoStream.VideoRangeType == VideoRangeType.DOVI;
@@ -4004,9 +4009,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isCuInCuOut = isNvDecoder && isNvencEncoder;
var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.ReferenceFrameRate ?? 60) <= 30;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doCuTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -4215,9 +4218,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isDxInDxOut = isD3d11vaDecoder && isAmfEncoder;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -4463,9 +4464,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doVppTonemap = IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVppTonemap || doOclTonemap;
@@ -4757,12 +4756,10 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVaVppTonemap = IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVaVppTonemap || doOclTonemap;
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5088,12 +5085,10 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isVaInVaOut = isVaapiDecoder && isVaapiEncoder;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVaVppTonemap = isVaapiDecoder && IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVaVppTonemap || doOclTonemap;
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5325,10 +5320,8 @@ namespace MediaBrowser.Controller.MediaEncoding
var isSwEncoder = !isVaapiEncoder;
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVkTonemap = IsVulkanHwTonemapAvailable(state, options);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5565,9 +5558,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isi965Driver = _mediaEncoder.IsVaapiDeviceInteli965;
var isAmdDriver = _mediaEncoder.IsVaapiDeviceAmd;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -5798,9 +5789,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var reqMaxH = state.BaseRequest.MaxHeight;
var threeDFormat = state.MediaSource.Video3DFormat;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doVtTonemap = IsVideoToolboxTonemapAvailable(state, options);
var doMetalTonemap = !doVtTonemap && IsHwTonemapAvailable(state, options);
var usingHwSurface = isVtDecoder && (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface);
@@ -5999,9 +5988,7 @@ namespace MediaBrowser.Controller.MediaEncoding
&& (vidEncoder.Contains("h264", StringComparison.OrdinalIgnoreCase)
|| vidEncoder.Contains("hevc", StringComparison.OrdinalIgnoreCase));
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -6265,12 +6252,21 @@ namespace MediaBrowser.Controller.MediaEncoding
overlayFilters?.RemoveAll(string.IsNullOrEmpty);
var framerate = GetFramerateParam(state);
- if (framerate.HasValue)
+ if (mainFilters is not null && framerate.HasValue)
{
- mainFilters.Insert(0, string.Format(
- CultureInfo.InvariantCulture,
- "fps={0}",
- framerate.Value));
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
+ var fpsFilter = string.Format(CultureInfo.InvariantCulture, "fps={0}", framerate.Value);
+
+ // For filter chain containing the deinterlace filter,
+ // place the fps filter at the end to preserve temporal info.
+ if (doDeintH2645)
+ {
+ mainFilters.Add(fpsFilter);
+ }
+ else
+ {
+ mainFilters.Insert(0, fpsFilter);
+ }
}
var mainStr = string.Empty;
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
new file mode 100644
index 0000000000..a372b90212
--- /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;
+
+/// <summary>
+/// ComicBookInfo provider.
+/// </summary>
+public class ComicBookInfoProvider : IComicProvider
+{
+ private readonly ILogger<ComicBookInfoProvider> _logger;
+ private readonly IFileSystem _fileSystem;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ComicBookInfoProvider"/> class.
+ /// </summary>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{ComicBookInfoProvider}"/> interface.</param>
+ public ComicBookInfoProvider(IFileSystem fileSystem, ILogger<ComicBookInfoProvider> logger)
+ {
+ _fileSystem = fileSystem;
+ _logger = logger;
+ }
+
+ /// <inheritdoc />
+ public async ValueTask<MetadataResult<Book>> 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<Book> { 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<Book> { 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<Book> { HasMetadata = false };
+ }
+
+ var comicBookMetadata = JsonSerializer.Deserialize<ComicBookInfoFormat>(volume.Comment, JsonDefaults.Options);
+
+ if (comicBookMetadata is null)
+ {
+ _logger.LogError("ComicBookInfo deserialization failure: {Path}", info.Path);
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+
+ return SaveMetadata(comicBookMetadata);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "failed to load ComicBookInfo metadata: {Path}", info.Path);
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+ }
+
+ /// <inheritdoc />
+ 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<Book> SaveMetadata(ComicBookInfoFormat comic)
+ {
+ if (comic.Metadata is null)
+ {
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+
+ var book = ReadComicBookMetadata(comic.Metadata);
+
+ if (book is null)
+ {
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+
+ var metadataResult = new MetadataResult<Book> { 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<Book> 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<string> 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, 0, 0, 0, DateTimeKind.Unspecified);
+ }
+ 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;
+
+/// <summary>
+/// ComicBookInfo credit.
+/// </summary>
+public class ComicBookInfoCredit
+{
+ /// <summary>
+ /// Gets or sets the person name.
+ /// </summary>
+ [JsonPropertyName("person")]
+ public string? Person { get; set; }
+
+ /// <summary>
+ /// Gets or sets the role.
+ /// </summary>
+ [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;
+
+/// <summary>
+/// ComicBookInfo format.
+/// </summary>
+public class ComicBookInfoFormat
+{
+ /// <summary>
+ /// Gets or sets the app ID.
+ /// </summary>
+ [JsonPropertyName("appID")]
+ public string? AppId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the last modified timestamp.
+ /// </summary>
+ [JsonPropertyName("lastModified")]
+ public string? LastModified { get; set; }
+
+ /// <summary>
+ /// Gets or sets the metadata.
+ /// </summary>
+ [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;
+
+/// <summary>
+/// ComicBookInfo metadata.
+/// </summary>
+public class ComicBookInfoMetadata
+{
+ /// <summary>
+ /// Gets or sets the series.
+ /// </summary>
+ [JsonPropertyName("series")]
+ public string? Series { get; set; }
+
+ /// <summary>
+ /// Gets or sets the title.
+ /// </summary>
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ /// <summary>
+ /// Gets or sets the publisher.
+ /// </summary>
+ [JsonPropertyName("publisher")]
+ public string? Publisher { get; set; }
+
+ /// <summary>
+ /// Gets or sets the publication month.
+ /// </summary>
+ [JsonPropertyName("publicationMonth")]
+ public int? PublicationMonth { get; set; }
+
+ /// <summary>
+ /// Gets or sets the publication year.
+ /// </summary>
+ [JsonPropertyName("publicationYear")]
+ public int? PublicationYear { get; set; }
+
+ /// <summary>
+ /// Gets or sets the issue number.
+ /// </summary>
+ [JsonPropertyName("issue")]
+ public int? Issue { get; set; }
+
+ /// <summary>
+ /// Gets or sets the number of issues.
+ /// </summary>
+ [JsonPropertyName("numberOfIssues")]
+ public int? NumberOfIssues { get; set; }
+
+ /// <summary>
+ /// Gets or sets the volume number.
+ /// </summary>
+ [JsonPropertyName("volume")]
+ public int? Volume { get; set; }
+
+ /// <summary>
+ /// Gets or sets the number of volumes.
+ /// </summary>
+ [JsonPropertyName("numberOfVolumes")]
+ public int? NumberOfVolumes { get; set; }
+
+ /// <summary>
+ /// Gets or sets the rating.
+ /// </summary>
+ [JsonPropertyName("rating")]
+ public int? Rating { get; set; }
+
+ /// <summary>
+ /// Gets or sets the genre.
+ /// </summary>
+ [JsonPropertyName("genre")]
+ public string? Genre { get; set; }
+
+ /// <summary>
+ /// Gets or sets the language.
+ /// </summary>
+ [JsonPropertyName("language")]
+ public string? Language { get; set; }
+
+ /// <summary>
+ /// Gets or sets the country.
+ /// </summary>
+ [JsonPropertyName("country")]
+ public string? Country { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of credits.
+ /// </summary>
+ [JsonPropertyName("credits")]
+ public IReadOnlyList<ComicBookInfoCredit> Credits { get; set; } = Array.Empty<ComicBookInfoCredit>();
+
+ /// <summary>
+ /// Gets or sets the list of tags.
+ /// </summary>
+ [JsonPropertyName("tags")]
+ public IReadOnlyList<string> Tags { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets the comments.
+ /// </summary>
+ [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;
+
+/// <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 Task<DynamicImageResponse> 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 });
+ }
+
+ /// <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>
+ private async Task<DynamicImageResponse> 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 };
+ }
+ }
+
+ /// <summary>
+ /// Tries to find the entry containing the cover.
+ /// </summary>
+ /// <param name="archive">The archive to search.</param>
+ /// <returns>The search result.</returns>
+ 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..b8329e7805
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs
@@ -0,0 +1,212 @@
+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;
+
+/// <summary>
+/// ComicInfo reader.
+/// </summary>
+public static class ComicInfoReader
+{
+ /// <summary>
+ /// Filename to check for comic metadata either next to the comic file or inside the archive.
+ /// </summary>
+ public const string ComicRackMetaFile = "ComicInfo.xml";
+
+ /// <summary>
+ /// Read comic book metadata.
+ /// </summary>
+ /// <param name="xml">The XDocument to read for comic metadata.</param>
+ /// <returns>The resulting book.</returns>
+ public static 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;
+ }
+
+ /// <summary>
+ /// Read people metadata.
+ /// </summary>
+ /// <param name="xml">The XDocument to read for people metadata.</param>
+ /// <param name="metadataResult">The metadata result to update.</param>
+ public static void ReadPeopleMetadata(XDocument xml, MetadataResult<Book> 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 }));
+ });
+ }
+
+ /// <summary>
+ /// Read culture information.
+ /// </summary>
+ /// <param name="xml">the XDocument to read for metadata.</param>
+ /// <param name="xPath">The path to search.</param>
+ /// <param name="commitResult">The action to take after parsing all metadata.</param>
+ public static void ReadCultureInfoInto(XDocument xml, string xPath, Action<CultureInfo> commitResult)
+ {
+ string? culture = null;
+
+ if (!ReadStringInto(xml, xPath, value => culture = value))
+ {
+ return;
+ }
+
+ // culture cannot be null here as the method would have returned earlier
+ commitResult(new CultureInfo(culture!));
+ }
+
+ private static bool ReadStringInto(XDocument xml, string xPath, Action<string> 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<IEnumerable<string>> 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<int> 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<DateTime> 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, 0, 0, 0, DateTimeKind.Unspecified);
+
+ commitResult(dateTime);
+ return true;
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return false;
+ }
+ }
+
+ private static bool ParseInt(string input, Action<int> 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..8dd76d8b15
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
@@ -0,0 +1,99 @@
+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;
+
+/// <summary>
+/// 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.
+/// </summary>
+public class ExternalComicInfoProvider : IComicProvider
+{
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger<ExternalComicInfoProvider> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ExternalComicInfoProvider"/> class.
+ /// </summary>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{ExternalComicInfoProvider}"/> interface.</param>
+ public ExternalComicInfoProvider(IFileSystem fileSystem, ILogger<ExternalComicInfoProvider> logger)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ }
+
+ /// <inheritdoc />
+ public async ValueTask<MetadataResult<Book>> 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<Book> { HasMetadata = false };
+ }
+
+ var book = ComicInfoReader.ReadComicBookMetadata(comicInfoXml);
+
+ if (book is null)
+ {
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+
+ var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true };
+
+ ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult);
+ ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
+
+ return metadataResult;
+ }
+
+ /// <inheritdoc />
+ public bool HasItemChanged(BaseItem item)
+ {
+ var file = GetXmlFilePath(item.Path);
+
+ return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
+ }
+
+ private async Task<XDocument?> 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..98a6aba7d6
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
@@ -0,0 +1,120 @@
+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;
+
+/// <summary>
+/// Handles metadata for comics which is saved as an XML document inside the comic itself.
+/// </summary>
+public class InternalComicInfoProvider : IComicProvider
+{
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger<InternalComicInfoProvider> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="InternalComicInfoProvider"/> class.
+ /// </summary>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{InternalComicInfoProvider}"/> interface.</param>
+ public InternalComicInfoProvider(IFileSystem fileSystem, ILogger<InternalComicInfoProvider> logger)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ }
+
+ /// <inheritdoc />
+ public async ValueTask<MetadataResult<Book>> 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<Book> { HasMetadata = false };
+ }
+
+ var book = ComicInfoReader.ReadComicBookMetadata(comicInfoXml);
+
+ if (book is null)
+ {
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+
+ var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true };
+
+ ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult);
+ ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
+
+ return metadataResult;
+ }
+
+ /// <inheritdoc />
+ 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<XDocument?> 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;
+
+/// <summary>
+/// Comic provider.
+/// </summary>
+public class ComicProvider : ILocalMetadataProvider<Book>, IHasItemChangeMonitor
+{
+ private readonly IEnumerable<IComicProvider> _comicProviders;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ComicProvider"/> class.
+ /// </summary>
+ /// <param name="comicProviders">The list of comic providers.</param>
+ public ComicProvider(IEnumerable<IComicProvider> comicProviders)
+ {
+ _comicProviders = comicProviders;
+ }
+
+ /// <inheritdoc />
+ public string Name => "Comic Provider";
+
+ /// <inheritdoc />
+ public async Task<MetadataResult<Book>> 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<Book> { HasMetadata = false };
+ }
+
+ /// <inheritdoc />
+ 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;
+
+/// <inheritdoc />
+public class ComicServiceRegistrator : IPluginServiceRegistrator
+{
+ /// <inheritdoc />
+ public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
+ {
+ // register the generic local metadata provider for comic files
+ serviceCollection.AddSingleton<ComicProvider>();
+
+ // register the actual implementations of the local metadata provider for comic files
+ serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>();
+ serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>();
+ serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>();
+ }
+}
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;
+
+/// <summary>
+/// Comic provider interface.
+/// </summary>
+public interface IComicProvider
+{
+ /// <summary>
+ /// Read the item metadata.
+ /// </summary>
+ /// <param name="info">The item information.</param>
+ /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The metadata result.</returns>
+ ValueTask<MetadataResult<Book>> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Determine whether the item has changed.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <returns>Item change status.</returns>
+ bool HasItemChanged(BaseItem item);
+}
diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
index 1032582900..df51dd8421 100644
--- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj
+++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
@@ -22,6 +22,7 @@
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="PlaylistsNET" />
+ <PackageReference Include="SharpCompress" />
<PackageReference Include="z440.atl.core" />
<PackageReference Include="TMDbLib" />
</ItemGroup>