diff options
Diffstat (limited to 'MediaBrowser.Providers')
28 files changed, 804 insertions, 368 deletions
diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs index d2a3344bef..4632e1d511 100644 --- a/MediaBrowser.Providers/Manager/ImageSaver.cs +++ b/MediaBrowser.Providers/Manager/ImageSaver.cs @@ -9,6 +9,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -173,7 +174,7 @@ namespace MediaBrowser.Providers.Manager // Delete the current path if (currentImageIsLocalFile - && !savedPaths.Contains(currentImagePath, StringComparer.OrdinalIgnoreCase) + && !savedPaths.Contains(currentImagePath, StringComparison.OrdinalIgnoreCase) && (saveLocally || currentImagePath.Contains(_config.ApplicationPaths.InternalMetadataPath, StringComparison.OrdinalIgnoreCase))) { var currentPath = currentImagePath; @@ -494,7 +495,7 @@ namespace MediaBrowser.Providers.Manager var filenames = images.Select(i => Path.GetFileNameWithoutExtension(i.Path)).ToList(); var current = 1; - while (filenames.Contains(numberedIndexPrefix + current.ToString(CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)) + while (filenames.Contains(numberedIndexPrefix + current.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase)) { current++; } diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index 5e19856111..b1d73c4c41 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -457,7 +457,7 @@ namespace MediaBrowser.Providers.Manager CancellationToken cancellationToken) { var eligibleImages = images - .Where(i => i.Type == type && i.Width >= minWidth) + .Where(i => i.Type == type && (i.Width == null || i.Width >= minWidth)) .ToList(); if (EnableImageStub(item) && eligibleImages.Count > 0) diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 90d14a9731..0af76f75a0 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Configuration; @@ -679,8 +680,15 @@ namespace MediaBrowser.Providers.Manager { foreach (var remoteImage in localItem.RemoteImages) { - await ProviderManager.SaveImage(item, remoteImage.url, remoteImage.type, null, cancellationToken).ConfigureAwait(false); - refreshResult.UpdateType |= ItemUpdateType.ImageUpdate; + try + { + await ProviderManager.SaveImage(item, remoteImage.url, remoteImage.type, null, cancellationToken).ConfigureAwait(false); + refreshResult.UpdateType |= ItemUpdateType.ImageUpdate; + } + catch (HttpRequestException ex) + { + Logger.LogError(ex, "Could not save {ImageType} image: {Url}", Enum.GetName(remoteImage.type), remoteImage.url); + } } if (imageService.MergeImages(item, localItem.Images)) diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index ca557f6d61..0385ce6a76 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -11,7 +11,9 @@ using System.Net.Http; using System.Net.Mime; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Data.Events; +using Jellyfin.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Common.Progress; using MediaBrowser.Controller; @@ -659,7 +661,7 @@ namespace MediaBrowser.Providers.Manager /// <inheritdoc/> public void SaveMetadata(BaseItem item, ItemUpdateType updateType, IEnumerable<string> savers) { - SaveMetadata(item, updateType, _savers.Where(i => savers.Contains(i.Name, StringComparer.OrdinalIgnoreCase))); + SaveMetadata(item, updateType, _savers.Where(i => savers.Contains(i.Name, StringComparison.OrdinalIgnoreCase))); } /// <summary> @@ -736,7 +738,7 @@ namespace MediaBrowser.Providers.Manager { if (libraryOptions.MetadataSavers == null) { - if (options.DisabledMetadataSavers.Contains(saver.Name, StringComparer.OrdinalIgnoreCase)) + if (options.DisabledMetadataSavers.Contains(saver.Name, StringComparison.OrdinalIgnoreCase)) { return false; } @@ -762,7 +764,7 @@ namespace MediaBrowser.Providers.Manager } else { - if (!libraryOptions.MetadataSavers.Contains(saver.Name, StringComparer.OrdinalIgnoreCase)) + if (!libraryOptions.MetadataSavers.Contains(saver.Name, StringComparison.OrdinalIgnoreCase)) { return false; } @@ -1133,7 +1135,7 @@ namespace MediaBrowser.Providers.Manager var albums = _libraryManager .GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { nameof(MusicAlbum) }, + IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, ArtistIds = new[] { item.Id }, DtoOptions = new DtoOptions(false) { diff --git a/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs b/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs index 8c81b08db7..b4b1895f51 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CA1002, CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -13,7 +11,9 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -21,38 +21,49 @@ using MediaBrowser.Model.IO; namespace MediaBrowser.Providers.MediaInfo { /// <summary> - /// Uses ffmpeg to create video images. + /// Uses <see cref="IMediaEncoder"/> to extract embedded images. /// </summary> public class AudioImageProvider : IDynamicImageProvider { + private readonly IMediaSourceManager _mediaSourceManager; private readonly IMediaEncoder _mediaEncoder; private readonly IServerConfigurationManager _config; private readonly IFileSystem _fileSystem; - public AudioImageProvider(IMediaEncoder mediaEncoder, IServerConfigurationManager config, IFileSystem fileSystem) + /// <summary> + /// Initializes a new instance of the <see cref="AudioImageProvider"/> class. + /// </summary> + /// <param name="mediaSourceManager">The media source manager for fetching item streams.</param> + /// <param name="mediaEncoder">The media encoder for extracting embedded images.</param> + /// <param name="config">The server configuration manager for getting image paths.</param> + /// <param name="fileSystem">The filesystem.</param> + public AudioImageProvider(IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, IServerConfigurationManager config, IFileSystem fileSystem) { + _mediaSourceManager = mediaSourceManager; _mediaEncoder = mediaEncoder; _config = config; _fileSystem = fileSystem; } - public string AudioImagesPath => Path.Combine(_config.ApplicationPaths.CachePath, "extracted-audio-images"); + private string AudioImagesPath => Path.Combine(_config.ApplicationPaths.CachePath, "extracted-audio-images"); + /// <inheritdoc /> public string Name => "Image Extractor"; + /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { - return new List<ImageType> { ImageType.Primary }; + return new[] { ImageType.Primary }; } + /// <inheritdoc /> public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken) { - var audio = (Audio)item; - - var imageStreams = - audio.GetMediaStreams(MediaStreamType.EmbeddedImage) - .Where(i => i.Type == MediaStreamType.EmbeddedImage) - .ToList(); + var imageStreams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery + { + ItemId = item.Id, + Type = MediaStreamType.EmbeddedImage + }); // Can't extract if we didn't find a video stream in the file if (imageStreams.Count == 0) @@ -63,7 +74,7 @@ namespace MediaBrowser.Providers.MediaInfo return GetImage((Audio)item, imageStreams, cancellationToken); } - public async Task<DynamicImageResponse> GetImage(Audio item, List<MediaStream> imageStreams, CancellationToken cancellationToken) + private async Task<DynamicImageResponse> GetImage(Audio item, List<MediaStream> imageStreams, CancellationToken cancellationToken) { var path = GetAudioImagePath(item); @@ -75,7 +86,7 @@ namespace MediaBrowser.Providers.MediaInfo imageStreams.FirstOrDefault(i => (i.Comment ?? string.Empty).IndexOf("cover", StringComparison.OrdinalIgnoreCase) != -1) ?? imageStreams.FirstOrDefault(); - var imageStreamIndex = imageStream == null ? (int?)null : imageStream.Index; + var imageStreamIndex = imageStream?.Index; var tempFile = await _mediaEncoder.ExtractAudioImage(item.Path, imageStreamIndex, cancellationToken).ConfigureAwait(false); @@ -127,6 +138,7 @@ namespace MediaBrowser.Providers.MediaInfo return Path.Join(AudioImagesPath, prefix, filename); } + /// <inheritdoc /> public bool Supports(BaseItem item) { if (item.IsShortcut) diff --git a/MediaBrowser.Providers/MediaInfo/AudioResolver.cs b/MediaBrowser.Providers/MediaInfo/AudioResolver.cs new file mode 100644 index 0000000000..425913501a --- /dev/null +++ b/MediaBrowser.Providers/MediaInfo/AudioResolver.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Emby.Naming.Audio; +using Emby.Naming.Common; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.MediaInfo; + +namespace MediaBrowser.Providers.MediaInfo +{ + /// <summary> + /// Resolves external audios for videos. + /// </summary> + public class AudioResolver + { + private readonly ILocalizationManager _localizationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly NamingOptions _namingOptions; + + /// <summary> + /// Initializes a new instance of the <see cref="AudioResolver"/> class. + /// </summary> + /// <param name="localizationManager">The localization manager.</param> + /// <param name="mediaEncoder">The media encoder.</param> + /// <param name="namingOptions">The naming options.</param> + public AudioResolver( + ILocalizationManager localizationManager, + IMediaEncoder mediaEncoder, + NamingOptions namingOptions) + { + _localizationManager = localizationManager; + _mediaEncoder = mediaEncoder; + _namingOptions = namingOptions; + } + + /// <summary> + /// Returns the audio streams found in the external audio files for the given video. + /// </summary> + /// <param name="video">The video to get the external audio streams from.</param> + /// <param name="startIndex">The stream index to start adding audio streams at.</param> + /// <param name="directoryService">The directory service to search for files.</param> + /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param> + /// <param name="cancellationToken">The cancellation token to cancel operation.</param> + /// <returns>A list of external audio streams.</returns> + public async IAsyncEnumerable<MediaStream> GetExternalAudioStreams( + Video video, + int startIndex, + IDirectoryService directoryService, + bool clearCache, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!video.IsFileProtocol) + { + yield break; + } + + IEnumerable<string> paths = GetExternalAudioFiles(video, directoryService, clearCache); + foreach (string path in paths) + { + string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(path); + Model.MediaInfo.MediaInfo mediaInfo = await GetMediaInfo(path, cancellationToken).ConfigureAwait(false); + + foreach (MediaStream mediaStream in mediaInfo.MediaStreams) + { + mediaStream.Index = startIndex++; + mediaStream.Type = MediaStreamType.Audio; + mediaStream.IsExternal = true; + mediaStream.Path = path; + mediaStream.IsDefault = false; + mediaStream.Title = null; + + if (string.IsNullOrEmpty(mediaStream.Language)) + { + // Try to translate to three character code + // Be flexible and check against both the full and three character versions + var language = StringExtensions.RightPart(fileNameWithoutExtension, '.').ToString(); + + if (language != fileNameWithoutExtension) + { + var culture = _localizationManager.FindLanguageInfo(language); + + language = culture == null ? language : culture.ThreeLetterISOLanguageName; + mediaStream.Language = language; + } + } + + yield return mediaStream; + } + } + } + + /// <summary> + /// Returns the external audio file paths for the given video. + /// </summary> + /// <param name="video">The video to get the external audio file paths from.</param> + /// <param name="directoryService">The directory service to search for files.</param> + /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param> + /// <returns>A list of external audio file paths.</returns> + public IEnumerable<string> GetExternalAudioFiles( + Video video, + IDirectoryService directoryService, + bool clearCache) + { + if (!video.IsFileProtocol) + { + yield break; + } + + // Check if video folder exists + string folder = video.ContainingFolderPath; + if (!Directory.Exists(folder)) + { + yield break; + } + + string videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(video.Path); + + var files = directoryService.GetFilePaths(folder, clearCache, true); + for (int i = 0; i < files.Count; i++) + { + string file = files[i]; + if (string.Equals(video.Path, file, StringComparison.OrdinalIgnoreCase) + || !AudioFileParser.IsAudioFile(file, _namingOptions) + || Path.GetExtension(file.AsSpan()).Equals(".strm", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file); + // The audio filename must either be equal to the video filename or start with the video filename followed by a dot + if (videoFileNameWithoutExtension.Equals(fileNameWithoutExtension, StringComparison.OrdinalIgnoreCase) + || (fileNameWithoutExtension.Length > videoFileNameWithoutExtension.Length + && fileNameWithoutExtension[videoFileNameWithoutExtension.Length] == '.' + && fileNameWithoutExtension.StartsWith(videoFileNameWithoutExtension, StringComparison.OrdinalIgnoreCase))) + { + yield return file; + } + } + } + + /// <summary> + /// Returns the media info of the given audio file. + /// </summary> + /// <param name="path">The path to the audio file.</param> + /// <param name="cancellationToken">The cancellation token to cancel operation.</param> + /// <returns>The media info for the given audio file.</returns> + private Task<Model.MediaInfo.MediaInfo> GetMediaInfo(string path, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + return _mediaEncoder.GetMediaInfo( + new MediaInfoRequest + { + MediaType = DlnaProfileType.Audio, + MediaSource = new MediaSourceInfo + { + Path = path, + Protocol = MediaProtocol.File + } + }, + cancellationToken); + } + } +} diff --git a/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs b/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs index 9b63971a9c..96d7d139a4 100644 --- a/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs @@ -8,13 +8,16 @@ using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Net; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.MediaInfo { @@ -44,15 +47,21 @@ namespace MediaBrowser.Providers.MediaInfo "logo", }; + private readonly IMediaSourceManager _mediaSourceManager; private readonly IMediaEncoder _mediaEncoder; + private readonly ILogger<EmbeddedImageProvider> _logger; /// <summary> /// Initializes a new instance of the <see cref="EmbeddedImageProvider"/> class. /// </summary> + /// <param name="mediaSourceManager">The media source manager for fetching item streams and attachments.</param> /// <param name="mediaEncoder">The media encoder for extracting attached/embedded images.</param> - public EmbeddedImageProvider(IMediaEncoder mediaEncoder) + /// <param name="logger">The logger.</param> + public EmbeddedImageProvider(IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, ILogger<EmbeddedImageProvider> logger) { + _mediaSourceManager = mediaSourceManager; _mediaEncoder = mediaEncoder; + _logger = logger; } /// <inheritdoc /> @@ -114,12 +123,17 @@ namespace MediaBrowser.Providers.MediaInfo ImageType.Primary => _primaryImageFileNames, ImageType.Backdrop => _backdropImageFileNames, ImageType.Logo => _logoImageFileNames, - _ => _primaryImageFileNames + _ => Array.Empty<string>() }; + if (imageFileNames.Length == 0) + { + _logger.LogWarning("Attempted to load unexpected image type: {Type}", type); + return new DynamicImageResponse { HasImage = false }; + } + // Try attachments first - var attachmentStream = item.GetMediaSources(false) - .SelectMany(source => source.MediaAttachments) + var attachmentStream = _mediaSourceManager.GetMediaAttachments(item.Id) .FirstOrDefault(attachment => !string.IsNullOrEmpty(attachment.FileName) && imageFileNames.Any(name => attachment.FileName.Contains(name, StringComparison.OrdinalIgnoreCase))); @@ -129,7 +143,11 @@ namespace MediaBrowser.Providers.MediaInfo } // Fall back to EmbeddedImage streams - var imageStreams = item.GetMediaStreams().FindAll(i => i.Type == MediaStreamType.EmbeddedImage); + var imageStreams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery + { + ItemId = item.Id, + Type = MediaStreamType.EmbeddedImage + }); if (imageStreams.Count == 0) { @@ -156,13 +174,21 @@ namespace MediaBrowser.Providers.MediaInfo } } + var format = imageStream.Codec switch + { + "mjpeg" => ImageFormat.Jpg, + "png" => ImageFormat.Png, + "gif" => ImageFormat.Gif, + _ => ImageFormat.Jpg + }; + string extractedImagePath = - await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, imageStream, imageStream.Index, ".jpg", cancellationToken) + await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, imageStream, imageStream.Index, format, cancellationToken) .ConfigureAwait(false); return new DynamicImageResponse { - Format = ImageFormat.Jpg, + Format = format, HasImage = true, Path = extractedImagePath, Protocol = MediaProtocol.File @@ -180,10 +206,6 @@ namespace MediaBrowser.Providers.MediaInfo extension = ".jpg"; } - string extractedAttachmentPath = - await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, null, attachmentStream.Index, extension, cancellationToken) - .ConfigureAwait(false); - ImageFormat format = extension switch { ".bmp" => ImageFormat.Bmp, @@ -194,6 +216,10 @@ namespace MediaBrowser.Providers.MediaInfo _ => ImageFormat.Jpg }; + string extractedAttachmentPath = + await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, null, attachmentStream.Index, format, cancellationToken) + .ConfigureAwait(false); + return new DynamicImageResponse { Format = format, diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs index d4b5d8655c..19a435196a 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Emby.Naming.Common; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -38,17 +39,10 @@ namespace MediaBrowser.Providers.MediaInfo IHasItemChangeMonitor { private readonly ILogger<FFProbeProvider> _logger; - private readonly IMediaEncoder _mediaEncoder; - private readonly IItemRepository _itemRepo; - private readonly IBlurayExaminer _blurayExaminer; - private readonly ILocalizationManager _localization; - private readonly IEncodingManager _encodingManager; - private readonly IServerConfigurationManager _config; - private readonly ISubtitleManager _subtitleManager; - private readonly IChapterManager _chapterManager; - private readonly ILibraryManager _libraryManager; - private readonly IMediaSourceManager _mediaSourceManager; private readonly SubtitleResolver _subtitleResolver; + private readonly AudioResolver _audioResolver; + private readonly FFProbeVideoInfo _videoProber; + private readonly FFProbeAudioInfo _audioProber; private readonly Task<ItemUpdateType> _cachedTask = Task.FromResult(ItemUpdateType.None); @@ -63,21 +57,26 @@ namespace MediaBrowser.Providers.MediaInfo IServerConfigurationManager config, ISubtitleManager subtitleManager, IChapterManager chapterManager, - ILibraryManager libraryManager) + ILibraryManager libraryManager, + NamingOptions namingOptions) { _logger = logger; - _mediaEncoder = mediaEncoder; - _itemRepo = itemRepo; - _blurayExaminer = blurayExaminer; - _localization = localization; - _encodingManager = encodingManager; - _config = config; - _subtitleManager = subtitleManager; - _chapterManager = chapterManager; - _libraryManager = libraryManager; - _mediaSourceManager = mediaSourceManager; - + _audioResolver = new AudioResolver(localization, mediaEncoder, namingOptions); _subtitleResolver = new SubtitleResolver(BaseItem.LocalizationManager); + _videoProber = new FFProbeVideoInfo( + _logger, + mediaSourceManager, + mediaEncoder, + itemRepo, + blurayExaminer, + localization, + encodingManager, + config, + subtitleManager, + chapterManager, + libraryManager, + _audioResolver); + _audioProber = new FFProbeAudioInfo(mediaSourceManager, mediaEncoder, itemRepo, libraryManager); } public string Name => "ffprobe"; @@ -97,7 +96,7 @@ namespace MediaBrowser.Providers.MediaInfo var file = directoryService.GetFile(path); if (file != null && file.LastWriteTimeUtc != item.DateModified) { - _logger.LogDebug("Refreshing {0} due to date modified timestamp change.", path); + _logger.LogDebug("Refreshing {ItemPath} due to date modified timestamp change.", path); return true; } } @@ -107,7 +106,15 @@ namespace MediaBrowser.Providers.MediaInfo && !video.SubtitleFiles.SequenceEqual( _subtitleResolver.GetExternalSubtitleFiles(video, directoryService, false), StringComparer.Ordinal)) { - _logger.LogDebug("Refreshing {0} due to external subtitles change.", item.Path); + _logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path); + return true; + } + + if (item.SupportsLocalMetadata && video != null && !video.IsPlaceHolder + && !video.AudioFiles.SequenceEqual( + _audioResolver.GetExternalAudioFiles(video, directoryService, false), StringComparer.Ordinal)) + { + _logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path); return true; } @@ -177,20 +184,7 @@ namespace MediaBrowser.Providers.MediaInfo FetchShortcutInfo(item); } - var prober = new FFProbeVideoInfo( - _logger, - _mediaSourceManager, - _mediaEncoder, - _itemRepo, - _blurayExaminer, - _localization, - _encodingManager, - _config, - _subtitleManager, - _chapterManager, - _libraryManager); - - return prober.ProbeVideo(item, options, cancellationToken); + return _videoProber.ProbeVideo(item, options, cancellationToken); } private string NormalizeStrmLine(string line) @@ -226,9 +220,7 @@ namespace MediaBrowser.Providers.MediaInfo FetchShortcutInfo(item); } - var prober = new FFProbeAudioInfo(_mediaSourceManager, _mediaEncoder, _itemRepo, _libraryManager); - - return prober.Probe(item, options, cancellationToken); + return _audioProber.Probe(item, options, cancellationToken); } } } diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index 4ab15f60e2..77372e0635 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -44,6 +44,7 @@ namespace MediaBrowser.Providers.MediaInfo private readonly ISubtitleManager _subtitleManager; private readonly IChapterManager _chapterManager; private readonly ILibraryManager _libraryManager; + private readonly AudioResolver _audioResolver; private readonly IMediaSourceManager _mediaSourceManager; private readonly long _dummyChapterDuration = TimeSpan.FromMinutes(5).Ticks; @@ -59,7 +60,8 @@ namespace MediaBrowser.Providers.MediaInfo IServerConfigurationManager config, ISubtitleManager subtitleManager, IChapterManager chapterManager, - ILibraryManager libraryManager) + ILibraryManager libraryManager, + AudioResolver audioResolver) { _logger = logger; _mediaEncoder = mediaEncoder; @@ -71,6 +73,7 @@ namespace MediaBrowser.Providers.MediaInfo _subtitleManager = subtitleManager; _chapterManager = chapterManager; _libraryManager = libraryManager; + _audioResolver = audioResolver; _mediaSourceManager = mediaSourceManager; } @@ -214,6 +217,8 @@ namespace MediaBrowser.Providers.MediaInfo await AddExternalSubtitles(video, mediaStreams, options, cancellationToken).ConfigureAwait(false); + await AddExternalAudioAsync(video, mediaStreams, options, cancellationToken).ConfigureAwait(false); + var libraryOptions = _libraryManager.GetLibraryOptions(video); if (mediaInfo != null) @@ -575,6 +580,31 @@ namespace MediaBrowser.Providers.MediaInfo } /// <summary> + /// Adds the external audio. + /// </summary> + /// <param name="video">The video.</param> + /// <param name="currentStreams">The current streams.</param> + /// <param name="options">The refreshOptions.</param> + /// <param name="cancellationToken">The cancellation token.</param> + private async Task AddExternalAudioAsync( + Video video, + List<MediaStream> currentStreams, + MetadataRefreshOptions options, + CancellationToken cancellationToken) + { + var startIndex = currentStreams.Count == 0 ? 0 : currentStreams.Max(i => i.Index) + 1; + var externalAudioStreams = _audioResolver.GetExternalAudioStreams(video, startIndex, options.DirectoryService, false, cancellationToken); + + await foreach (MediaStream externalAudioStream in externalAudioStreams) + { + currentStreams.Add(externalAudioStream); + } + + // Select all external audio file paths + video.AudioFiles = currentStreams.Where(i => i.Type == MediaStreamType.Audio && i.IsExternal).Select(i => i.Path).Distinct().ToArray(); + } + + /// <summary> /// Creates dummy chapters. /// </summary> /// <param name="video">The video.</param> diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs b/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs index dd4a5f061c..ba284187ed 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs +++ b/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs @@ -1,7 +1,3 @@ -#nullable disable - -#pragma warning disable CA1002, CS1591 - using System; using System.Collections.Generic; using System.IO; @@ -12,15 +8,30 @@ using MediaBrowser.Model.Globalization; namespace MediaBrowser.Providers.MediaInfo { + /// <summary> + /// Resolves external subtitles for videos. + /// </summary> public class SubtitleResolver { private readonly ILocalizationManager _localization; + /// <summary> + /// Initializes a new instance of the <see cref="SubtitleResolver"/> class. + /// </summary> + /// <param name="localization">The localization manager.</param> public SubtitleResolver(ILocalizationManager localization) { _localization = localization; } + /// <summary> + /// Retrieves the external subtitle streams for the provided video. + /// </summary> + /// <param name="video">The video to search from.</param> + /// <param name="startIndex">The stream index to start adding subtitle streams at.</param> + /// <param name="directoryService">The directory service to search for files.</param> + /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param> + /// <returns>The external subtitle streams located.</returns> public List<MediaStream> GetExternalSubtitleStreams( Video video, int startIndex, @@ -56,6 +67,13 @@ namespace MediaBrowser.Providers.MediaInfo return streams; } + /// <summary> + /// Locates the external subtitle files for the provided video. + /// </summary> + /// <param name="video">The video to search from.</param> + /// <param name="directoryService">The directory service to search for files.</param> + /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param> + /// <returns>The external subtitle file paths located.</returns> public IEnumerable<string> GetExternalSubtitleFiles( Video video, IDirectoryService directoryService, @@ -74,6 +92,13 @@ namespace MediaBrowser.Providers.MediaInfo } } + /// <summary> + /// Extracts the subtitle files from the provided list and adds them to the list of streams. + /// </summary> + /// <param name="streams">The list of streams to add external subtitles to.</param> + /// <param name="videoPath">The path to the video file.</param> + /// <param name="startIndex">The stream index to start adding subtitle streams at.</param> + /// <param name="files">The files to add if they are subtitles.</param> public void AddExternalSubtitleStreams( List<MediaStream> streams, string videoPath, @@ -120,6 +145,12 @@ namespace MediaBrowser.Providers.MediaInfo while (languageSpan.Length > 0) { var lastDot = languageSpan.LastIndexOf('.'); + if (lastDot < videoFileNameWithoutExtension.Length) + { + languageSpan = ReadOnlySpan<char>.Empty; + break; + } + var currentSlice = languageSpan[lastDot..]; if (currentSlice.Equals(".default", StringComparison.OrdinalIgnoreCase) || currentSlice.Equals(".forced", StringComparison.OrdinalIgnoreCase) @@ -133,12 +164,19 @@ namespace MediaBrowser.Providers.MediaInfo break; } - // Try to translate to three character code - // Be flexible and check against both the full and three character versions var language = languageSpan.ToString(); - var culture = _localization.FindLanguageInfo(language); + if (string.IsNullOrWhiteSpace(language)) + { + language = null; + } + else + { + // Try to translate to three character code + // Be flexible and check against both the full and three character versions + var culture = _localization.FindLanguageInfo(language); - language = culture == null ? language : culture.ThreeLetterISOLanguageName; + language = culture == null ? language : culture.ThreeLetterISOLanguageName; + } mediaStream = new MediaStream { diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs index 1eacbf1e1b..cce71b0673 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs +++ b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; 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; @@ -66,7 +67,7 @@ namespace MediaBrowser.Providers.MediaInfo { var options = GetOptions(); - var types = new[] { "Episode", "Movie" }; + var types = new[] { BaseItemKind.Episode, BaseItemKind.Movie }; var dict = new Dictionary<Guid, BaseItem>(); diff --git a/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs b/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs index d226182c09..d4bf629702 100644 --- a/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs @@ -4,7 +4,9 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; @@ -19,16 +21,19 @@ namespace MediaBrowser.Providers.MediaInfo /// </summary> public class VideoImageProvider : IDynamicImageProvider, IHasOrder { + private readonly IMediaSourceManager _mediaSourceManager; private readonly IMediaEncoder _mediaEncoder; private readonly ILogger<VideoImageProvider> _logger; /// <summary> /// Initializes a new instance of the <see cref="VideoImageProvider"/> class. /// </summary> + /// <param name="mediaSourceManager">The media source manager for fetching item streams.</param> /// <param name="mediaEncoder">The media encoder for capturing images.</param> /// <param name="logger">The logger.</param> - public VideoImageProvider(IMediaEncoder mediaEncoder, ILogger<VideoImageProvider> logger) + public VideoImageProvider(IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, ILogger<VideoImageProvider> logger) { + _mediaSourceManager = mediaSourceManager; _mediaEncoder = mediaEncoder; _logger = logger; } @@ -78,12 +83,18 @@ namespace MediaBrowser.Providers.MediaInfo // If we know the duration, grab it from 10% into the video. Otherwise just 10 seconds in. // Always use 10 seconds for dvd because our duration could be out of whack - var imageOffset = item.VideoType != VideoType.Dvd && item.RunTimeTicks.HasValue && - item.RunTimeTicks.Value > 0 + var imageOffset = item.VideoType != VideoType.Dvd && item.RunTimeTicks > 0 ? TimeSpan.FromTicks(item.RunTimeTicks.Value / 10) : TimeSpan.FromSeconds(10); - var videoStream = item.GetDefaultVideoStream() ?? item.GetMediaStreams().FirstOrDefault(i => i.Type == MediaStreamType.Video); + var query = new MediaStreamQuery { ItemId = item.Id, Index = item.DefaultVideoStreamIndex }; + var videoStream = _mediaSourceManager.GetMediaStreams(query).FirstOrDefault(); + if (videoStream == null) + { + query.Type = MediaStreamType.Video; + query.Index = null; + videoStream = _mediaSourceManager.GetMediaStreams(query).FirstOrDefault(); + } if (videoStream == null) { diff --git a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs index 8baca30c65..fe9986d424 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; @@ -44,7 +45,7 @@ namespace MediaBrowser.Providers.Playlists } var extension = Path.GetExtension(path); - if (!Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (!Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { return Task.FromResult(ItemUpdateType.None); } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index 5559b9db6a..8a32cb07ce 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -319,6 +319,12 @@ namespace MediaBrowser.Providers.Music { case "name-credit": { + if (reader.IsEmptyElement) + { + reader.Read(); + break; + } + using var subReader = reader.ReadSubtree(); return ParseArtistNameCredit(subReader); } @@ -355,6 +361,12 @@ namespace MediaBrowser.Providers.Music { case "artist": { + if (reader.IsEmptyElement) + { + reader.Read(); + break; + } + var id = reader.GetAttribute("id"); using var subReader = reader.ReadSubtree(); return ParseArtistArtistCredit(subReader, id); @@ -457,8 +469,8 @@ namespace MediaBrowser.Providers.Music }; using var reader = XmlReader.Create(oReader, settings); - reader.MoveToContent(); - reader.Read(); + await reader.MoveToContentAsync().ConfigureAwait(false); + await reader.ReadAsync().ConfigureAwait(false); // Loop through each element while (!reader.EOF && reader.ReadState == ReadState.Interactive) @@ -471,7 +483,7 @@ namespace MediaBrowser.Providers.Music { if (reader.IsEmptyElement) { - reader.Read(); + await reader.ReadAsync().ConfigureAwait(false); continue; } @@ -481,14 +493,14 @@ namespace MediaBrowser.Providers.Music default: { - reader.Skip(); + await reader.SkipAsync().ConfigureAwait(false); break; } } } else { - reader.Read(); + await reader.ReadAsync().ConfigureAwait(false); } } @@ -755,6 +767,12 @@ namespace MediaBrowser.Providers.Music case "artist-credit": { + if (reader.IsEmptyElement) + { + reader.Read(); + break; + } + using var subReader = reader.ReadSubtree(); var artist = ParseArtistCredit(subReader); diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs index f67ac6ede4..d8b33a799f 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; @@ -17,24 +16,17 @@ namespace MediaBrowser.Providers.Plugins.Omdb { public class OmdbEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder { - private readonly IHttpClientFactory _httpClientFactory; private readonly OmdbItemProvider _itemProvider; - private readonly IFileSystem _fileSystem; - private readonly IServerConfigurationManager _configurationManager; - private readonly IApplicationHost _appHost; + private readonly OmdbProvider _omdbProvider; public OmdbEpisodeProvider( - IApplicationHost appHost, IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, IFileSystem fileSystem, IServerConfigurationManager configurationManager) { - _httpClientFactory = httpClientFactory; - _fileSystem = fileSystem; - _configurationManager = configurationManager; - _appHost = appHost; - _itemProvider = new OmdbItemProvider(_appHost, httpClientFactory, libraryManager, fileSystem, configurationManager); + _itemProvider = new OmdbItemProvider(httpClientFactory, libraryManager, fileSystem, configurationManager); + _omdbProvider = new OmdbProvider(httpClientFactory, fileSystem, configurationManager); } // After TheTvDb @@ -44,12 +36,12 @@ namespace MediaBrowser.Providers.Plugins.Omdb public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) { - return _itemProvider.GetSearchResults(searchInfo, "episode", cancellationToken); + return _itemProvider.GetSearchResults(searchInfo, cancellationToken); } public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) { - var result = new MetadataResult<Episode>() + var result = new MetadataResult<Episode> { Item = new Episode(), QueriedById = true @@ -61,13 +53,20 @@ namespace MediaBrowser.Providers.Plugins.Omdb return result; } - if (info.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out string? seriesImdbId) && !string.IsNullOrEmpty(seriesImdbId)) + if (info.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out string? seriesImdbId) + && !string.IsNullOrEmpty(seriesImdbId) + && info.IndexNumber.HasValue + && info.ParentIndexNumber.HasValue) { - if (info.IndexNumber.HasValue && info.ParentIndexNumber.HasValue) - { - result.HasMetadata = await new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager) - .FetchEpisodeData(result, info.IndexNumber.Value, info.ParentIndexNumber.Value, info.GetProviderId(MetadataProvider.Imdb), seriesImdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); - } + result.HasMetadata = await _omdbProvider.FetchEpisodeData( + result, + info.IndexNumber.Value, + info.ParentIndexNumber.Value, + info.GetProviderId(MetadataProvider.Imdb), + seriesImdbId, + info.MetadataLanguage, + info.MetadataCountryCode, + cancellationToken).ConfigureAwait(false); } return result; diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs index fa82089c86..60b373483f 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs @@ -3,11 +3,10 @@ #pragma warning disable CS1591 using System.Collections.Generic; -using System.Globalization; +using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -23,16 +22,12 @@ namespace MediaBrowser.Providers.Plugins.Omdb public class OmdbImageProvider : IRemoteImageProvider, IHasOrder { private readonly IHttpClientFactory _httpClientFactory; - private readonly IFileSystem _fileSystem; - private readonly IServerConfigurationManager _configurationManager; - private readonly IApplicationHost _appHost; + private readonly OmdbProvider _omdbProvider; - public OmdbImageProvider(IApplicationHost appHost, IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IServerConfigurationManager configurationManager) + public OmdbImageProvider(IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IServerConfigurationManager configurationManager) { _httpClientFactory = httpClientFactory; - _fileSystem = fileSystem; - _configurationManager = configurationManager; - _appHost = appHost; + _omdbProvider = new OmdbProvider(_httpClientFactory, fileSystem, configurationManager); } public string Name => "The Open Movie Database"; @@ -52,38 +47,27 @@ namespace MediaBrowser.Providers.Plugins.Omdb public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var imdbId = item.GetProviderId(MetadataProvider.Imdb); + if (string.IsNullOrWhiteSpace(imdbId)) + { + return Enumerable.Empty<RemoteImageInfo>(); + } - var list = new List<RemoteImageInfo>(); - - var provider = new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager); + var rootObject = await _omdbProvider.GetRootObject(imdbId, cancellationToken).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(imdbId)) + if (string.IsNullOrEmpty(rootObject.Poster)) { - var rootObject = await provider.GetRootObject(imdbId, cancellationToken).ConfigureAwait(false); + return Enumerable.Empty<RemoteImageInfo>(); + } - if (!string.IsNullOrEmpty(rootObject.Poster)) + // the poster url is sometimes higher quality than the poster api + return new[] + { + new RemoteImageInfo { - if (item is Episode) - { - // img.omdbapi.com is returning 404's - list.Add(new RemoteImageInfo - { - ProviderName = Name, - Url = rootObject.Poster - }); - } - else - { - list.Add(new RemoteImageInfo - { - ProviderName = Name, - Url = string.Format(CultureInfo.InvariantCulture, "https://img.omdbapi.com/?i={0}&apikey=2c9d9507", imdbId) - }); - } + ProviderName = Name, + Url = rootObject.Poster } - } - - return list; + }; } public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs index 2409993a29..e5753b2b59 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs @@ -8,11 +8,11 @@ using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions.Json; -using MediaBrowser.Common; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -31,13 +31,10 @@ namespace MediaBrowser.Providers.Plugins.Omdb { private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryManager _libraryManager; - private readonly IFileSystem _fileSystem; - private readonly IServerConfigurationManager _configurationManager; - private readonly IApplicationHost _appHost; private readonly JsonSerializerOptions _jsonOptions; + private readonly OmdbProvider _omdbProvider; public OmdbItemProvider( - IApplicationHost appHost, IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, IFileSystem fileSystem, @@ -45,9 +42,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb { _httpClientFactory = httpClientFactory; _libraryManager = libraryManager; - _fileSystem = fileSystem; - _configurationManager = configurationManager; - _appHost = appHost; + _omdbProvider = new OmdbProvider(_httpClientFactory, fileSystem, configurationManager); _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options); _jsonOptions.Converters.Add(new JsonOmdbNotAvailableStringConverter()); @@ -59,185 +54,166 @@ namespace MediaBrowser.Providers.Plugins.Omdb // After primary option public int Order => 2; + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(TrailerInfo searchInfo, CancellationToken cancellationToken) + { + return GetSearchResultsInternal(searchInfo, true, cancellationToken); + } + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) { - return GetSearchResults(searchInfo, "series", cancellationToken); + return GetSearchResultsInternal(searchInfo, true, cancellationToken); } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) { - return GetSearchResults(searchInfo, "movie", cancellationToken); + return GetSearchResultsInternal(searchInfo, true, cancellationToken); } - public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ItemLookupInfo searchInfo, string type, CancellationToken cancellationToken) + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) { - return GetSearchResultsInternal(searchInfo, type, true, cancellationToken); + return GetSearchResultsInternal(searchInfo, true, cancellationToken); } - private async Task<IEnumerable<RemoteSearchResult>> GetSearchResultsInternal(ItemLookupInfo searchInfo, string type, bool isSearch, CancellationToken cancellationToken) + private async Task<IEnumerable<RemoteSearchResult>> GetSearchResultsInternal(ItemLookupInfo searchInfo, bool isSearch, CancellationToken cancellationToken) { + var type = searchInfo switch + { + EpisodeInfo => "episode", + SeriesInfo => "series", + _ => "movie" + }; + + // This is a bit hacky? var episodeSearchInfo = searchInfo as EpisodeInfo; + var indexNumberEnd = episodeSearchInfo?.IndexNumberEnd; var imdbId = searchInfo.GetProviderId(MetadataProvider.Imdb); - var urlQuery = "plot=full&r=json"; - if (type == "episode" && episodeSearchInfo != null) + var urlQuery = new StringBuilder("plot=full&r=json"); + if (episodeSearchInfo != null) { episodeSearchInfo.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out imdbId); - } - - var name = searchInfo.Name; - var year = searchInfo.Year; + if (searchInfo.IndexNumber.HasValue) + { + urlQuery.Append("&Episode=").Append(searchInfo.IndexNumber.Value); + } - if (!string.IsNullOrWhiteSpace(name)) - { - var parsedName = _libraryManager.ParseName(name); - var yearInName = parsedName.Year; - name = parsedName.Name; - year ??= yearInName; + if (searchInfo.ParentIndexNumber.HasValue) + { + urlQuery.Append("&Season=").Append(searchInfo.ParentIndexNumber.Value); + } } if (string.IsNullOrWhiteSpace(imdbId)) { - if (year.HasValue) + var name = searchInfo.Name; + var year = searchInfo.Year; + if (!string.IsNullOrWhiteSpace(name)) { - urlQuery += "&y=" + year.Value.ToString(CultureInfo.InvariantCulture); + var parsedName = _libraryManager.ParseName(name); + var yearInName = parsedName.Year; + name = parsedName.Name; + year ??= yearInName; } - // &s means search and returns a list of results as opposed to t - if (isSearch) - { - urlQuery += "&s=" + WebUtility.UrlEncode(name); - } - else + if (year.HasValue) { - urlQuery += "&t=" + WebUtility.UrlEncode(name); + urlQuery.Append("&y=").Append(year); } - urlQuery += "&type=" + type; + // &s means search and returns a list of results as opposed to t + urlQuery.Append(isSearch ? "&s=" : "&t="); + urlQuery.Append(WebUtility.UrlEncode(name)); + urlQuery.Append("&type=") + .Append(type); } else { - urlQuery += "&i=" + imdbId; + urlQuery.Append("&i=") + .Append(imdbId); isSearch = false; } - if (type == "episode") - { - if (searchInfo.IndexNumber.HasValue) - { - urlQuery += string.Format(CultureInfo.InvariantCulture, "&Episode={0}", searchInfo.IndexNumber); - } - - if (searchInfo.ParentIndexNumber.HasValue) - { - urlQuery += string.Format(CultureInfo.InvariantCulture, "&Season={0}", searchInfo.ParentIndexNumber); - } - } - - var url = OmdbProvider.GetOmdbUrl(urlQuery); + var url = OmdbProvider.GetOmdbUrl(urlQuery.ToString()); - using var response = await OmdbProvider.GetOmdbResponse(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var resultList = new List<SearchResult>(); if (isSearch) { var searchResultList = await JsonSerializer.DeserializeAsync<SearchResultList>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (searchResultList != null && searchResultList.Search != null) + if (searchResultList?.Search != null) { - resultList.AddRange(searchResultList.Search); + var resultCount = searchResultList.Search.Count; + var result = new RemoteSearchResult[resultCount]; + for (var i = 0; i < resultCount; i++) + { + result[i] = ResultToMetadataResult(searchResultList.Search[i], searchInfo, indexNumberEnd); + } + + return result; } } else { var result = await JsonSerializer.DeserializeAsync<SearchResult>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (string.Equals(result.Response, "true", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(result?.Response, "true", StringComparison.OrdinalIgnoreCase)) { - resultList.Add(result); + return new[] { ResultToMetadataResult(result, searchInfo, indexNumberEnd) }; } } - return resultList.Select(result => - { - var item = new RemoteSearchResult - { - IndexNumber = searchInfo.IndexNumber, - Name = result.Title, - ParentIndexNumber = searchInfo.ParentIndexNumber, - SearchProviderName = Name - }; - - if (episodeSearchInfo != null && episodeSearchInfo.IndexNumberEnd.HasValue) - { - item.IndexNumberEnd = episodeSearchInfo.IndexNumberEnd.Value; - } - - item.SetProviderId(MetadataProvider.Imdb, result.imdbID); - - if (result.Year.Length > 0 - && int.TryParse(result.Year.AsSpan().Slice(0, Math.Min(result.Year.Length, 4)), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedYear)) - { - item.ProductionYear = parsedYear; - } - - if (!string.IsNullOrEmpty(result.Released) - && DateTime.TryParse(result.Released, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var released)) - { - item.PremiereDate = released; - } - - if (!string.IsNullOrWhiteSpace(result.Poster) && !string.Equals(result.Poster, "N/A", StringComparison.OrdinalIgnoreCase)) - { - item.ImageUrl = result.Poster; - } - - return item; - }); + return Enumerable.Empty<RemoteSearchResult>(); } public Task<MetadataResult<Trailer>> GetMetadata(TrailerInfo info, CancellationToken cancellationToken) { - return GetMovieResult<Trailer>(info, cancellationToken); + return GetResult<Trailer>(info, cancellationToken); } - public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(TrailerInfo searchInfo, CancellationToken cancellationToken) + public Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) { - return GetSearchResults(searchInfo, "movie", cancellationToken); + return GetResult<Series>(info, cancellationToken); } - public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) + public Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken) { - var result = new MetadataResult<Series> + return GetResult<Movie>(info, cancellationToken); + } + + private RemoteSearchResult ResultToMetadataResult(SearchResult result, ItemLookupInfo searchInfo, int? indexNumberEnd) + { + var item = new RemoteSearchResult { - Item = new Series(), - QueriedById = true + IndexNumber = searchInfo.IndexNumber, + Name = result.Title, + ParentIndexNumber = searchInfo.ParentIndexNumber, + SearchProviderName = Name, + IndexNumberEnd = indexNumberEnd }; - var imdbId = info.GetProviderId(MetadataProvider.Imdb); - if (string.IsNullOrWhiteSpace(imdbId)) + item.SetProviderId(MetadataProvider.Imdb, result.imdbID); + + if (OmdbProvider.TryParseYear(result.Year, out var parsedYear)) { - imdbId = await GetSeriesImdbId(info, cancellationToken).ConfigureAwait(false); - result.QueriedById = false; + item.ProductionYear = parsedYear; } - if (!string.IsNullOrEmpty(imdbId)) + if (!string.IsNullOrEmpty(result.Released) + && DateTime.TryParse(result.Released, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var released)) { - result.Item.SetProviderId(MetadataProvider.Imdb, imdbId); - result.HasMetadata = true; - - await new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager).Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + item.PremiereDate = released; } - return result; - } + if (!string.IsNullOrWhiteSpace(result.Poster)) + { + item.ImageUrl = result.Poster; + } - public Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken) - { - return GetMovieResult<Movie>(info, cancellationToken); + return item; } - private async Task<MetadataResult<T>> GetMovieResult<T>(ItemLookupInfo info, CancellationToken cancellationToken) + private async Task<MetadataResult<T>> GetResult<T>(ItemLookupInfo info, CancellationToken cancellationToken) where T : BaseItem, new() { var result = new MetadataResult<T> @@ -249,7 +225,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb var imdbId = info.GetProviderId(MetadataProvider.Imdb); if (string.IsNullOrWhiteSpace(imdbId)) { - imdbId = await GetMovieImdbId(info, cancellationToken).ConfigureAwait(false); + imdbId = await GetImdbId(info, cancellationToken).ConfigureAwait(false); result.QueriedById = false; } @@ -258,22 +234,15 @@ namespace MediaBrowser.Providers.Plugins.Omdb result.Item.SetProviderId(MetadataProvider.Imdb, imdbId); result.HasMetadata = true; - await new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager).Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + await _omdbProvider.Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); } return result; } - private async Task<string> GetMovieImdbId(ItemLookupInfo info, CancellationToken cancellationToken) - { - var results = await GetSearchResultsInternal(info, "movie", false, cancellationToken).ConfigureAwait(false); - var first = results.FirstOrDefault(); - return first?.GetProviderId(MetadataProvider.Imdb); - } - - private async Task<string> GetSeriesImdbId(SeriesInfo info, CancellationToken cancellationToken) + private async Task<string> GetImdbId(ItemLookupInfo info, CancellationToken cancellationToken) { - var results = await GetSearchResultsInternal(info, "series", false, cancellationToken).ConfigureAwait(false); + var results = await GetSearchResultsInternal(info, false, cancellationToken).ConfigureAwait(false); var first = results.FirstOrDefault(); return first?.GetProviderId(MetadataProvider.Imdb); } diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index 816a882b46..12ea2d55b9 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -4,15 +4,16 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; +using System.Net.Http.Json; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions.Json; -using MediaBrowser.Common; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -28,24 +29,22 @@ namespace MediaBrowser.Providers.Plugins.Omdb private readonly IFileSystem _fileSystem; private readonly IServerConfigurationManager _configurationManager; private readonly IHttpClientFactory _httpClientFactory; - private readonly IApplicationHost _appHost; private readonly JsonSerializerOptions _jsonOptions; /// <summary>Initializes a new instance of the <see cref="OmdbProvider"/> class.</summary> /// <param name="httpClientFactory">HttpClientFactory to use for calls to OMDB service.</param> /// <param name="fileSystem">IFileSystem to use for store OMDB data.</param> - /// <param name="appHost">IApplicationHost to use.</param> /// <param name="configurationManager">IServerConfigurationManager to use.</param> - public OmdbProvider(IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IApplicationHost appHost, IServerConfigurationManager configurationManager) + public OmdbProvider(IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IServerConfigurationManager configurationManager) { _httpClientFactory = httpClientFactory; _fileSystem = fileSystem; _configurationManager = configurationManager; - _appHost = appHost; _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options); - _jsonOptions.Converters.Add(new JsonOmdbNotAvailableStringConverter()); - _jsonOptions.Converters.Add(new JsonOmdbNotAvailableInt32Converter()); + // These converters need to take priority + _jsonOptions.Converters.Insert(0, new JsonOmdbNotAvailableStringConverter()); + _jsonOptions.Converters.Insert(0, new JsonOmdbNotAvailableInt32Converter()); } /// <summary>Fetches data from OMDB service.</summary> @@ -68,8 +67,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb var result = await GetRootObject(imdbId, cancellationToken).ConfigureAwait(false); + var isEnglishRequested = IsConfiguredForEnglish(item, language); // Only take the name and rating if the user's language is set to English, since Omdb has no localization - if (string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) || _configurationManager.Configuration.EnableNewOmdbSupport) + if (isEnglishRequested) { item.Name = result.Title; @@ -79,9 +79,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb } } - if (!string.IsNullOrEmpty(result.Year) && result.Year.Length >= 4 - && int.TryParse(result.Year.AsSpan().Slice(0, 4), NumberStyles.Number, CultureInfo.InvariantCulture, out var year) - && year >= 0) + if (TryParseYear(result.Year, out var year)) { item.ProductionYear = year; } @@ -117,7 +115,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb item.SetProviderId(MetadataProvider.Imdb, result.imdbID); } - ParseAdditionalMetadata(itemResult, result); + ParseAdditionalMetadata(itemResult, result, isEnglishRequested); } /// <summary>Gets data about an episode.</summary> @@ -180,8 +178,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb return false; } + var isEnglishRequested = IsConfiguredForEnglish(item, language); // Only take the name and rating if the user's language is set to English, since Omdb has no localization - if (string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) || _configurationManager.Configuration.EnableNewOmdbSupport) + if (isEnglishRequested) { item.Name = result.Title; @@ -191,9 +190,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb } } - if (!string.IsNullOrEmpty(result.Year) && result.Year.Length >= 4 - && int.TryParse(result.Year.AsSpan().Slice(0, 4), NumberStyles.Number, CultureInfo.InvariantCulture, out var year) - && year >= 0) + if (TryParseYear(result.Year, out var year)) { item.ProductionYear = year; } @@ -229,7 +226,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb item.SetProviderId(MetadataProvider.Imdb, result.imdbID); } - ParseAdditionalMetadata(itemResult, result); + ParseAdditionalMetadata(itemResult, result, isEnglishRequested); return true; } @@ -263,6 +260,30 @@ namespace MediaBrowser.Providers.Plugins.Omdb return Url + "&" + query; } + /// <summary> + /// Extract the year from a string. + /// </summary> + /// <param name="input">The input string.</param> + /// <param name="year">The year.</param> + /// <returns>A value indicating whether the input could successfully be parsed as a year.</returns> + public static bool TryParseYear(string input, [NotNullWhen(true)] out int? year) + { + if (string.IsNullOrEmpty(input)) + { + year = 0; + return false; + } + + if (int.TryParse(input.AsSpan(0, 4), NumberStyles.Number, CultureInfo.InvariantCulture, out var result)) + { + year = result; + return true; + } + + year = 0; + return false; + } + private async Task<string> EnsureItemInfo(string imdbId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(imdbId)) @@ -295,7 +316,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb "i={0}&plot=short&tomatoes=true&r=json", imdbParam)); - var rootObject = await GetDeserializedOmdbResponse<RootObject>(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); + var rootObject = await _httpClientFactory.CreateClient(NamedClient.Default).GetFromJsonAsync<RootObject>(url, _jsonOptions, cancellationToken).ConfigureAwait(false); await using FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false); @@ -335,37 +356,13 @@ namespace MediaBrowser.Providers.Plugins.Omdb imdbParam, seasonId)); - var rootObject = await GetDeserializedOmdbResponse<SeasonRootObject>(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); + var rootObject = await _httpClientFactory.CreateClient(NamedClient.Default).GetFromJsonAsync<SeasonRootObject>(url, _jsonOptions, cancellationToken).ConfigureAwait(false); await using FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false); return path; } - /// <summary>Gets response from OMDB service as type T.</summary> - /// <param name="httpClient">HttpClient instance to use for service call.</param> - /// <param name="url">Http URL to use for service call.</param> - /// <param name="cancellationToken">CancellationToken to use for service call.</param> - /// <typeparam name="T">The first generic type parameter.</typeparam> - /// <returns>OMDB service response as type T.</returns> - public async Task<T> GetDeserializedOmdbResponse<T>(HttpClient httpClient, string url, CancellationToken cancellationToken) - { - using var response = await GetOmdbResponse(httpClient, url, cancellationToken).ConfigureAwait(false); - await using Stream content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - - return await JsonSerializer.DeserializeAsync<T>(content, _jsonOptions, cancellationToken).ConfigureAwait(false); - } - - /// <summary>Gets response from OMDB service.</summary> - /// <param name="httpClient">HttpClient instance to use for service call.</param> - /// <param name="url">Http URL to use for service call.</param> - /// <param name="cancellationToken">CancellationToken to use for service call.</param> - /// <returns>OMDB service response as HttpResponseMessage.</returns> - public static Task<HttpResponseMessage> GetOmdbResponse(HttpClient httpClient, string url, CancellationToken cancellationToken) - { - return httpClient.GetAsync(url, cancellationToken); - } - internal string GetDataFilePath(string imdbId) { if (string.IsNullOrEmpty(imdbId)) @@ -394,31 +391,25 @@ namespace MediaBrowser.Providers.Plugins.Omdb return Path.Combine(dataPath, filename); } - private void ParseAdditionalMetadata<T>(MetadataResult<T> itemResult, RootObject result) + private static void ParseAdditionalMetadata<T>(MetadataResult<T> itemResult, RootObject result, bool isEnglishRequested) where T : BaseItem { var item = itemResult.Item; - var isConfiguredForEnglish = IsConfiguredForEnglish(item) || _configurationManager.Configuration.EnableNewOmdbSupport; - // Grab series genres because IMDb data is better than TVDB. Leave movies alone // But only do it if English is the preferred language because this data will not be localized - if (isConfiguredForEnglish && !string.IsNullOrWhiteSpace(result.Genre)) + if (isEnglishRequested && !string.IsNullOrWhiteSpace(result.Genre)) { item.Genres = Array.Empty<string>(); - foreach (var genre in result.Genre - .Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(i => i.Trim()) - .Where(i => !string.IsNullOrWhiteSpace(i))) + foreach (var genre in result.Genre.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { item.AddGenre(genre); } } - if (isConfiguredForEnglish) + if (isEnglishRequested) { - // Omdb is currently English only, so for other languages skip this and let secondary providers fill it in item.Overview = result.Plot; } @@ -431,7 +422,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb { var person = new PersonInfo { - Name = result.Director.Trim(), + Name = result.Director, Type = PersonType.Director }; @@ -442,7 +433,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb { var person = new PersonInfo { - Name = result.Writer.Trim(), + Name = result.Writer, Type = PersonType.Writer }; @@ -451,29 +442,34 @@ namespace MediaBrowser.Providers.Plugins.Omdb if (!string.IsNullOrWhiteSpace(result.Actors)) { - var actorList = result.Actors.Split(','); + var actorList = result.Actors.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); foreach (var actor in actorList) { - if (!string.IsNullOrWhiteSpace(actor)) + if (string.IsNullOrWhiteSpace(actor)) { - var person = new PersonInfo - { - Name = actor.Trim(), - Type = PersonType.Actor - }; - - itemResult.AddPerson(person); + continue; } + + var person = new PersonInfo + { + Name = actor, + Type = PersonType.Actor + }; + + itemResult.AddPerson(person); } } } - private bool IsConfiguredForEnglish(BaseItem item) + private static bool IsConfiguredForEnglish(BaseItem item, string language) { - var lang = item.GetPreferredMetadataLanguage(); + if (string.IsNullOrEmpty(language)) + { + language = item.GetPreferredMetadataLanguage(); + } // The data isn't localized and so can only be used for English users - return string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase); + return string.Equals(language, "en", StringComparison.OrdinalIgnoreCase); } internal class SeasonRootObject @@ -550,7 +546,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb if (Ratings != null) { var rating = Ratings.FirstOrDefault(i => string.Equals(i.Source, "Rotten Tomatoes", StringComparison.OrdinalIgnoreCase)); - if (rating != null && rating.Value != null) + if (rating?.Value != null) { var value = rating.Value.TrimEnd('%'); if (float.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var score)) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs b/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs new file mode 100644 index 0000000000..0bab7c3cad --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs @@ -0,0 +1,41 @@ +using System.Net.Mime; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TMDbLib.Objects.General; + +namespace MediaBrowser.Providers.Plugins.Tmdb.Api +{ + /// <summary> + /// The TMDb api controller. + /// </summary> + [ApiController] + [Authorize(Policy = "DefaultAuthorization")] + [Route("[controller]")] + [Produces(MediaTypeNames.Application.Json)] + public class TmdbController : ControllerBase + { + private readonly TmdbClientManager _tmdbClientManager; + + /// <summary> + /// Initializes a new instance of the <see cref="TmdbController"/> class. + /// </summary> + /// <param name="tmdbClientManager">The TMDb client manager.</param> + public TmdbController(TmdbClientManager tmdbClientManager) + { + _tmdbClientManager = tmdbClientManager; + } + + /// <summary> + /// Gets the TMDb image configuration options. + /// </summary> + /// <returns>The image portion of the TMDb client configuration.</returns> + [HttpGet("ClientConfiguration")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ConfigImageTypes> TmdbClientConfiguration() + { + return (await _tmdbClientManager.GetClientConfiguration().ConfigureAwait(false)).Images; + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs index 9a78a75362..dec7961484 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs @@ -26,5 +26,25 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// Gets or sets a value indicating the maximum number of cast members to fetch for an item. /// </summary> public int MaxCastMembers { get; set; } = 15; + + /// <summary> + /// Gets or sets a value indicating the poster image size to fetch. + /// </summary> + public string? PosterSize { get; set; } + + /// <summary> + /// Gets or sets a value indicating the backdrop image size to fetch. + /// </summary> + public string? BackdropSize { get; set; } + + /// <summary> + /// Gets or sets a value indicating the profile image size to fetch. + /// </summary> + public string? ProfileSize { get; set; } + + /// <summary> + /// Gets or sets a value indicating the still image size to fetch. + /// </summary> + public string? StillSize { get; set; } } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html index 12b4c7ca4e..52693795b5 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html @@ -24,7 +24,21 @@ <input is="emby-input" type="number" id="maxCastMembers" pattern="[0-9]*" required min="0" max="1000" label="Max Cast Members" /> <div class="fieldDescription">The maximum number of cast members to fetch for an item.</div> </div> - <br /> + <div class="verticalSection verticalSection-extrabottompadding"> + <h2>Image Scaling</h2> + <div class="selectContainer"> + <select is="emby-select" id="selectPosterSize" label="Poster"></select> + </div> + <div class="selectContainer"> + <select is="emby-select" id="selectBackdropSize" label="Backdrop"></select> + </div> + <div class="selectContainer"> + <select is="emby-select" id="selectProfileSize" label="Profile"></select> + </div> + <div class="selectContainer"> + <select is="emby-select" id="selectStillSize" label="Still"></select> + </div> + </div> <div> <button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button> </div> @@ -39,6 +53,47 @@ document.querySelector('.configPage') .addEventListener('pageshow', function () { Dashboard.showLoadingMsg(); + + var clientConfig, pluginConfig; + var configureImageScaling = function() { + if (clientConfig === null || pluginConfig === null) { + return; + } + + var sizeOptionsGenerator = function (size) { + return '<option value="' + size + '">' + size + '</option>'; + } + + var selPosterSize = document.querySelector('#selectPosterSize'); + selPosterSize.innerHTML = clientConfig.PosterSizes.map(sizeOptionsGenerator); + selPosterSize.value = pluginConfig.PosterSize; + + var selBackdropSize = document.querySelector('#selectBackdropSize'); + selBackdropSize.innerHTML = clientConfig.BackdropSizes.map(sizeOptionsGenerator); + selBackdropSize.value = pluginConfig.BackdropSize; + + var selProfileSize = document.querySelector('#selectProfileSize'); + selProfileSize.innerHTML = clientConfig.ProfileSizes.map(sizeOptionsGenerator); + selProfileSize.value = pluginConfig.ProfileSize; + + var selStillSize = document.querySelector('#selectStillSize'); + selStillSize.innerHTML = clientConfig.StillSizes.map(sizeOptionsGenerator); + selStillSize.value = pluginConfig.StillSize; + + Dashboard.hideLoadingMsg(); + } + + const request = { + url: ApiClient.getUrl('tmdb/ClientConfiguration'), + dataType: 'json', + type: 'GET', + headers: { accept: 'application/json' } + } + ApiClient.fetch(request).then(function (config) { + clientConfig = config; + configureImageScaling(); + }); + ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) { document.querySelector('#includeAdult').checked = config.IncludeAdult; document.querySelector('#excludeTagsSeries').checked = config.ExcludeTagsSeries; @@ -51,7 +106,8 @@ cancelable: false })); - Dashboard.hideLoadingMsg(); + pluginConfig = config; + configureImageScaling(); }); }); @@ -65,6 +121,10 @@ config.ExcludeTagsSeries = document.querySelector('#excludeTagsSeries').checked; config.ExcludeTagsMovies = document.querySelector('#excludeTagsMovies').checked; config.MaxCastMembers = document.querySelector('#maxCastMembers').value; + config.PosterSize = document.querySelector('#selectPosterSize').value; + config.BackdropSize = document.querySelector('#selectBackdropSize').value; + config.ProfileSize = document.querySelector('#selectProfileSize').value; + config.StillSize = document.querySelector('#selectStillSize').value; ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); }); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index fcaacc90d1..e4a56fde94 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -279,8 +280,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies // Normalize this var type = TmdbUtils.MapCrewToPersonType(person); - if (!keepTypes.Contains(type, StringComparer.OrdinalIgnoreCase) && - !keepTypes.Contains(person.Job ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (!keepTypes.Contains(type, StringComparison.OrdinalIgnoreCase) && + !keepTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index 8ac9d0cab1..f50f158772 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; @@ -188,8 +189,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // Normalize this var type = TmdbUtils.MapCrewToPersonType(person); - if (!TmdbUtils.WantedCrewTypes.Contains(type, StringComparer.OrdinalIgnoreCase) - && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (!TmdbUtils.WantedCrewTypes.Contains(type, StringComparison.OrdinalIgnoreCase) + && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 7afaddc245..27c52a5a22 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; @@ -87,8 +88,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // Normalize this var type = TmdbUtils.MapCrewToPersonType(person); - if (!TmdbUtils.WantedCrewTypes.Contains(type, StringComparer.OrdinalIgnoreCase) - && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (!TmdbUtils.WantedCrewTypes.Contains(type, StringComparison.OrdinalIgnoreCase) + && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 77e22ffbf0..f565b65698 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; @@ -365,8 +366,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // Normalize this var type = TmdbUtils.MapCrewToPersonType(person); - if (!keepTypes.Contains(type, StringComparer.OrdinalIgnoreCase) - && !keepTypes.Contains(person.Job ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (!keepTypes.Contains(type, StringComparison.OrdinalIgnoreCase) + && !keepTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index cb644c8ca1..28d6f4d0c1 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -498,7 +498,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return null; } - return _tmDbClient.GetImageUrl(size, path).ToString(); + return _tmDbClient.GetImageUrl(size, path, true).ToString(); } /// <summary> @@ -508,7 +508,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <returns>The absolute URL.</returns> public string GetPosterUrl(string posterPath) { - return GetUrl(_tmDbClient.Config.Images.PosterSizes[^1], posterPath); + return GetUrl(Plugin.Instance.Configuration.PosterSize, posterPath); } /// <summary> @@ -518,7 +518,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <returns>The absolute URL.</returns> public string GetProfileUrl(string actorProfilePath) { - return GetUrl(_tmDbClient.Config.Images.ProfileSizes[^1], actorProfilePath); + return GetUrl(Plugin.Instance.Configuration.ProfileSize, actorProfilePath); } /// <summary> @@ -529,7 +529,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="results">The collection to add the remote images into.</param> public void ConvertPostersToRemoteImageInfo(List<ImageData> images, string requestLanguage, List<RemoteImageInfo> results) { - ConvertToRemoteImageInfo(images, _tmDbClient.Config.Images.PosterSizes[^1], ImageType.Primary, requestLanguage, results); + ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.PosterSize, ImageType.Primary, requestLanguage, results); } /// <summary> @@ -540,7 +540,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="results">The collection to add the remote images into.</param> public void ConvertBackdropsToRemoteImageInfo(List<ImageData> images, string requestLanguage, List<RemoteImageInfo> results) { - ConvertToRemoteImageInfo(images, _tmDbClient.Config.Images.BackdropSizes[^1], ImageType.Backdrop, requestLanguage, results); + ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.BackdropSize, ImageType.Backdrop, requestLanguage, results); } /// <summary> @@ -551,7 +551,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="results">The collection to add the remote images into.</param> public void ConvertProfilesToRemoteImageInfo(List<ImageData> images, string requestLanguage, List<RemoteImageInfo> results) { - ConvertToRemoteImageInfo(images, _tmDbClient.Config.Images.ProfileSizes[^1], ImageType.Primary, requestLanguage, results); + ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.ProfileSize, ImageType.Primary, requestLanguage, results); } /// <summary> @@ -562,7 +562,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="results">The collection to add the remote images into.</param> public void ConvertStillsToRemoteImageInfo(List<ImageData> images, string requestLanguage, List<RemoteImageInfo> results) { - ConvertToRemoteImageInfo(images, _tmDbClient.Config.Images.StillSizes[^1], ImageType.Primary, requestLanguage, results); + ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.StillSize, ImageType.Primary, requestLanguage, results); } /// <summary> @@ -575,16 +575,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="results">The collection to add the remote images into.</param> private void ConvertToRemoteImageInfo(List<ImageData> images, string size, ImageType type, string requestLanguage, List<RemoteImageInfo> results) { + // sizes provided are for original resolution, don't store them when downloading scaled images + var scaleImage = !string.Equals(size, "original", StringComparison.OrdinalIgnoreCase); + for (var i = 0; i < images.Count; i++) { var image = images[i]; + results.Add(new RemoteImageInfo { Url = GetUrl(size, image.FilePath), CommunityRating = image.VoteAverage, VoteCount = image.VoteCount, - Width = image.Width, - Height = image.Height, + Width = scaleImage ? null : image.Width, + Height = scaleImage ? null : image.Height, Language = TmdbUtils.AdjustImageLanguage(image.Iso_639_1, requestLanguage), ProviderName = TmdbUtils.ProviderName, Type = type, @@ -593,9 +597,51 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } } - private Task EnsureClientConfigAsync() + private async Task EnsureClientConfigAsync() + { + if (!_tmDbClient.HasConfig) + { + var config = await _tmDbClient.GetConfigAsync().ConfigureAwait(false); + ValidatePreferences(config); + } + } + + private static void ValidatePreferences(TMDbConfig config) { - return !_tmDbClient.HasConfig ? _tmDbClient.GetConfigAsync() : Task.CompletedTask; + var imageConfig = config.Images; + + var pluginConfig = Plugin.Instance.Configuration; + + if (!imageConfig.PosterSizes.Contains(pluginConfig.PosterSize)) + { + pluginConfig.PosterSize = imageConfig.PosterSizes[^1]; + } + + if (!imageConfig.BackdropSizes.Contains(pluginConfig.BackdropSize)) + { + pluginConfig.BackdropSize = imageConfig.BackdropSizes[^1]; + } + + if (!imageConfig.ProfileSizes.Contains(pluginConfig.ProfileSize)) + { + pluginConfig.ProfileSize = imageConfig.ProfileSizes[^1]; + } + + if (!imageConfig.StillSizes.Contains(pluginConfig.StillSize)) + { + pluginConfig.StillSize = imageConfig.StillSizes[^1]; + } + } + + /// <summary> + /// Gets the <see cref="TMDbClient"/> configuration. + /// </summary> + /// <returns>The configuration.</returns> + public async Task<TMDbConfig> GetClientConfiguration() + { + await EnsureClientConfigAsync().ConfigureAwait(false); + + return _tmDbClient.Config; } /// <inheritdoc /> diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index c2b420c331..34019e5824 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -9,6 +9,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -76,8 +77,7 @@ namespace MediaBrowser.Providers.Subtitles var contentType = request.ContentType; var providers = _subtitleProviders - .Where(i => i.SupportedMediaTypes.Contains(contentType)) - .Where(i => !request.DisabledSubtitleFetchers.Contains(i.Name, StringComparer.OrdinalIgnoreCase)) + .Where(i => i.SupportedMediaTypes.Contains(contentType) && !request.DisabledSubtitleFetchers.Contains(i.Name, StringComparison.OrdinalIgnoreCase)) .OrderBy(i => { var index = request.SubtitleFetcherOrder.ToList().IndexOf(i.Name); diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index 9d223b4b66..770dc3e003 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -130,11 +130,12 @@ namespace MediaBrowser.Providers.TV /// <returns>The async task.</returns> private async Task FillInMissingSeasonsAsync(Series series, CancellationToken cancellationToken) { - var episodesInSeriesFolder = series.GetRecursiveChildren(i => i is Episode) - .Cast<Episode>() + var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season); + var episodesInSeriesFolder = seriesChildren + .OfType<Episode>() .Where(i => !i.IsInSeasonFolder); - List<Season> seasons = series.Children.OfType<Season>().ToList(); + List<Season> seasons = seriesChildren.OfType<Season>().ToList(); // Loop through the unique season numbers foreach (var episode in episodesInSeriesFolder) |
