diff options
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> |
