diff options
Diffstat (limited to 'MediaBrowser.Providers')
35 files changed, 1153 insertions, 186 deletions
diff --git a/MediaBrowser.Providers/Books/Isbn/ISBNExternalId.cs b/MediaBrowser.Providers/Books/Isbn/ISBNExternalId.cs new file mode 100644 index 0000000000..a86275d5ae --- /dev/null +++ b/MediaBrowser.Providers/Books/Isbn/ISBNExternalId.cs @@ -0,0 +1,23 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace MediaBrowser.Providers.Books.Isbn +{ + /// <inheritdoc /> + public class IsbnExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "ISBN"; + + /// <inheritdoc /> + public string Key => "ISBN"; + + /// <inheritdoc /> + public ExternalIdMediaType? Type => null; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Book; + } +} diff --git a/MediaBrowser.Providers/Books/Isbn/ISBNExternalUrlProvider.cs b/MediaBrowser.Providers/Books/Isbn/ISBNExternalUrlProvider.cs new file mode 100644 index 0000000000..9d7b1ff208 --- /dev/null +++ b/MediaBrowser.Providers/Books/Isbn/ISBNExternalUrlProvider.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Books.Isbn; + +/// <inheritdoc/> +public class IsbnExternalUrlProvider : IExternalUrlProvider +{ + /// <inheritdoc/> + public string Name => "ISBN"; + + /// <inheritdoc /> + public IEnumerable<string> GetExternalUrls(BaseItem item) + { + if (item.TryGetProviderId("ISBN", out var externalId)) + { + if (item is Book) + { + yield return $"https://search.worldcat.org/search?q=bn:{externalId}"; + } + } + } +} 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 e0354dbdfa..727f481b65 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -255,7 +255,7 @@ namespace MediaBrowser.Providers.Manager catch (Exception ex) { result.ErrorMessage = ex.Message; - _logger.LogError(ex, "Error in {Provider}", provider.Name); + _logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, item.Path ?? item.Name); } } @@ -339,7 +339,7 @@ namespace MediaBrowser.Providers.Manager catch (Exception ex) { result.ErrorMessage = ex.Message; - _logger.LogError(ex, "Error in {Provider}", provider.Name); + _logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, item.Path ?? item.Name); } } diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index a2102ca9cd..abdfb1e3b7 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -153,7 +153,7 @@ namespace MediaBrowser.Providers.Manager if (isFirstRefresh) { - await SaveItemAsync(metadataResult, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + await SaveItemAsync(metadataResult, ItemUpdateType.MetadataImport, false, cancellationToken).ConfigureAwait(false); } // Next run metadata providers @@ -247,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; @@ -275,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; @@ -815,7 +820,7 @@ namespace MediaBrowser.Providers.Manager } catch (Exception ex) { - Logger.LogError(ex, "Error in {Provider}", provider.Name); + Logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, logName); // If a local provider fails, consider that a failure refreshResult.ErrorMessage = ex.Message; @@ -881,7 +886,7 @@ namespace MediaBrowser.Providers.Manager catch (Exception ex) { refreshResult.ErrorMessage = ex.Message; - Logger.LogError(ex, "Error in {Provider}", provider.Name); + Logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, logName); } } @@ -930,7 +935,7 @@ namespace MediaBrowser.Providers.Manager { refreshResult.Failures++; refreshResult.ErrorMessage = ex.Message; - Logger.LogError(ex, "Error in {Provider}", provider.Name); + Logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, logName); } } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index f8e2aece1f..0bab73180f 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -487,6 +487,13 @@ namespace MediaBrowser.Providers.Manager return true; } + // Artists without a folder structure that are derived from metadata have no real path in the library, + // so GetLibraryOptions returns null. Allow all providers through rather than blocking them. + if (item is MusicArtist && libraryTypeOptions is null) + { + return true; + } + return _baseItemManager.IsMetadataFetcherEnabled(item, libraryTypeOptions, provider.Name); } 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 bde23e842f..fdc2f36469 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -8,7 +8,6 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Extensions; -using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -25,7 +24,6 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.MediaInfo @@ -74,7 +72,6 @@ namespace MediaBrowser.Providers.MediaInfo _subtitleResolver = subtitleResolver; _mediaAttachmentRepository = mediaAttachmentRepository; _mediaStreamRepository = mediaStreamRepository; - _mediaStreamRepository = mediaStreamRepository; } public async Task<ItemUpdateType> ProbeVideo<T>( @@ -366,6 +363,8 @@ namespace MediaBrowser.Providers.MediaInfo blurayVideoStream.ColorSpace = ffmpegVideoStream.ColorSpace; blurayVideoStream.ColorTransfer = ffmpegVideoStream.ColorTransfer; blurayVideoStream.ColorPrimaries = ffmpegVideoStream.ColorPrimaries; + blurayVideoStream.BitDepth = ffmpegVideoStream.BitDepth; + blurayVideoStream.PixelFormat = ffmpegVideoStream.PixelFormat; } } @@ -549,47 +548,19 @@ namespace MediaBrowser.Providers.MediaInfo var enableSubtitleDownloading = options.MetadataRefreshMode == MetadataRefreshMode.Default || options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh; - var subtitleOptions = _config.GetConfiguration<SubtitleOptions>("subtitles"); - var libraryOptions = _libraryManager.GetLibraryOptions(video); - string[] subtitleDownloadLanguages; - bool skipIfEmbeddedSubtitlesPresent; - bool skipIfAudioTrackMatches; - bool requirePerfectMatch; - bool enabled; - - if (libraryOptions.SubtitleDownloadLanguages is null) - { - subtitleDownloadLanguages = subtitleOptions.DownloadLanguages; - skipIfEmbeddedSubtitlesPresent = subtitleOptions.SkipIfEmbeddedSubtitlesPresent; - skipIfAudioTrackMatches = subtitleOptions.SkipIfAudioTrackMatches; - requirePerfectMatch = subtitleOptions.RequirePerfectMatch; - enabled = (subtitleOptions.DownloadEpisodeSubtitles && - video is Episode) || - (subtitleOptions.DownloadMovieSubtitles && - video is Movie); - } - else - { - subtitleDownloadLanguages = libraryOptions.SubtitleDownloadLanguages; - skipIfEmbeddedSubtitlesPresent = libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent; - skipIfAudioTrackMatches = libraryOptions.SkipSubtitlesIfAudioTrackMatches; - requirePerfectMatch = libraryOptions.RequirePerfectSubtitleMatch; - enabled = true; - } - - if (enableSubtitleDownloading && enabled) + if (enableSubtitleDownloading && libraryOptions.SubtitleDownloadLanguages is not null) { var downloadedLanguages = await new SubtitleDownloader( _logger, _subtitleManager).DownloadSubtitles( video, currentStreams.Concat(externalSubtitleStreams).ToList(), - skipIfEmbeddedSubtitlesPresent, - skipIfAudioTrackMatches, - requirePerfectMatch, - subtitleDownloadLanguages, + libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent, + libraryOptions.SkipSubtitlesIfAudioTrackMatches, + libraryOptions.RequirePerfectSubtitleMatch, + libraryOptions.SubtitleDownloadLanguages, libraryOptions.DisabledSubtitleFetchers, libraryOptions.SubtitleFetcherOrder, true, diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index 9f5463b82c..c3ff26202f 100644 --- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs @@ -262,9 +262,28 @@ namespace MediaBrowser.Providers.MediaInfo private void FetchShortcutInfo(BaseItem item) { - item.ShortcutPath = File.ReadAllLines(item.Path) + var shortcutPath = File.ReadAllLines(item.Path) .Select(NormalizeStrmLine) .FirstOrDefault(i => !string.IsNullOrWhiteSpace(i) && !i.StartsWith('#')); + + if (string.IsNullOrWhiteSpace(shortcutPath)) + { + return; + } + + // Only allow remote URLs in .strm files to prevent local file access + if (Uri.TryCreate(shortcutPath, UriKind.Absolute, out var uri) + && (string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase) + || string.Equals(uri.Scheme, "rtsp", StringComparison.OrdinalIgnoreCase) + || string.Equals(uri.Scheme, "rtp", StringComparison.OrdinalIgnoreCase))) + { + item.ShortcutPath = shortcutPath; + } + else + { + _logger.LogWarning("Ignoring invalid or non-remote .strm path in {File}: {Path}", item.Path, shortcutPath); + } } /// <summary> diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs index 1134baf92d..7188e9804e 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs +++ b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs @@ -8,14 +8,12 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; -using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Subtitles; using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Providers; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -57,16 +55,9 @@ namespace MediaBrowser.Providers.MediaInfo public bool IsLogged => true; - private SubtitleOptions GetOptions() - { - return _config.GetConfiguration<SubtitleOptions>("subtitles"); - } - /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - var options = GetOptions(); - var types = new[] { BaseItemKind.Episode, BaseItemKind.Movie }; var dict = new Dictionary<Guid, BaseItem>(); @@ -81,17 +72,14 @@ namespace MediaBrowser.Providers.MediaInfo if (libraryOptions.SubtitleDownloadLanguages is null) { - subtitleDownloadLanguages = options.DownloadLanguages; - skipIfEmbeddedSubtitlesPresent = options.SkipIfEmbeddedSubtitlesPresent; - skipIfAudioTrackMatches = options.SkipIfAudioTrackMatches; - } - else - { - subtitleDownloadLanguages = libraryOptions.SubtitleDownloadLanguages; - skipIfEmbeddedSubtitlesPresent = libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent; - skipIfAudioTrackMatches = libraryOptions.SkipSubtitlesIfAudioTrackMatches; + // Skip this library if subtitle download languages are not configured + continue; } + subtitleDownloadLanguages = libraryOptions.SubtitleDownloadLanguages; + skipIfEmbeddedSubtitlesPresent = libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent; + skipIfAudioTrackMatches = libraryOptions.SkipSubtitlesIfAudioTrackMatches; + foreach (var lang in subtitleDownloadLanguages) { var query = new InternalItemsQuery @@ -144,7 +132,7 @@ namespace MediaBrowser.Providers.MediaInfo try { - await DownloadSubtitles(video as Video, options, cancellationToken).ConfigureAwait(false); + await DownloadSubtitles(video as Video, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -160,7 +148,7 @@ namespace MediaBrowser.Providers.MediaInfo } } - private async Task<bool> DownloadSubtitles(Video video, SubtitleOptions options, CancellationToken cancellationToken) + private async Task<bool> DownloadSubtitles(Video video, CancellationToken cancellationToken) { var mediaStreams = video.GetMediaStreams(); @@ -173,19 +161,15 @@ namespace MediaBrowser.Providers.MediaInfo if (libraryOptions.SubtitleDownloadLanguages is null) { - subtitleDownloadLanguages = options.DownloadLanguages; - skipIfEmbeddedSubtitlesPresent = options.SkipIfEmbeddedSubtitlesPresent; - skipIfAudioTrackMatches = options.SkipIfAudioTrackMatches; - requirePerfectMatch = options.RequirePerfectMatch; - } - else - { - subtitleDownloadLanguages = libraryOptions.SubtitleDownloadLanguages; - skipIfEmbeddedSubtitlesPresent = libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent; - skipIfAudioTrackMatches = libraryOptions.SkipSubtitlesIfAudioTrackMatches; - requirePerfectMatch = libraryOptions.RequirePerfectSubtitleMatch; + // Subtitle downloading is not configured for this library + return true; } + subtitleDownloadLanguages = libraryOptions.SubtitleDownloadLanguages; + skipIfEmbeddedSubtitlesPresent = libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent; + skipIfAudioTrackMatches = libraryOptions.SkipSubtitlesIfAudioTrackMatches; + requirePerfectMatch = libraryOptions.RequirePerfectSubtitleMatch; + var downloadedLanguages = await new SubtitleDownloader( _logger, _subtitleManager).DownloadSubtitles( diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs index 00bd96282c..d8cb6b4b24 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs @@ -125,7 +125,9 @@ namespace MediaBrowser.Providers.Plugins.AudioDb if (string.IsNullOrWhiteSpace(overview)) { - overview = result.strBiographyEN; + overview = string.IsNullOrWhiteSpace(result.strBiographyEN) + ? result.strBiography + : result.strBiographyEN; } item.Overview = (overview ?? string.Empty).StripHtml(); @@ -224,6 +226,8 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public string strTwitter { get; set; } + public string strBiography { get; set; } + public string strBiographyEN { get; set; } public string strBiographyDE { get; set; } diff --git a/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalId.cs b/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalId.cs new file mode 100644 index 0000000000..8cbd1f89a7 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalId.cs @@ -0,0 +1,23 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace MediaBrowser.Providers.Plugins.ComicVine +{ + /// <inheritdoc /> + public class ComicVineExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "Comic Vine"; + + /// <inheritdoc /> + public string Key => "ComicVine"; + + /// <inheritdoc /> + public ExternalIdMediaType? Type => null; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Book; + } +} diff --git a/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalUrlProvider.cs new file mode 100644 index 0000000000..9122399179 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalUrlProvider.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Plugins.ComicVine; + +/// <inheritdoc/> +public class ComicVineExternalUrlProvider : IExternalUrlProvider +{ + /// <inheritdoc/> + public string Name => "Comic Vine"; + + /// <inheritdoc /> + public IEnumerable<string> GetExternalUrls(BaseItem item) + { + if (item.TryGetProviderId("ComicVine", out var externalId)) + { + switch (item) + { + case Person: + case Book: + yield return $"https://comicvine.gamespot.com/{externalId}"; + break; + } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/ComicVine/ComicVinePersonExternalId.cs b/MediaBrowser.Providers/Plugins/ComicVine/ComicVinePersonExternalId.cs new file mode 100644 index 0000000000..26b8e11380 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ComicVine/ComicVinePersonExternalId.cs @@ -0,0 +1,23 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace MediaBrowser.Providers.Plugins.ComicVine +{ + /// <inheritdoc /> + public class ComicVinePersonExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "Comic Vine"; + + /// <inheritdoc /> + public string Key => "ComicVine"; + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Person; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Person; + } +} diff --git a/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalId.cs b/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalId.cs new file mode 100644 index 0000000000..02d3b36974 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalId.cs @@ -0,0 +1,23 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace MediaBrowser.Providers.Plugins.GoogleBooks +{ + /// <inheritdoc /> + public class GoogleBooksExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "Google Books"; + + /// <inheritdoc /> + public string Key => "GoogleBooks"; + + /// <inheritdoc /> + public ExternalIdMediaType? Type => null; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Book; + } +} diff --git a/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalUrlProvider.cs new file mode 100644 index 0000000000..95047ee83e --- /dev/null +++ b/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalUrlProvider.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Plugins.GoogleBooks; + +/// <inheritdoc/> +public class GoogleBooksExternalUrlProvider : IExternalUrlProvider +{ + /// <inheritdoc /> + public string Name => "Google Books"; + + /// <inheritdoc /> + public IEnumerable<string> GetExternalUrls(BaseItem item) + { + if (item.TryGetProviderId("GoogleBooks", out var externalId)) + { + if (item is Book) + { + yield return $"https://books.google.com/books?id={externalId}"; + } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs index 1323d2604a..9df21596c5 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs @@ -22,7 +22,7 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz; /// <summary> /// MusicBrainz artist provider. /// </summary> -public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IDisposable +public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IDisposable, IHasOrder { private readonly ILogger<MusicBrainzArtistProvider> _logger; private Query _musicBrainzQuery; @@ -42,6 +42,10 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar /// <inheritdoc /> public string Name => "MusicBrainz"; + /// <inheritdoc /> + /// Runs first to populate the MusicBrainz artist ID used by downstream providers. + public int Order => 0; + private void ReloadConfig(object? sender, BasePluginConfiguration e) { var configuration = (PluginConfiguration)e; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs b/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs index 450ee2a337..3eacc4f0f0 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs @@ -33,7 +33,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Api /// <returns>The image portion of the TMDb client configuration.</returns> [HttpGet("ClientConfiguration")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ConfigImageTypes> TmdbClientConfiguration() + public async Task<ConfigImageTypes?> TmdbClientConfiguration() { return (await _tmdbClientManager.GetClientConfiguration().ConfigureAwait(false)).Images; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs index 02818a0e24..78be5804e3 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs @@ -75,10 +75,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets var posters = collection.Images.Posters; var backdrops = collection.Images.Backdrops; - var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count); + var remoteImages = new List<RemoteImageInfo>(posters?.Count ?? 0 + backdrops?.Count ?? 0); - remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language)); - remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language)); + if (posters is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language)); + } + + if (backdrops is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language)); + } return remoteImages; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs index 34c9abae12..a7bba2d539 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs @@ -67,10 +67,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets result.SetProviderId(MetadataProvider.Tmdb, collection.Id.ToString(CultureInfo.InvariantCulture)); - return new[] { result }; + return [result]; } var collectionSearchResults = await _tmdbClientManager.SearchCollectionAsync(searchInfo.Name, language, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + if (collectionSearchResults is null) + { + return []; + } var collections = new RemoteSearchResult[collectionSearchResults.Count]; for (var i = 0; i < collectionSearchResults.Count; i++) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs index fcc3574107..714c57d361 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs @@ -79,7 +79,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (movieTmdbId <= 0) { - return Enumerable.Empty<RemoteImageInfo>(); + return []; } // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here @@ -89,17 +89,28 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (movie?.Images is null) { - return Enumerable.Empty<RemoteImageInfo>(); + return []; } var posters = movie.Images.Posters; var backdrops = movie.Images.Backdrops; var logos = movie.Images.Logos; - var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count + logos.Count); + var remoteImages = new List<RemoteImageInfo>(posters?.Count ?? 0 + backdrops?.Count ?? 0 + logos?.Count ?? 0); - remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language)); - remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language)); - remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language)); + if (posters is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language)); + } + + if (backdrops is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language)); + } + + if (logos is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language)); + } return remoteImages; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index 414a0a3c9b..ff584ba1de 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -15,6 +15,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using TMDbLib.Objects.Find; +using TMDbLib.Objects.General; using TMDbLib.Objects.Search; namespace MediaBrowser.Providers.Plugins.Tmdb.Movies @@ -84,7 +85,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies remoteResult.SetProviderId(MetadataProvider.Tmdb, movie.Id.ToString(CultureInfo.InvariantCulture)); remoteResult.TrySetProviderId(MetadataProvider.Imdb, movie.ImdbId); - return new[] { remoteResult }; + return [remoteResult]; } } @@ -118,6 +119,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies .ConfigureAwait(false); } + if (movieResults is null) + { + return []; + } + var len = movieResults.Count; var remoteSearchResults = new RemoteSearchResult[len]; for (var i = 0; i < len; i++) @@ -158,7 +164,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); - if (searchResults.Count > 0) + if (searchResults?.Count > 0) { tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture); } @@ -167,7 +173,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (string.IsNullOrEmpty(tmdbId) && !string.IsNullOrEmpty(imdbId)) { var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); - if (movieResultFromImdbId?.MovieResults.Count > 0) + if (movieResultFromImdbId?.MovieResults?.Count > 0) { tmdbId = movieResultFromImdbId.MovieResults[0].Id.ToString(CultureInfo.InvariantCulture); } @@ -193,7 +199,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies OriginalTitle = movieResult.OriginalTitle, Overview = movieResult.Overview?.Replace("\n\n", "\n", StringComparison.InvariantCulture), Tagline = movieResult.Tagline, - ProductionLocations = movieResult.ProductionCountries.Select(pc => pc.Name).ToArray() + ProductionLocations = movieResult.ProductionCountries?.Select(pc => pc.Name).ToArray() ?? Array.Empty<string>() }; var metadataResult = new MetadataResult<Movie> { @@ -218,14 +224,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var ourRelease = releases.FirstOrDefault(c => string.Equals(c.Iso_3166_1, info.MetadataCountryCode, StringComparison.OrdinalIgnoreCase)); - if (ourRelease is not null) + if (ourRelease?.Certification is not null) { - movie.OfficialRating = TmdbUtils.BuildParentalRating(ourRelease.Iso_3166_1, ourRelease.Certification); + movie.OfficialRating = TmdbUtils.BuildParentalRating(info.MetadataCountryCode, ourRelease.Certification); } else { var usRelease = releases.FirstOrDefault(c => string.Equals(c.Iso_3166_1, "US", StringComparison.OrdinalIgnoreCase)); - if (usRelease is not null) + if (usRelease?.Certification is not null) { movie.OfficialRating = usRelease.Certification; } @@ -242,16 +248,23 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var genres = movieResult.Genres; - foreach (var genre in genres.Select(g => g.Name).Trimmed()) + if (genres is not null) { - movie.AddGenre(genre); + foreach (var genre in genres.Select(g => g.Name).Trimmed()) + { + movie.AddGenre(genre); + } } if (movieResult.Keywords?.Keywords is not null) { - for (var i = 0; i < movieResult.Keywords.Keywords.Count; i++) + foreach (var keyword in movieResult.Keywords.Keywords) { - movie.AddTag(movieResult.Keywords.Keywords[i].Name); + var name = keyword.Name; + if (!string.IsNullOrWhiteSpace(name)) + { + movie.AddTag(name); + } } } @@ -303,9 +316,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/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs index 4b32d0f6bf..64ab98b262 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs @@ -56,13 +56,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People } result.SetProviderId(MetadataProvider.Tmdb, personResult.Id.ToString(CultureInfo.InvariantCulture)); - result.TrySetProviderId(MetadataProvider.Imdb, personResult.ExternalIds.ImdbId); + result.TrySetProviderId(MetadataProvider.Imdb, personResult.ExternalIds?.ImdbId); - return new[] { result }; + return [result]; } } var personSearchResult = await _tmdbClientManager.SearchPersonAsync(searchInfo.Name, cancellationToken).ConfigureAwait(false); + if (personSearchResult is null) + { + return []; + } var remoteSearchResults = new RemoteSearchResult[personSearchResult.Count]; for (var i = 0; i < personSearchResult.Count; i++) @@ -91,7 +95,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People if (personTmdbId <= 0) { var personSearchResults = await _tmdbClientManager.SearchPersonAsync(info.Name, cancellationToken).ConfigureAwait(false); - if (personSearchResults.Count > 0) + if (personSearchResults?.Count > 0) { personTmdbId = personSearchResults[0].Id; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index e30c555cb4..f0e159f098 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -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..1eb522137d 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -76,7 +76,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV result.Item.Name = seasonResult.Name; } - result.Item.TrySetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds.TvdbId); + result.Item.TrySetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds?.TvdbId); // TODO why was this disabled? var credits = seasonResult.Credits; @@ -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/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs index 5cba84dcb3..f2e7d0c6e4 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs @@ -79,11 +79,22 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var posters = series.Images.Posters; var backdrops = series.Images.Backdrops; var logos = series.Images.Logos; - var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count + logos.Count); + var remoteImages = new List<RemoteImageInfo>(posters?.Count ?? 0 + backdrops?.Count ?? 0 + logos?.Count ?? 0); - remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language)); - remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language)); - remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language)); + if (posters is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language)); + } + + if (backdrops is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language)); + } + + if (logos is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language)); + } return remoteImages; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index f0828e8263..7e36c1e204 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -112,6 +112,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var tvSearchResults = await _tmdbClientManager.SearchSeriesAsync(searchInfo.Name, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken: cancellationToken) .ConfigureAwait(false); + if (tvSearchResults is null) + { + return []; + } var remoteResults = new RemoteSearchResult[tvSearchResults.Count]; for (var i = 0; i < tvSearchResults.Count; i++) @@ -141,6 +145,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } remoteResult.PremiereDate = series.FirstAirDate?.ToUniversalTime(); + remoteResult.ProductionYear = series.FirstAirDate?.Year; return remoteResult; } @@ -157,6 +162,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV remoteResult.SetProviderId(MetadataProvider.Tmdb, series.Id.ToString(CultureInfo.InvariantCulture)); remoteResult.PremiereDate = series.FirstAirDate?.ToUniversalTime(); + remoteResult.ProductionYear = series.FirstAirDate?.Year; return remoteResult; } @@ -174,7 +180,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (string.IsNullOrEmpty(tmdbId) && info.TryGetProviderId(MetadataProvider.Imdb, out var imdbId)) { var searchResult = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); - if (searchResult?.TvResults.Count > 0) + if (searchResult?.TvResults?.Count > 0) { tmdbId = searchResult.TvResults[0].Id.ToString(CultureInfo.InvariantCulture); } @@ -183,7 +189,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (string.IsNullOrEmpty(tmdbId) && info.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId)) { var searchResult = await _tmdbClientManager.FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); - if (searchResult?.TvResults.Count > 0) + if (searchResult?.TvResults?.Count > 0) { tmdbId = searchResult.TvResults[0].Id.ToString(CultureInfo.InvariantCulture); } @@ -198,7 +204,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var cleanedName = TmdbUtils.CleanName(parsedName.Name); var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, info.Year ?? parsedName.Year ?? 0, cancellationToken).ConfigureAwait(false); - if (searchResults.Count > 0) + if (searchResults?.Count > 0) { tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture); } @@ -262,15 +268,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (seriesResult.Keywords?.Results is not null) { - for (var i = 0; i < seriesResult.Keywords.Results.Count; i++) + foreach (var result in seriesResult.Keywords.Results) { - series.AddTag(seriesResult.Keywords.Results[i].Name); + var name = result.Name; + if (!string.IsNullOrWhiteSpace(name)) + { + series.AddTag(name); + } } } series.HomePageUrl = seriesResult.Homepage; - series.RunTimeTicks = seriesResult.EpisodeRunTime.Select(i => TimeSpan.FromMinutes(i).Ticks).FirstOrDefault(); + series.RunTimeTicks = seriesResult.EpisodeRunTime?.Select(i => TimeSpan.FromMinutes(i).Ticks).FirstOrDefault(); if (Emby.Naming.TV.TvParserHelpers.TryParseSeriesStatus(seriesResult.Status, out var seriesStatus)) { @@ -279,6 +289,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV series.EndDate = seriesResult.LastAirDate; series.PremiereDate = seriesResult.FirstAirDate; + series.ProductionYear = seriesResult.FirstAirDate?.Year; var ids = seriesResult.ExternalIds; if (ids is not null) @@ -288,21 +299,21 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV series.TrySetProviderId(MetadataProvider.Tvdb, ids.TvdbId); } - var contentRatings = seriesResult.ContentRatings.Results ?? new List<ContentRating>(); + var contentRatings = seriesResult.ContentRatings?.Results ?? new List<ContentRating>(); var ourRelease = contentRatings.FirstOrDefault(c => string.Equals(c.Iso_3166_1, preferredCountryCode, StringComparison.OrdinalIgnoreCase)); var usRelease = contentRatings.FirstOrDefault(c => string.Equals(c.Iso_3166_1, "US", StringComparison.OrdinalIgnoreCase)); var minimumRelease = contentRatings.FirstOrDefault(); - if (ourRelease is not null) + if (ourRelease?.Rating is not null) { - series.OfficialRating = TmdbUtils.BuildParentalRating(ourRelease.Iso_3166_1, ourRelease.Rating); + series.OfficialRating = TmdbUtils.BuildParentalRating(preferredCountryCode, ourRelease.Rating); } - else if (usRelease is not null) + else if (usRelease?.Rating is not null) { series.OfficialRating = usRelease.Rating; } - else if (minimumRelease is not null) + else if (minimumRelease?.Rating is not null) { series.OfficialRating = minimumRelease.Rating; } @@ -347,7 +358,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV Role = actor.Character?.Trim() ?? string.Empty, Type = PersonKind.Actor, SortOrder = actor.Order, - ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath) + // NOTE: Null values are filtered out above + ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath!) }; if (actor.Id > 0) @@ -367,9 +379,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) { @@ -390,7 +400,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV Name = crewMember.Name.Trim(), Role = crewMember.Job?.Trim() ?? string.Empty, Type = entry.PersonType, - ImageUrl = _tmdbClientManager.GetProfileUrl(crewMember.ProfilePath) + // NOTE: Null values are filtered out above + ImageUrl = _tmdbClientManager.GetProfileUrl(crewMember.ProfilePath!) }; if (crewMember.Id > 0) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index fedf345988..274db347ba 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Globalization; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Enums; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; @@ -195,7 +194,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); var series = await GetSeriesAsync(tvShowId, language, imageLanguages, countryCode, cancellationToken).ConfigureAwait(false); - var episodeGroupId = series?.EpisodeGroups.Results.Find(g => g.Type == groupType)?.Id; + var episodeGroupId = series?.EpisodeGroups?.Results?.Find(g => g.Type == groupType)?.Id; if (episodeGroupId is null) { @@ -263,7 +262,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv episode information or null if not found.</returns> - public async Task<TvEpisode?> GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string displayOrder, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) + public async Task<TvEpisode?> GetEpisodeAsync(int tvShowId, int seasonNumber, long episodeNumber, string displayOrder, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { var key = $"episode-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}e{episodeNumber.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}"; if (_memoryCache.TryGetValue(key, out TvEpisode? episode)) @@ -276,9 +275,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb var group = await GetSeriesGroupAsync(tvShowId, displayOrder, language, imageLanguages, countryCode, cancellationToken).ConfigureAwait(false); if (group is not null) { - var season = group.Groups.Find(s => s.Order == seasonNumber); + var season = group.Groups?.Find(s => s.Order == seasonNumber); // Episode order starts at 0 - var ep = season?.Episodes.Find(e => e.Order == episodeNumber - 1); + var ep = season?.Episodes?.Find(e => e.Order == episodeNumber - 1); if (ep is not null) { seasonNumber = ep.SeasonNumber; @@ -382,7 +381,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="year">The year the tv show first aired.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv show information.</returns> - public async Task<IReadOnlyList<SearchTv>> SearchSeriesAsync(string name, string language, string? countryCode, int year = 0, CancellationToken cancellationToken = default) + public async Task<IReadOnlyList<SearchTv>?> SearchSeriesAsync(string name, string language, string? countryCode, int year = 0, CancellationToken cancellationToken = default) { var key = $"searchseries-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer<SearchTv>? series) && series is not null) @@ -396,12 +395,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb .SearchTvShowAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), includeAdult: Plugin.Instance.Configuration.IncludeAdult, firstAirDateYear: year, cancellationToken: cancellationToken) .ConfigureAwait(false); - if (searchResults.Results.Count > 0) + if (searchResults?.Results?.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } - return searchResults.Results; + return searchResults?.Results; } /// <summary> @@ -410,7 +409,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="name">The name of the person.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb person information.</returns> - public async Task<IReadOnlyList<SearchPerson>> SearchPersonAsync(string name, CancellationToken cancellationToken) + public async Task<IReadOnlyList<SearchPerson>?> SearchPersonAsync(string name, CancellationToken cancellationToken) { var key = $"searchperson-{name}"; if (_memoryCache.TryGetValue(key, out SearchContainer<SearchPerson>? person) && person is not null) @@ -424,12 +423,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb .SearchPersonAsync(name, includeAdult: Plugin.Instance.Configuration.IncludeAdult, cancellationToken: cancellationToken) .ConfigureAwait(false); - if (searchResults.Results.Count > 0) + if (searchResults?.Results?.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } - return searchResults.Results; + return searchResults?.Results; } /// <summary> @@ -439,7 +438,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="language">The movie's language.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb movie information.</returns> - public Task<IReadOnlyList<SearchMovie>> SearchMovieAsync(string name, string language, CancellationToken cancellationToken) + public Task<IReadOnlyList<SearchMovie>?> SearchMovieAsync(string name, string language, CancellationToken cancellationToken) { return SearchMovieAsync(name, 0, language, null, cancellationToken); } @@ -453,7 +452,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb movie information.</returns> - public async Task<IReadOnlyList<SearchMovie>> SearchMovieAsync(string name, int year, string language, string? countryCode, CancellationToken cancellationToken) + public async Task<IReadOnlyList<SearchMovie>?> SearchMovieAsync(string name, int year, string language, string? countryCode, CancellationToken cancellationToken) { var key = $"moviesearch-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer<SearchMovie>? movies) && movies is not null) @@ -467,12 +466,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb .SearchMovieAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), includeAdult: Plugin.Instance.Configuration.IncludeAdult, year: year, cancellationToken: cancellationToken) .ConfigureAwait(false); - if (searchResults.Results.Count > 0) + if (searchResults?.Results?.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } - return searchResults.Results; + return searchResults?.Results; } /// <summary> @@ -483,7 +482,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb collection information.</returns> - public async Task<IReadOnlyList<SearchCollection>> SearchCollectionAsync(string name, string language, string? countryCode, CancellationToken cancellationToken) + public async Task<IReadOnlyList<SearchCollection>?> SearchCollectionAsync(string name, string language, string? countryCode, CancellationToken cancellationToken) { var key = $"collectionsearch-{name}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer<SearchCollection>? collections) && collections is not null) @@ -497,12 +496,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb .SearchCollectionAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), cancellationToken: cancellationToken) .ConfigureAwait(false); - if (searchResults.Results.Count > 0) + if (searchResults?.Results?.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } - return searchResults.Results; + return searchResults?.Results; } /// <summary> @@ -511,14 +510,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="size">The image size to fetch.</param> /// <param name="path">The relative URL of the image.</param> /// <returns>The absolute URL.</returns> - private string? GetUrl(string? size, string path) + private string? GetUrl(string? size, string? path) { if (string.IsNullOrEmpty(path)) { 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> @@ -526,7 +528,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// </summary> /// <param name="posterPath">The relative URL of the poster.</param> /// <returns>The absolute URL.</returns> - public string? GetPosterUrl(string posterPath) + public string? GetPosterUrl(string? posterPath) { return GetUrl(Plugin.Instance.Configuration.PosterSize, posterPath); } @@ -536,7 +538,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// </summary> /// <param name="actorProfilePath">The relative URL of the profile image.</param> /// <returns>The absolute URL.</returns> - public string? GetProfileUrl(string actorProfilePath) + public string? GetProfileUrl(string? actorProfilePath) { return GetUrl(Plugin.Instance.Configuration.ProfileSize, actorProfilePath); } @@ -639,30 +641,44 @@ namespace MediaBrowser.Providers.Plugins.Tmdb private static void ValidatePreferences(TMDbConfig config) { var imageConfig = config.Images; + if (imageConfig is null) + { + return; + } var pluginConfig = Plugin.Instance.Configuration; - if (!imageConfig.PosterSizes.Contains(pluginConfig.PosterSize)) + if (imageConfig.PosterSizes is not null + && pluginConfig.PosterSize is not null + && !imageConfig.PosterSizes.Contains(pluginConfig.PosterSize)) { pluginConfig.PosterSize = imageConfig.PosterSizes[^1]; } - if (!imageConfig.BackdropSizes.Contains(pluginConfig.BackdropSize)) + if (imageConfig.BackdropSizes is not null + && pluginConfig.BackdropSize is not null + && !imageConfig.BackdropSizes.Contains(pluginConfig.BackdropSize)) { pluginConfig.BackdropSize = imageConfig.BackdropSizes[^1]; } - if (!imageConfig.LogoSizes.Contains(pluginConfig.LogoSize)) + if (imageConfig.LogoSizes is not null + && pluginConfig.LogoSize is not null + && !imageConfig.LogoSizes.Contains(pluginConfig.LogoSize)) { pluginConfig.LogoSize = imageConfig.LogoSizes[^1]; } - if (!imageConfig.ProfileSizes.Contains(pluginConfig.ProfileSize)) + if (imageConfig.ProfileSizes is not null + && pluginConfig.ProfileSize is not null + && !imageConfig.ProfileSizes.Contains(pluginConfig.ProfileSize)) { pluginConfig.ProfileSize = imageConfig.ProfileSizes[^1]; } - if (!imageConfig.StillSizes.Contains(pluginConfig.StillSize)) + if (imageConfig.StillSizes is not null + && pluginConfig.StillSize is not null + && !imageConfig.StillSizes.Contains(pluginConfig.StillSize)) { pluginConfig.StillSize = imageConfig.StillSizes[^1]; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index f5e59a2789..39c0497bed 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 (string.Equals(crew.Department, "directing", StringComparison.OrdinalIgnoreCase) + && string.Equals(crew.Job, "director", StringComparison.OrdinalIgnoreCase)) { return PersonKind.Director; } - if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase) - && crew.Job.Contains("producer", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(crew.Department, "production", StringComparison.OrdinalIgnoreCase) + && string.Equals(crew.Job, "producer", StringComparison.OrdinalIgnoreCase)) { return PersonKind.Producer; } - if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(crew.Department, "writing", StringComparison.OrdinalIgnoreCase) + && (string.Equals(crew.Job, "writer", StringComparison.OrdinalIgnoreCase) || string.Equals(crew.Job, "screenplay", StringComparison.OrdinalIgnoreCase))) { return PersonKind.Writer; } @@ -96,9 +97,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <returns>A boolean indicating whether the video is a trailer.</returns> public static bool IsTrailerType(Video video) { - return video.Site.Equals("youtube", StringComparison.OrdinalIgnoreCase) - && (video.Type.Equals("trailer", StringComparison.OrdinalIgnoreCase) - || video.Type.Equals("teaser", StringComparison.OrdinalIgnoreCase)); + return string.Equals(video.Site, "youtube", StringComparison.OrdinalIgnoreCase) + && (string.Equals(video.Type, "trailer", StringComparison.OrdinalIgnoreCase) + || string.Equals(video.Type, "teaser", StringComparison.OrdinalIgnoreCase)); } /// <summary> @@ -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"); @@ -184,10 +177,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="imageLanguage">The image's actual language code.</param> /// <param name="requestLanguage">The requested language code.</param> /// <returns>The language code.</returns> - public static string AdjustImageLanguage(string imageLanguage, string requestLanguage) + public static string AdjustImageLanguage(string? imageLanguage, string requestLanguage) { - if (!string.IsNullOrEmpty(imageLanguage) - && !string.IsNullOrEmpty(requestLanguage) + if (string.IsNullOrEmpty(imageLanguage)) + { + return string.Empty; + } + + if (!string.IsNullOrEmpty(requestLanguage) && requestLanguage.Length > 2 && imageLanguage.Length == 2 && requestLanguage.StartsWith(imageLanguage, StringComparison.OrdinalIgnoreCase)) diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index ae5e1090ad..a78ec995cf 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Emby.Naming.Common; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Entities; @@ -32,6 +33,7 @@ namespace MediaBrowser.Providers.Subtitles private readonly ILibraryMonitor _monitor; private readonly IMediaSourceManager _mediaSourceManager; private readonly ILocalizationManager _localization; + private readonly HashSet<string> _allowedSubtitleFormats; private readonly ISubtitleProvider[] _subtitleProviders; @@ -41,7 +43,8 @@ namespace MediaBrowser.Providers.Subtitles ILibraryMonitor monitor, IMediaSourceManager mediaSourceManager, ILocalizationManager localizationManager, - IEnumerable<ISubtitleProvider> subtitleProviders) + IEnumerable<ISubtitleProvider> subtitleProviders, + NamingOptions namingOptions) { _logger = logger; _fileSystem = fileSystem; @@ -51,6 +54,9 @@ namespace MediaBrowser.Providers.Subtitles _subtitleProviders = subtitleProviders .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) .ToArray(); + _allowedSubtitleFormats = new HashSet<string>( + namingOptions.SubtitleFileExtensions.Select(e => e.TrimStart('.')), + StringComparer.OrdinalIgnoreCase); } /// <inheritdoc /> @@ -171,6 +177,12 @@ namespace MediaBrowser.Providers.Subtitles /// <inheritdoc /> public Task UploadSubtitle(Video video, SubtitleResponse response) { + var format = response.Format; + if (string.IsNullOrEmpty(format) || !_allowedSubtitleFormats.Contains(format)) + { + throw new ArgumentException($"Unsupported subtitle format: '{format}'"); + } + var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(video); return TrySaveSubtitle(video, libraryOptions, response); } @@ -193,7 +205,13 @@ namespace MediaBrowser.Providers.Subtitles } var savePaths = new List<string>(); - var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant(); + var language = response.Language.ToLowerInvariant(); + if (language.AsSpan().IndexOfAny(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) >= 0) + { + throw new ArgumentException("Language contains invalid characters."); + } + + var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + language; if (response.IsForced) { @@ -221,15 +239,22 @@ namespace MediaBrowser.Providers.Subtitles private async Task TrySaveToFiles(Stream stream, List<string> savePaths, Video video, string extension) { + if (!_allowedSubtitleFormats.Contains(extension, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Invalid subtitle format: {extension}"); + } + List<Exception>? exs = null; foreach (var savePath in savePaths) { - var path = savePath + "." + extension; + var path = Path.GetFullPath(savePath + "." + extension); try { - if (path.StartsWith(video.ContainingFolderPath, StringComparison.Ordinal) - || path.StartsWith(video.GetInternalMetadataPath(), StringComparison.Ordinal)) + var containingFolder = video.ContainingFolderPath + Path.DirectorySeparatorChar; + var metadataFolder = video.GetInternalMetadataPath() + Path.DirectorySeparatorChar; + if (path.StartsWith(containingFolder, StringComparison.Ordinal) + || path.StartsWith(metadataFolder, StringComparison.Ordinal)) { var fileExists = File.Exists(path); var counter = 0; diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index c3a6ddd6ae..61a31fbfd6 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -201,6 +202,26 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> false); } + private static bool NeedsVirtualSeason(Episode episode, HashSet<Guid> physicalSeasonIds, HashSet<string> physicalSeasonPaths) + { + // Episode has a known season number, needs a season + if (episode.ParentIndexNumber.HasValue) + { + return true; + } + + // Not yet processed + if (episode.SeasonId.IsEmpty()) + { + return false; + } + + // Episode has been processed, only needs a virtual season if it isn't + // already linked to a known physical season by ID or path + return !physicalSeasonIds.Contains(episode.SeasonId) + && !physicalSeasonPaths.Contains(System.IO.Path.GetDirectoryName(episode.Path) ?? string.Empty); + } + /// <summary> /// Creates seasons for all episodes if they don't exist. /// If no season number can be determined, a dummy season will be created. @@ -212,8 +233,20 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> { var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season); var seasons = seriesChildren.OfType<Season>().ToList(); + + var physicalSeasonIds = seasons + .Where(e => e.LocationType != LocationType.Virtual) + .Select(e => e.Id) + .ToHashSet(); + + var physicalSeasonPathSet = seasons + .Where(e => e.LocationType != LocationType.Virtual && !string.IsNullOrEmpty(e.Path)) + .Select(e => e.Path) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var uniqueSeasonNumbers = seriesChildren .OfType<Episode>() + .Where(e => NeedsVirtualSeason(e, physicalSeasonIds, physicalSeasonPathSet)) .Select(e => e.ParentIndexNumber >= 0 ? e.ParentIndexNumber : null) .Distinct(); |
