mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-11 10:10:35 +01:00
migrate local comic providers to server codebase
This commit is contained in:
@@ -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" />
|
||||
<!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
|
||||
<PackageVersion Include="SkiaSharp" Version="[3.116.1]" />
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
_logger.LogError("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);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
146
MediaBrowser.Providers/Books/ComicImageProvider.cs
Normal file
146
MediaBrowser.Providers/Books/ComicImageProvider.cs
Normal file
@@ -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}"),
|
||||
};
|
||||
}
|
||||
218
MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs
Normal file
218
MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using System.Xml.XPath;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using SharpCompress;
|
||||
|
||||
namespace MediaBrowser.Providers.Books.ComicInfo;
|
||||
|
||||
/// <summary>
|
||||
/// ComicInfo reader.
|
||||
/// </summary>
|
||||
public 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 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 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 void ReadCultureInfoInto(XDocument xml, string xPath, Action<CultureInfo> commitResult)
|
||||
{
|
||||
string? culture = null;
|
||||
|
||||
if (!ReadStringInto(xml, xPath, value => culture = value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// culture cannot be null here as the method would have returned earlier
|
||||
commitResult(new CultureInfo(culture!));
|
||||
}
|
||||
catch (CultureNotFoundException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ReadStringInto(XDocument xml, string xPath, Action<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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.Providers.Books.ComicInfo;
|
||||
|
||||
/// <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;
|
||||
private readonly ComicInfoReader _utilities = new();
|
||||
|
||||
/// <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 = _utilities.ReadComicBookMetadata(comicInfoXml);
|
||||
|
||||
if (book is null)
|
||||
{
|
||||
return new MetadataResult<Book> { HasMetadata = false };
|
||||
}
|
||||
|
||||
var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true };
|
||||
|
||||
_utilities.ReadPeopleMetadata(comicInfoXml, metadataResult);
|
||||
_utilities.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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.IO.Compression;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Linq;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.Providers.Books.ComicInfo;
|
||||
|
||||
/// <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;
|
||||
private readonly ComicInfoReader _utilities = new();
|
||||
|
||||
/// <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 = _utilities.ReadComicBookMetadata(comicInfoXml);
|
||||
|
||||
if (book is null)
|
||||
{
|
||||
return new MetadataResult<Book> { HasMetadata = false };
|
||||
}
|
||||
|
||||
var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true };
|
||||
|
||||
_utilities.ReadPeopleMetadata(comicInfoXml, metadataResult);
|
||||
_utilities.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;
|
||||
}
|
||||
}
|
||||
59
MediaBrowser.Providers/Books/ComicProvider.cs
Normal file
59
MediaBrowser.Providers/Books/ComicProvider.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
23
MediaBrowser.Providers/Books/ComicServiceRegistrator.cs
Normal file
23
MediaBrowser.Providers/Books/ComicServiceRegistrator.cs
Normal file
@@ -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>();
|
||||
}
|
||||
}
|
||||
28
MediaBrowser.Providers/Books/IComicProvider.cs
Normal file
28
MediaBrowser.Providers/Books/IComicProvider.cs
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user