diff options
| author | Bond-009 <bond.009@outlook.com> | 2026-02-14 12:07:30 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-14 12:07:30 +0100 |
| commit | 29582ed461b693368ec56567c2e40cfa20ef4bf5 (patch) | |
| tree | 04721b833e8e6108c2e13c4f0ea9f4dc7b2ae946 /MediaBrowser.Providers | |
| parent | ca6d499680f9fbb369844a11eb0e0213b66bb00b (diff) | |
| parent | 3b6985986709473c69ba785460c702c6bbe3771d (diff) | |
Merge branch 'master' into issue15137
Diffstat (limited to 'MediaBrowser.Providers')
19 files changed, 804 insertions, 76 deletions
diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubImageProvider.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubImageProvider.cs new file mode 100644 index 0000000000..69cae77628 --- /dev/null +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubImageProvider.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.OpenPackagingFormat +{ + /// <summary> + /// Provides the primary image for EPUB items that have embedded covers. + /// </summary> + public class EpubImageProvider : IDynamicImageProvider + { + private readonly ILogger<EpubImageProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="EpubImageProvider"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{EpubImageProvider}"/> interface.</param> + public EpubImageProvider(ILogger<EpubImageProvider> logger) + { + _logger = logger; + } + + /// <inheritdoc /> + public string Name => "EPUB Metadata"; + + /// <inheritdoc /> + public bool Supports(BaseItem item) + { + return item is Book; + } + + /// <inheritdoc /> + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + { + yield return ImageType.Primary; + } + + /// <inheritdoc /> + public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken) + { + if (string.Equals(Path.GetExtension(item.Path), ".epub", StringComparison.OrdinalIgnoreCase)) + { + return GetFromZip(item, cancellationToken); + } + + return Task.FromResult(new DynamicImageResponse { HasImage = false }); + } + + private async Task<DynamicImageResponse> LoadCover(ZipArchive epub, XmlDocument opf, string opfRootDirectory, CancellationToken cancellationToken) + { + var utilities = new OpfReader<EpubImageProvider>(opf, _logger); + var coverReference = utilities.ReadCoverPath(opfRootDirectory); + if (coverReference == null) + { + return new DynamicImageResponse { HasImage = false }; + } + + var cover = coverReference.Value; + var coverFile = epub.GetEntry(cover.Path); + + if (coverFile == null) + { + return new DynamicImageResponse { HasImage = false }; + } + + var memoryStream = new MemoryStream(); + + var coverStream = await coverFile.OpenAsync(cancellationToken).ConfigureAwait(false); + await using (coverStream.ConfigureAwait(false)) + { + await coverStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); + } + + memoryStream.Position = 0; + + var response = new DynamicImageResponse { HasImage = true, Stream = memoryStream }; + response.SetFormatFromMimeType(cover.MimeType); + + return response; + } + + private async Task<DynamicImageResponse> GetFromZip(BaseItem item, CancellationToken cancellationToken) + { + using var epub = await ZipFile.OpenReadAsync(item.Path, cancellationToken).ConfigureAwait(false); + + var opfFilePath = EpubUtils.ReadContentFilePath(epub); + if (opfFilePath == null) + { + return new DynamicImageResponse { HasImage = false }; + } + + var opfRootDirectory = Path.GetDirectoryName(opfFilePath); + if (opfRootDirectory == null) + { + return new DynamicImageResponse { HasImage = false }; + } + + var opfFile = epub.GetEntry(opfFilePath); + if (opfFile == null) + { + return new DynamicImageResponse { HasImage = false }; + } + + using var opfStream = await opfFile.OpenAsync(cancellationToken).ConfigureAwait(false); + + var opfDocument = new XmlDocument(); + opfDocument.Load(opfStream); + + return await LoadCover(epub, opfDocument, opfRootDirectory, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubProvider.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubProvider.cs new file mode 100644 index 0000000000..bc77e5928d --- /dev/null +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubProvider.cs @@ -0,0 +1,100 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.OpenPackagingFormat +{ + /// <summary> + /// Provides book metadata from OPF content in an EPUB item. + /// </summary> + public class EpubProvider : ILocalMetadataProvider<Book> + { + private readonly IFileSystem _fileSystem; + private readonly ILogger<EpubProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="EpubProvider"/> class. + /// </summary> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{EpubProvider}"/> interface.</param> + public EpubProvider(IFileSystem fileSystem, ILogger<EpubProvider> logger) + { + _fileSystem = fileSystem; + _logger = logger; + } + + /// <inheritdoc /> + public string Name => "EPUB Metadata"; + + /// <inheritdoc /> + public Task<MetadataResult<Book>> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + var path = GetEpubFile(info.Path)?.FullName; + + if (path is null) + { + return Task.FromResult(new MetadataResult<Book> { HasMetadata = false }); + } + + var result = ReadEpubAsZip(path, cancellationToken); + + if (result is null) + { + return Task.FromResult(new MetadataResult<Book> { HasMetadata = false }); + } + else + { + return Task.FromResult(result); + } + } + + private FileSystemMetadata? GetEpubFile(string path) + { + var fileInfo = _fileSystem.GetFileSystemInfo(path); + + if (fileInfo.IsDirectory) + { + return null; + } + + if (!string.Equals(Path.GetExtension(fileInfo.FullName), ".epub", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return fileInfo; + } + + private MetadataResult<Book>? ReadEpubAsZip(string path, CancellationToken cancellationToken) + { + using var epub = ZipFile.OpenRead(path); + + var opfFilePath = EpubUtils.ReadContentFilePath(epub); + if (opfFilePath == null) + { + return null; + } + + var opf = epub.GetEntry(opfFilePath); + if (opf == null) + { + return null; + } + + using var opfStream = opf.Open(); + + var opfDocument = new XmlDocument(); + opfDocument.Load(opfStream); + + var utilities = new OpfReader<EpubProvider>(opfDocument, _logger); + return utilities.ReadOpfData(cancellationToken); + } + } +} diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubUtils.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubUtils.cs new file mode 100644 index 0000000000..e5d2987312 --- /dev/null +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubUtils.cs @@ -0,0 +1,35 @@ +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Xml.Linq; + +namespace MediaBrowser.Providers.Books.OpenPackagingFormat +{ + /// <summary> + /// Utilities for EPUB files. + /// </summary> + public static class EpubUtils + { + /// <summary> + /// Attempt to read content from ZIP archive. + /// </summary> + /// <param name="epub">The ZIP archive.</param> + /// <returns>The content file path.</returns> + public static string? ReadContentFilePath(ZipArchive epub) + { + var container = epub.GetEntry(Path.Combine("META-INF", "container.xml")); + if (container == null) + { + return null; + } + + using var containerStream = container.Open(); + + XNamespace containerNamespace = "urn:oasis:names:tc:opendocument:xmlns:container"; + var containerDocument = XDocument.Load(containerStream); + var element = containerDocument.Descendants(containerNamespace + "rootfile").FirstOrDefault(); + + return element?.Attribute("full-path")?.Value; + } + } +} diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfProvider.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfProvider.cs new file mode 100644 index 0000000000..6e678802c1 --- /dev/null +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfProvider.cs @@ -0,0 +1,94 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.OpenPackagingFormat +{ + /// <summary> + /// Provides metadata for book items that have an OPF file in the same directory. Supports the standard + /// content.opf filename, bespoke metadata.opf name from Calibre libraries, and OPF files that have the + /// same name as their respective books for directories with several books. + /// </summary> + public class OpfProvider : ILocalMetadataProvider<Book>, IHasItemChangeMonitor + { + private const string StandardOpfFile = "content.opf"; + private const string CalibreOpfFile = "metadata.opf"; + + private readonly IFileSystem _fileSystem; + + private readonly ILogger<OpfProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="OpfProvider"/> class. + /// </summary> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{OpfProvider}"/> interface.</param> + public OpfProvider(IFileSystem fileSystem, ILogger<OpfProvider> logger) + { + _fileSystem = fileSystem; + _logger = logger; + } + + /// <inheritdoc /> + public string Name => "Open Packaging Format"; + + /// <inheritdoc /> + public bool HasChanged(BaseItem item, IDirectoryService directoryService) + { + var file = GetXmlFile(item.Path); + + return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved; + } + + /// <inheritdoc /> + public Task<MetadataResult<Book>> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + var path = GetXmlFile(info.Path).FullName; + + try + { + return Task.FromResult(ReadOpfData(path, cancellationToken)); + } + catch (FileNotFoundException) + { + return Task.FromResult(new MetadataResult<Book> { HasMetadata = false }); + } + } + + private FileSystemMetadata GetXmlFile(string path) + { + var fileInfo = _fileSystem.GetFileSystemInfo(path); + var directoryInfo = fileInfo.IsDirectory ? fileInfo : _fileSystem.GetDirectoryInfo(Path.GetDirectoryName(path)!); + + // check for OPF with matching name first since it's the most specific filename + var specificFile = Path.Combine(directoryInfo.FullName, Path.GetFileNameWithoutExtension(path) + ".opf"); + var file = _fileSystem.GetFileInfo(specificFile); + + if (file.Exists) + { + return file; + } + + file = _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, StandardOpfFile)); + + // check metadata.opf last since it's really only used by Calibre + return file.Exists ? file : _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, CalibreOpfFile)); + } + + private MetadataResult<Book> ReadOpfData(string file, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var doc = new XmlDocument(); + doc.Load(file); + + var utilities = new OpfReader<OpfProvider>(doc, _logger); + return utilities.ReadOpfData(cancellationToken); + } + } +} diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs new file mode 100644 index 0000000000..5d202c59e1 --- /dev/null +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs @@ -0,0 +1,329 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Xml; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Net; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.OpenPackagingFormat +{ + /// <summary> + /// Methods used to pull metadata and other information from Open Packaging Format in XML objects. + /// </summary> + /// <typeparam name="TCategoryName">The type of category.</typeparam> + public class OpfReader<TCategoryName> + { + private const string DcNamespace = @"http://purl.org/dc/elements/1.1/"; + private const string OpfNamespace = @"http://www.idpf.org/2007/opf"; + + private readonly XmlNamespaceManager _namespaceManager; + private readonly XmlDocument _document; + + private readonly ILogger<TCategoryName> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="OpfReader{TCategoryName}"/> class. + /// </summary> + /// <param name="document">The XML document to parse.</param> + /// <param name="logger">Instance of the <see cref="ILogger{TCategoryName}"/> interface.</param> + public OpfReader(XmlDocument document, ILogger<TCategoryName> logger) + { + _document = document; + _logger = logger; + _namespaceManager = new XmlNamespaceManager(_document.NameTable); + + _namespaceManager.AddNamespace("dc", DcNamespace); + _namespaceManager.AddNamespace("opf", OpfNamespace); + } + + /// <summary> + /// Checks for the existence of a cover image. + /// </summary> + /// <param name="opfRootDirectory">The root directory in which the OPF file is located.</param> + /// <returns>Returns the found cover and its type or null.</returns> + public (string MimeType, string Path)? ReadCoverPath(string opfRootDirectory) + { + var coverImage = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@properties='cover-image']"); + if (coverImage is not null) + { + return coverImage; + } + + var coverId = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@id='cover' and @media-type='image/*']"); + if (coverId is not null) + { + return coverId; + } + + var coverImageId = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@id='*cover-image']"); + if (coverImageId is not null) + { + return coverImageId; + } + + var metaCoverImage = _document.SelectSingleNode("//opf:meta[@name='cover']", _namespaceManager); + var content = metaCoverImage?.Attributes?["content"]?.Value; + if (string.IsNullOrEmpty(content) || metaCoverImage is null) + { + return null; + } + + var coverPath = Path.Combine("Images", content); + var coverFileManifest = _document.SelectSingleNode($"//opf:item[@href='{coverPath}']", _namespaceManager); + var mediaType = coverFileManifest?.Attributes?["media-type"]?.Value; + if (coverFileManifest?.Attributes is not null && !string.IsNullOrEmpty(mediaType) && IsValidImage(mediaType)) + { + return (mediaType, Path.Combine(opfRootDirectory, coverPath)); + } + + var coverFileIdManifest = _document.SelectSingleNode($"//opf:item[@id='{content}']", _namespaceManager); + if (coverFileIdManifest is not null) + { + return ReadManifestItem(coverFileIdManifest, opfRootDirectory); + } + + return null; + } + + /// <summary> + /// Read all supported OPF data from the file. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The metadata result to update.</returns> + public MetadataResult<Book> ReadOpfData(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var book = CreateBookFromOpf(); + var result = new MetadataResult<Book> { Item = book, HasMetadata = true }; + + FindAuthors(result); + ReadStringInto("//dc:language", language => result.ResultLanguage = language); + + return result; + } + + private Book CreateBookFromOpf() + { + var book = new Book + { + Name = FindMainTitle(), + ForcedSortName = FindSortTitle(), + }; + + ReadStringInto("//dc:description", summary => book.Overview = summary); + ReadStringInto("//dc:publisher", publisher => book.AddStudio(publisher)); + ReadStringInto("//dc:identifier[@opf:scheme='AMAZON']", amazon => book.SetProviderId("Amazon", amazon)); + ReadStringInto("//dc:identifier[@opf:scheme='GOOGLE']", google => book.SetProviderId("GoogleBooks", google)); + ReadStringInto("//dc:identifier[@opf:scheme='ISBN']", isbn => book.SetProviderId("ISBN", isbn)); + + ReadStringInto("//dc:date", date => + { + if (DateTime.TryParse(date, out var dateValue)) + { + book.PremiereDate = dateValue.Date; + book.ProductionYear = dateValue.Date.Year; + } + }); + + var genreNodes = _document.SelectNodes("//dc:subject", _namespaceManager); + + if (genreNodes?.Count > 0) + { + foreach (var node in genreNodes.Cast<XmlNode>().Where(node => !string.IsNullOrEmpty(node.InnerText) && !book.Genres.Contains(node.InnerText))) + { + // specification has no rules about content and some books combine every genre into a single element + foreach (var item in node.InnerText.Split(["/", "&", ",", ";", " - "], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + book.AddGenre(item); + } + } + } + + ReadInt32AttributeInto("//opf:meta[@name='calibre:series_index']", index => book.IndexNumber = index); + ReadInt32AttributeInto("//opf:meta[@name='calibre:rating']", rating => book.CommunityRating = rating); + + var seriesNameNode = _document.SelectSingleNode("//opf:meta[@name='calibre:series']", _namespaceManager); + + if (!string.IsNullOrEmpty(seriesNameNode?.Attributes?["content"]?.Value)) + { + try + { + book.SeriesName = seriesNameNode.Attributes["content"]?.Value; + } + catch (Exception) + { + _logger.LogError("error parsing Calibre series name"); + } + } + + return book; + } + + private string FindMainTitle() + { + var title = string.Empty; + var titleTypes = _document.SelectNodes("//opf:meta[@property='title-type']", _namespaceManager); + + if (titleTypes is not null && titleTypes.Count > 0) + { + foreach (XmlElement titleNode in titleTypes) + { + string refines = titleNode.GetAttribute("refines").TrimStart('#'); + string titleType = titleNode.InnerText; + + var titleElement = _document.SelectSingleNode($"//dc:title[@id='{refines}']", _namespaceManager); + if (titleElement is not null && string.Equals(titleType, "main", StringComparison.OrdinalIgnoreCase)) + { + title = titleElement.InnerText; + } + } + } + + // fallback in case there is no main title definition + if (string.IsNullOrEmpty(title)) + { + ReadStringInto("//dc:title", titleString => title = titleString); + } + + return title; + } + + private string? FindSortTitle() + { + var titleTypes = _document.SelectNodes("//opf:meta[@property='file-as']", _namespaceManager); + + if (titleTypes is not null && titleTypes.Count > 0) + { + foreach (XmlElement titleNode in titleTypes) + { + string refines = titleNode.GetAttribute("refines").TrimStart('#'); + string sortTitle = titleNode.InnerText; + + var titleElement = _document.SelectSingleNode($"//dc:title[@id='{refines}']", _namespaceManager); + if (titleElement is not null) + { + return sortTitle; + } + } + } + + // search for OPF 2.0 style title_sort node + var resultElement = _document.SelectSingleNode("//opf:meta[@name='calibre:title_sort']", _namespaceManager); + var titleSort = resultElement?.Attributes?["content"]?.Value; + + return titleSort; + } + + private void FindAuthors(MetadataResult<Book> book) + { + var resultElement = _document.SelectNodes("//dc:creator", _namespaceManager); + + if (resultElement != null && resultElement.Count > 0) + { + foreach (XmlElement creator in resultElement) + { + var creatorName = creator.InnerText; + var role = creator.GetAttribute("opf:role"); + var person = new PersonInfo { Name = creatorName, Type = GetRole(role) }; + + book.AddPerson(person); + } + } + } + + private PersonKind GetRole(string? role) + { + switch (role) + { + case "arr": + return PersonKind.Arranger; + case "art": + return PersonKind.Artist; + case "aut": + case "aqt": + case "aft": + case "aui": + default: + return PersonKind.Author; + case "edt": + return PersonKind.Editor; + case "ill": + return PersonKind.Illustrator; + case "lyr": + return PersonKind.Lyricist; + case "mus": + return PersonKind.AlbumArtist; + case "oth": + return PersonKind.Unknown; + case "trl": + return PersonKind.Translator; + } + } + + private void ReadStringInto(string xmlPath, Action<string> commitResult) + { + var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager); + if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.InnerText)) + { + commitResult(resultElement.InnerText); + } + } + + private void ReadInt32AttributeInto(string xmlPath, Action<int> commitResult) + { + var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager); + var resultValue = resultElement?.Attributes?["content"]?.Value; + + if (!string.IsNullOrEmpty(resultValue)) + { + try + { + commitResult(Convert.ToInt32(Convert.ToDouble(resultValue, CultureInfo.InvariantCulture))); + } + catch (Exception e) + { + _logger.LogError(e, "error converting to Int32"); + } + } + } + + private (string MimeType, string Path)? ReadEpubCoverInto(string opfRootDirectory, string xmlPath) + { + var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager); + + if (resultElement is not null) + { + return ReadManifestItem(resultElement, opfRootDirectory); + } + + return null; + } + + private (string MimeType, string Path)? ReadManifestItem(XmlNode manifestNode, string opfRootDirectory) + { + var href = manifestNode.Attributes?["href"]?.Value; + var mediaType = manifestNode.Attributes?["media-type"]?.Value; + + if (string.IsNullOrEmpty(href) || string.IsNullOrEmpty(mediaType) || !IsValidImage(mediaType)) + { + return null; + } + + var coverPath = Path.Combine(opfRootDirectory, href); + + return (MimeType: mediaType, Path: coverPath); + } + + private static bool IsValidImage(string? mimeType) + { + return !string.IsNullOrEmpty(mimeType) && !string.IsNullOrWhiteSpace(MimeTypes.ToExtension(mimeType)); + } + } +} diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index 75882a088a..e0354dbdfa 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -88,7 +88,15 @@ namespace MediaBrowser.Providers.Manager } } - singular.AddRange(item.GetImages(ImageType.Backdrop)); + foreach (var backdrop in item.GetImages(ImageType.Backdrop)) + { + var imageInMetadataFolder = backdrop.Path.StartsWith(itemMetadataPath, StringComparison.OrdinalIgnoreCase); + if (imageInMetadataFolder || canDeleteLocal || item.IsSaveLocalMetadataEnabled()) + { + singular.Add(backdrop); + } + } + PruneImages(item, singular); return singular.Count > 0; @@ -466,10 +474,36 @@ namespace MediaBrowser.Providers.Manager } } - if (UpdateMultiImages(item, images, ImageType.Backdrop)) + bool hasBackdrop = false; + bool backdropStoredWithMedia = false; + + foreach (var image in images) { - changed = true; - foundImageTypes.Add(ImageType.Backdrop); + if (image.Type != ImageType.Backdrop) + { + continue; + } + + hasBackdrop = true; + + if (item.ContainingFolderPath is not null && item.ContainingFolderPath.Contains(Path.GetDirectoryName(image.FileInfo.FullName), StringComparison.OrdinalIgnoreCase)) + { + backdropStoredWithMedia = true; + break; + } + } + + if (hasBackdrop) + { + if (UpdateMultiImages(item, images, ImageType.Backdrop)) + { + changed = true; + } + + if (backdropStoredWithMedia) + { + foundImageTypes.Add(ImageType.Backdrop); + } } if (foundImageTypes.Count > 0) diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 1d83263c5e..e9cb46eab5 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -151,7 +151,10 @@ namespace MediaBrowser.Providers.Manager .ConfigureAwait(false); updateType |= beforeSaveResult; - updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, metadataResult, cancellationToken).ConfigureAwait(false); + if (isFirstRefresh) + { + await SaveItemAsync(metadataResult, ItemUpdateType.MetadataImport, false, cancellationToken).ConfigureAwait(false); + } // Next run metadata providers if (refreshOptions.MetadataRefreshMode != MetadataRefreshMode.None) @@ -229,6 +232,11 @@ namespace MediaBrowser.Providers.Manager if (file is not null) { item.DateModified = file.LastWriteTimeUtc; + + if (!file.IsDirectory) + { + item.Size = file.Length; + } } } @@ -239,7 +247,7 @@ namespace MediaBrowser.Providers.Manager } // Save to database - await SaveItemAsync(metadataResult, updateType, cancellationToken).ConfigureAwait(false); + await SaveItemAsync(metadataResult, updateType, isFirstRefresh, cancellationToken).ConfigureAwait(false); } return updateType; @@ -267,9 +275,14 @@ namespace MediaBrowser.Providers.Manager } } - protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, CancellationToken cancellationToken) + protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, bool reattachUserData, CancellationToken cancellationToken) { await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false); + if (reattachUserData) + { + await result.Item.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false); + } + if (result.Item.SupportsPeople && result.People is not null) { var baseItem = result.Item; @@ -312,12 +325,8 @@ namespace MediaBrowser.Providers.Manager { if (EnableUpdateMetadataFromChildren(item, isFullRefresh, updateType)) { - if (isFullRefresh || updateType > ItemUpdateType.None) - { - var children = GetChildrenForMetadataUpdates(item); - - updateType = UpdateMetadataFromChildren(item, children, isFullRefresh, updateType); - } + var children = GetChildrenForMetadataUpdates(item); + updateType = UpdateMetadataFromChildren(item, children, isFullRefresh, updateType); } var presentationUniqueKey = item.CreatePresentationUniqueKey(); @@ -339,7 +348,10 @@ namespace MediaBrowser.Providers.Manager item.DateModified = info.LastWriteTimeUtc; if (ServerConfigurationManager.GetMetadataConfiguration().UseFileCreationTimeForDateAdded) { - item.DateCreated = info.CreationTimeUtc; + if (info.CreationTimeUtc > DateTime.MinValue) + { + item.DateCreated = info.CreationTimeUtc; + } } if (item is Video video) @@ -357,16 +369,24 @@ namespace MediaBrowser.Providers.Manager protected virtual bool EnableUpdateMetadataFromChildren(TItemType item, bool isFullRefresh, ItemUpdateType currentUpdateType) { - if (isFullRefresh || currentUpdateType > ItemUpdateType.None) + if (item is Folder folder) { - if (EnableUpdatingPremiereDateFromChildren || EnableUpdatingGenresFromChildren || EnableUpdatingStudiosFromChildren || EnableUpdatingOfficialRatingFromChildren) + if (!isFullRefresh && currentUpdateType == ItemUpdateType.None) { - return true; + return folder.SupportsDateLastMediaAdded; } - if (item is Folder folder) + if (isFullRefresh || currentUpdateType > ItemUpdateType.None) { - return folder.SupportsDateLastMediaAdded || folder.SupportsCumulativeRunTimeTicks; + if (EnableUpdatingPremiereDateFromChildren || EnableUpdatingGenresFromChildren || EnableUpdatingStudiosFromChildren || EnableUpdatingOfficialRatingFromChildren) + { + return true; + } + + if (folder.SupportsDateLastMediaAdded || folder.SupportsCumulativeRunTimeTicks) + { + return true; + } } } @@ -387,36 +407,42 @@ namespace MediaBrowser.Providers.Manager { var updateType = ItemUpdateType.None; - if (isFullRefresh || currentUpdateType > ItemUpdateType.None) + if (item is Folder folder) { - updateType |= UpdateCumulativeRunTimeTicks(item, children); - updateType |= UpdateDateLastMediaAdded(item, children); - - // don't update user-changeable metadata for locked items - if (item.IsLocked) + if (folder.SupportsDateLastMediaAdded) { - return updateType; + updateType |= UpdateDateLastMediaAdded(item, children); } - if (EnableUpdatingPremiereDateFromChildren) + if ((isFullRefresh || currentUpdateType > ItemUpdateType.None) && folder.SupportsCumulativeRunTimeTicks) { - updateType |= UpdatePremiereDate(item, children); + updateType |= UpdateCumulativeRunTimeTicks(item, children); } + } - if (EnableUpdatingGenresFromChildren) - { - updateType |= UpdateGenres(item, children); - } + if (!(isFullRefresh || currentUpdateType > ItemUpdateType.None) || item.IsLocked) + { + return updateType; + } - if (EnableUpdatingStudiosFromChildren) - { - updateType |= UpdateStudios(item, children); - } + if (EnableUpdatingPremiereDateFromChildren) + { + updateType |= UpdatePremiereDate(item, children); + } - if (EnableUpdatingOfficialRatingFromChildren) - { - updateType |= UpdateOfficialRating(item, children); - } + if (EnableUpdatingGenresFromChildren) + { + updateType |= UpdateGenres(item, children); + } + + if (EnableUpdatingStudiosFromChildren) + { + updateType |= UpdateStudios(item, children); + } + + if (EnableUpdatingOfficialRatingFromChildren) + { + updateType |= UpdateOfficialRating(item, children); } return updateType; diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 43f0746ba7..f8e2aece1f 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -721,8 +721,6 @@ namespace MediaBrowser.Providers.Manager } } } - - _libraryManager.CreateItem(item, null); } /// <summary> diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 34b3104b0b..ed0c63b97f 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -18,7 +18,6 @@ <PackageReference Include="AsyncKeyedLock" /> <PackageReference Include="LrcParser" /> <PackageReference Include="MetaBrainz.MusicBrainz" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Http" /> <PackageReference Include="Newtonsoft.Json" /> @@ -28,7 +27,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index bdb6b93beb..bde23e842f 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -520,7 +520,7 @@ namespace MediaBrowser.Providers.MediaInfo { Name = person.Name, Type = person.Type, - Role = person.Role.Trim() + Role = person.Role?.Trim() }); } } diff --git a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs index 8df15e4408..e0a4c4f320 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs @@ -72,7 +72,7 @@ public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo> } else { - targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray(); + targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray(); } if (replaceData || targetItem.Shares.Count == 0) diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index c35324746a..88c8e4f7c9 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions; @@ -83,7 +84,9 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu if (!string.IsNullOrEmpty(releaseGroupId)) { var releaseGroupResult = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.Releases, null, cancellationToken).ConfigureAwait(false); - return GetReleaseGroupResult(releaseGroupResult.Releases); + + // No need to pass the cancellation token to GetReleaseGroupResultAsync as we're already passing it to ToBlockingEnumerable + return GetReleaseGroupResultAsync(releaseGroupResult.Releases, CancellationToken.None).ToBlockingEnumerable(cancellationToken); } var artistMusicBrainzId = searchInfo.GetMusicBrainzArtistId(); @@ -128,7 +131,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu } } - private IEnumerable<RemoteSearchResult> GetReleaseGroupResult(IEnumerable<IRelease>? releaseSearchResults) + private async IAsyncEnumerable<RemoteSearchResult> GetReleaseGroupResultAsync(IEnumerable<IRelease>? releaseSearchResults, [EnumeratorCancellation] CancellationToken cancellationToken = default) { if (releaseSearchResults is null) { @@ -138,7 +141,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu foreach (var result in releaseSearchResults) { // Fetch full release info, otherwise artists are missing - var fullResult = _musicBrainzQuery.LookupRelease(result.Id, Include.Artists | Include.ReleaseGroups); + var fullResult = await _musicBrainzQuery.LookupReleaseAsync(result.Id, Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); yield return GetReleaseResult(fullResult); } } diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index ad9edb031c..82c6e3011a 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -138,6 +138,8 @@ namespace MediaBrowser.Providers.Plugins.Omdb } var item = itemResult.Item; + item.IndexNumber = episodeNumber; + item.ParentIndexNumber = seasonNumber; var seasonResult = await GetSeasonRootObject(seriesImdbId, seasonNumber, cancellationToken).ConfigureAwait(false); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index 414a0a3c9b..2beb34e43b 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -303,9 +303,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies CrewMember = crewMember, PersonType = TmdbUtils.MapCrewToPersonType(crewMember) }) - .Where(entry => - TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) || - TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)); + .Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType)); if (config.HideMissingCrewMembers) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index 0953dde1ce..f0e159f098 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -177,8 +177,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var item = new Episode { - IndexNumber = info.IndexNumber, - ParentIndexNumber = info.ParentIndexNumber, + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, IndexNumberEnd = info.IndexNumberEnd, Name = episodeResult.Name, PremiereDate = episodeResult.AirDate, @@ -275,9 +275,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV CrewMember = crewMember, PersonType = TmdbUtils.MapCrewToPersonType(crewMember) }) - .Where(entry => - TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) || - TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)); + .Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType)); if (config.HideMissingCrewMembers) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 1b429039e7..0905a3bdcb 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -120,9 +120,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV CrewMember = crewMember, PersonType = TmdbUtils.MapCrewToPersonType(crewMember) }) - .Where(entry => - TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) || - TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)); + .Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType)); if (config.HideMissingCrewMembers) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index f0828e8263..82d4e58384 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -367,9 +367,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV CrewMember = crewMember, PersonType = TmdbUtils.MapCrewToPersonType(crewMember) }) - .Where(entry => - TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) || - TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)); + .Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType)); if (config.HideMissingCrewMembers) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index fedf345988..abaca65ff3 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -518,7 +518,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return null; } - return _tmDbClient.GetImageUrl(size, path, true).ToString(); + // Use "original" as default size if size is null or empty to prevent malformed URLs + var imageSize = string.IsNullOrEmpty(size) ? "original" : size; + + return _tmDbClient.GetImageUrl(imageSize, path, true).ToString(); } /// <summary> diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index f5e59a2789..0944b557e9 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -69,19 +69,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <returns>The Jellyfin person type.</returns> public static PersonKind MapCrewToPersonType(Crew crew) { - if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase) - && crew.Job.Contains("director", StringComparison.OrdinalIgnoreCase)) + if (crew.Department.Equals("directing", StringComparison.OrdinalIgnoreCase) + && crew.Job.Equals("director", StringComparison.OrdinalIgnoreCase)) { return PersonKind.Director; } if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase) - && crew.Job.Contains("producer", StringComparison.OrdinalIgnoreCase)) + && crew.Job.Equals("producer", StringComparison.OrdinalIgnoreCase)) { return PersonKind.Producer; } - if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase)) + if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase) + && (crew.Job.Equals("writer", StringComparison.OrdinalIgnoreCase) || crew.Job.Equals("screenplay", StringComparison.OrdinalIgnoreCase))) { return PersonKind.Writer; } @@ -116,14 +117,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb preferredLanguage = NormalizeLanguage(preferredLanguage, countryCode); languages.Add(preferredLanguage); - - if (preferredLanguage.Length == 5) // Like en-US - { - // Currently, TMDb supports 2-letter language codes only. - // They are planning to change this in the future, thus we're - // supplying both codes if we're having a 5-letter code. - languages.Add(preferredLanguage.Substring(0, 2)); - } } languages.Add("null"); |
