Browse Source

Merge pull request #6689 from 1337joe/expand-image-extraction

Claus Vium 3 years ago
parent
commit
768ec60e11

+ 2 - 1
MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs

@@ -95,9 +95,10 @@ namespace MediaBrowser.Controller.MediaEncoding
         /// <param name="mediaSource">Media source information.</param>
         /// <param name="imageStream">Media stream information.</param>
         /// <param name="imageStreamIndex">Index of the stream to extract from.</param>
+        /// <param name="outputExtension">The extension of the file to write, including the '.'.</param>
         /// <param name="cancellationToken">CancellationToken to use for operation.</param>
         /// <returns>Location of video image.</returns>
-        Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, CancellationToken cancellationToken);
+        Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, string outputExtension, CancellationToken cancellationToken);
 
         /// <summary>
         /// Extracts the video images on interval.

+ 20 - 10
MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs

@@ -469,17 +469,17 @@ namespace MediaBrowser.MediaEncoding.Encoder
                 Protocol = MediaProtocol.File
             };
 
-            return ExtractImage(path, null, null, imageStreamIndex, mediaSource, true, null, null, cancellationToken);
+            return ExtractImage(path, null, null, imageStreamIndex, mediaSource, true, null, null, ".jpg", cancellationToken);
         }
 
         public Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream videoStream, Video3DFormat? threedFormat, TimeSpan? offset, CancellationToken cancellationToken)
         {
-            return ExtractImage(inputFile, container, videoStream, null, mediaSource, false, threedFormat, offset, cancellationToken);
+            return ExtractImage(inputFile, container, videoStream, null, mediaSource, false, threedFormat, offset, ".jpg", cancellationToken);
         }
 
-        public Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, CancellationToken cancellationToken)
+        public Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, string outputExtension, CancellationToken cancellationToken)
         {
-            return ExtractImage(inputFile, container, imageStream, imageStreamIndex, mediaSource, false, null, null, cancellationToken);
+            return ExtractImage(inputFile, container, imageStream, imageStreamIndex, mediaSource, false, null, null, outputExtension, cancellationToken);
         }
 
         private async Task<string> ExtractImage(
@@ -491,6 +491,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
             bool isAudio,
             Video3DFormat? threedFormat,
             TimeSpan? offset,
+            string outputExtension,
             CancellationToken cancellationToken)
         {
             var inputArgument = GetInputArgument(inputFile, mediaSource);
@@ -500,7 +501,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
                 // The failure of HDR extraction usually occurs when using custom ffmpeg that does not contain the zscale filter.
                 try
                 {
-                    return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, true, cancellationToken).ConfigureAwait(false);
+                    return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, true, outputExtension, cancellationToken).ConfigureAwait(false);
                 }
                 catch (ArgumentException)
                 {
@@ -513,7 +514,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
 
                 try
                 {
-                    return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, true, cancellationToken).ConfigureAwait(false);
+                    return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, true, outputExtension, cancellationToken).ConfigureAwait(false);
                 }
                 catch (ArgumentException)
                 {
@@ -526,7 +527,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
 
                 try
                 {
-                    return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, false, cancellationToken).ConfigureAwait(false);
+                    return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, false, outputExtension, cancellationToken).ConfigureAwait(false);
                 }
                 catch (ArgumentException)
                 {
@@ -538,17 +539,26 @@ namespace MediaBrowser.MediaEncoding.Encoder
                 }
             }
 
-            return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, false, cancellationToken).ConfigureAwait(false);
+            return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, false, outputExtension, cancellationToken).ConfigureAwait(false);
         }
 
-        private async Task<string> ExtractImageInternal(string inputPath, string container, MediaStream videoStream, int? imageStreamIndex, Video3DFormat? threedFormat, TimeSpan? offset, bool useIFrame, bool allowTonemap, CancellationToken cancellationToken)
+        private async Task<string> ExtractImageInternal(string inputPath, string container, MediaStream videoStream, int? imageStreamIndex, Video3DFormat? threedFormat, TimeSpan? offset, bool useIFrame, bool allowTonemap, string outputExtension, CancellationToken cancellationToken)
         {
             if (string.IsNullOrEmpty(inputPath))
             {
                 throw new ArgumentNullException(nameof(inputPath));
             }
 
-            var tempExtractPath = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid() + ".jpg");
+            if (string.IsNullOrEmpty(outputExtension))
+            {
+                outputExtension = ".jpg";
+            }
+            else if (outputExtension[0] != '.')
+            {
+                outputExtension = "." + outputExtension;
+            }
+
+            var tempExtractPath = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid() + outputExtension);
             Directory.CreateDirectory(Path.GetDirectoryName(tempExtractPath));
 
             // apply some filters to thumbnail extracted below (below) crop any black lines that we made and get the correct ar.

+ 2 - 1
MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs

@@ -582,7 +582,8 @@ namespace MediaBrowser.MediaEncoding.Probing
         /// <returns>MediaAttachments.</returns>
         private MediaAttachment GetMediaAttachment(MediaStreamInfo streamInfo)
         {
-            if (!string.Equals(streamInfo.CodecType, "attachment", StringComparison.OrdinalIgnoreCase))
+            if (!string.Equals(streamInfo.CodecType, "attachment", StringComparison.OrdinalIgnoreCase)
+                && streamInfo.Disposition?.GetValueOrDefault("attached_pic") != 1)
             {
                 return null;
             }

+ 220 - 0
MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs

@@ -0,0 +1,220 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Drawing;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Net;
+
+namespace MediaBrowser.Providers.MediaInfo
+{
+    /// <summary>
+    /// Uses <see cref="IMediaEncoder"/> to extract embedded images.
+    /// </summary>
+    public class EmbeddedImageProvider : IDynamicImageProvider, IHasOrder
+    {
+        private static readonly string[] _primaryImageFileNames =
+        {
+            "poster",
+            "folder",
+            "cover",
+            "default"
+        };
+
+        private static readonly string[] _backdropImageFileNames =
+        {
+            "backdrop",
+            "fanart",
+            "background",
+            "art"
+        };
+
+        private static readonly string[] _logoImageFileNames =
+        {
+            "logo",
+        };
+
+        private readonly IMediaEncoder _mediaEncoder;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="EmbeddedImageProvider"/> class.
+        /// </summary>
+        /// <param name="mediaEncoder">The media encoder for extracting attached/embedded images.</param>
+        public EmbeddedImageProvider(IMediaEncoder mediaEncoder)
+        {
+            _mediaEncoder = mediaEncoder;
+        }
+
+        /// <inheritdoc />
+        public string Name => "Embedded Image Extractor";
+
+        /// <inheritdoc />
+        // Default to after internet image providers but before Screen Grabber
+        public int Order => 99;
+
+        /// <inheritdoc />
+        public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
+        {
+            if (item is Video)
+            {
+                if (item is Episode)
+                {
+                    return new[]
+                    {
+                        ImageType.Primary,
+                    };
+                }
+
+                return new[]
+                {
+                    ImageType.Primary,
+                    ImageType.Backdrop,
+                    ImageType.Logo,
+                };
+            }
+
+            return Array.Empty<ImageType>();
+        }
+
+        /// <inheritdoc />
+        public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken)
+        {
+            var video = (Video)item;
+
+            // No support for these
+            if (video.IsPlaceHolder || video.VideoType == VideoType.Dvd)
+            {
+                return Task.FromResult(new DynamicImageResponse { HasImage = false });
+            }
+
+            return GetEmbeddedImage(video, type, cancellationToken);
+        }
+
+        private async Task<DynamicImageResponse> GetEmbeddedImage(Video item, ImageType type, CancellationToken cancellationToken)
+        {
+            MediaSourceInfo mediaSource = new MediaSourceInfo
+            {
+                VideoType = item.VideoType,
+                IsoType = item.IsoType,
+                Protocol = item.PathProtocol ?? MediaProtocol.File,
+            };
+
+            string[] imageFileNames = type switch
+            {
+                ImageType.Primary => _primaryImageFileNames,
+                ImageType.Backdrop => _backdropImageFileNames,
+                ImageType.Logo => _logoImageFileNames,
+                _ => _primaryImageFileNames
+            };
+
+            // Try attachments first
+            var attachmentStream = item.GetMediaSources(false)
+                .SelectMany(source => source.MediaAttachments)
+                .FirstOrDefault(attachment => !string.IsNullOrEmpty(attachment.FileName)
+                    && imageFileNames.Any(name => attachment.FileName.Contains(name, StringComparison.OrdinalIgnoreCase)));
+
+            if (attachmentStream != null)
+            {
+                return await ExtractAttachment(item, cancellationToken, attachmentStream, mediaSource);
+            }
+
+            // Fall back to EmbeddedImage streams
+            var imageStreams = item.GetMediaStreams().FindAll(i => i.Type == MediaStreamType.EmbeddedImage);
+
+            if (imageStreams.Count == 0)
+            {
+                // Can't extract if we don't have any EmbeddedImage streams
+                return new DynamicImageResponse { HasImage = false };
+            }
+
+            // Extract first stream containing an element of imageFileNames
+            var imageStream = imageStreams
+                .FirstOrDefault(stream => !string.IsNullOrEmpty(stream.Comment)
+                    && imageFileNames.Any(name => stream.Comment.Contains(name, StringComparison.OrdinalIgnoreCase)));
+
+            // Primary type only: default to first image if none found by label
+            if (imageStream == null)
+            {
+                if (type == ImageType.Primary)
+                {
+                    imageStream = imageStreams[0];
+                }
+                else
+                {
+                    // No streams matched, abort
+                    return new DynamicImageResponse { HasImage = false };
+                }
+            }
+
+            string extractedImagePath =
+                await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, imageStream, imageStream.Index, ".jpg", cancellationToken)
+                    .ConfigureAwait(false);
+
+            return new DynamicImageResponse
+            {
+                Format = ImageFormat.Jpg,
+                HasImage = true,
+                Path = extractedImagePath,
+                Protocol = MediaProtocol.File
+            };
+        }
+
+        private async Task<DynamicImageResponse> ExtractAttachment(Video item, CancellationToken cancellationToken, MediaAttachment attachmentStream, MediaSourceInfo mediaSource)
+        {
+            var extension = string.IsNullOrEmpty(attachmentStream.MimeType)
+                ? Path.GetExtension(attachmentStream.FileName)
+                : MimeTypes.ToExtension(attachmentStream.MimeType);
+
+            if (string.IsNullOrEmpty(extension))
+            {
+                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,
+                ".gif" => ImageFormat.Gif,
+                ".jpg" => ImageFormat.Jpg,
+                ".png" => ImageFormat.Png,
+                ".webp" => ImageFormat.Webp,
+                _ => ImageFormat.Jpg
+            };
+
+            return new DynamicImageResponse
+            {
+                Format = format,
+                HasImage = true,
+                Path = extractedAttachmentPath,
+                Protocol = MediaProtocol.File
+            };
+        }
+
+        /// <inheritdoc />
+        public bool Supports(BaseItem item)
+        {
+            if (item.IsShortcut)
+            {
+                return false;
+            }
+
+            if (!item.IsFileProtocol)
+            {
+                return false;
+            }
+
+            return item is Video video && !video.IsPlaceHolder && video.IsCompleteMedia;
+        }
+    }
+}

+ 19 - 28
MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs

@@ -1,6 +1,3 @@
-#nullable enable
-#pragma warning disable CS1591
-
 using System;
 using System.Collections.Generic;
 using System.Linq;
@@ -17,11 +14,19 @@ using Microsoft.Extensions.Logging;
 
 namespace MediaBrowser.Providers.MediaInfo
 {
+    /// <summary>
+    /// Uses <see cref="IMediaEncoder"/> to create still images from the main video.
+    /// </summary>
     public class VideoImageProvider : IDynamicImageProvider, IHasOrder
     {
         private readonly IMediaEncoder _mediaEncoder;
         private readonly ILogger<VideoImageProvider> _logger;
 
+        /// <summary>
+        /// Initializes a new instance of the <see cref="VideoImageProvider"/> class.
+        /// </summary>
+        /// <param name="mediaEncoder">The media encoder for capturing images.</param>
+        /// <param name="logger">The logger.</param>
         public VideoImageProvider(IMediaEncoder mediaEncoder, ILogger<VideoImageProvider> logger)
         {
             _mediaEncoder = mediaEncoder;
@@ -71,36 +76,22 @@ namespace MediaBrowser.Providers.MediaInfo
                 Protocol = item.PathProtocol ?? MediaProtocol.File,
             };
 
-            var mediaStreams =
-                item.GetMediaStreams();
-
-            var imageStreams =
-                mediaStreams
-                    .Where(i => i.Type == MediaStreamType.EmbeddedImage)
-                    .ToList();
+            // 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
+                                  ? TimeSpan.FromTicks(item.RunTimeTicks.Value / 10)
+                                  : TimeSpan.FromSeconds(10);
 
-            string extractedImagePath;
+            var videoStream = item.GetDefaultVideoStream() ?? item.GetMediaStreams().FirstOrDefault(i => i.Type == MediaStreamType.Video);
 
-            if (imageStreams.Count == 0)
+            if (videoStream == null)
             {
-                // 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
-                                      ? TimeSpan.FromTicks(item.RunTimeTicks.Value / 10)
-                                      : TimeSpan.FromSeconds(10);
-
-                var videoStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video);
-                extractedImagePath = await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, videoStream, item.Video3DFormat, imageOffset, cancellationToken).ConfigureAwait(false);
+                _logger.LogInformation("Skipping image extraction: no video stream found for {Path}.", item.Path ?? string.Empty);
+                return new DynamicImageResponse { HasImage = false };
             }
-            else
-            {
-                var imageStream = imageStreams.Find(i => (i.Comment ?? string.Empty).Contains("front", StringComparison.OrdinalIgnoreCase))
-                    ?? imageStreams.Find(i => (i.Comment ?? string.Empty).Contains("cover", StringComparison.OrdinalIgnoreCase))
-                    ?? imageStreams[0];
 
-                extractedImagePath = await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, imageStream, imageStream.Index, cancellationToken).ConfigureAwait(false);
-            }
+            string extractedImagePath = await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, videoStream, item.Video3DFormat, imageOffset, cancellationToken).ConfigureAwait(false);
 
             return new DynamicImageResponse
             {

+ 216 - 0
tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs

@@ -0,0 +1,216 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Drawing;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.MediaInfo;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.MediaInfo
+{
+    public class EmbeddedImageProviderTests
+    {
+        private static TheoryData<BaseItem> GetSupportedImages_UnsupportedBaseItems_ReturnsEmpty_TestData()
+        {
+            return new ()
+            {
+                new AudioBook(),
+                new BoxSet(),
+                new Series(),
+                new Season(),
+            };
+        }
+
+        [Theory]
+        [MemberData(nameof(GetSupportedImages_UnsupportedBaseItems_ReturnsEmpty_TestData))]
+        public void GetSupportedImages_UnsupportedBaseItems_ReturnsEmpty(BaseItem item)
+        {
+            var embeddedImageProvider = GetEmbeddedImageProvider(null);
+            Assert.Empty(embeddedImageProvider.GetSupportedImages(item));
+        }
+
+        private static TheoryData<BaseItem, IEnumerable<ImageType>> GetSupportedImages_SupportedBaseItems_ReturnsPopulated_TestData()
+        {
+            return new TheoryData<BaseItem, IEnumerable<ImageType>>
+            {
+                { new Episode(), new List<ImageType> { ImageType.Primary } },
+                { new Movie(), new List<ImageType> { ImageType.Logo, ImageType.Backdrop, ImageType.Primary } },
+            };
+        }
+
+        [Theory]
+        [MemberData(nameof(GetSupportedImages_SupportedBaseItems_ReturnsPopulated_TestData))]
+        public void GetSupportedImages_SupportedBaseItems_ReturnsPopulated(BaseItem item, IEnumerable<ImageType> expected)
+        {
+            var embeddedImageProvider = GetEmbeddedImageProvider(null);
+            var actual = embeddedImageProvider.GetSupportedImages(item);
+            Assert.Equal(expected.OrderBy(i => i.ToString()), actual.OrderBy(i => i.ToString()));
+        }
+
+        [Fact]
+        public async void GetImage_InputWithNoStreams_ReturnsNoImage()
+        {
+            var embeddedImageProvider = GetEmbeddedImageProvider(null);
+
+            var input = GetMovie(new List<MediaAttachment>(), new List<MediaStream>());
+
+            var actual = await embeddedImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None);
+            Assert.NotNull(actual);
+            Assert.False(actual.HasImage);
+        }
+
+        [Fact]
+        public async void GetImage_InputWithUnlabeledAttachments_ReturnsNoImage()
+        {
+            var embeddedImageProvider = GetEmbeddedImageProvider(null);
+
+            // add an attachment without a filename - has a list to look through but finds nothing
+            var input = GetMovie(
+                new List<MediaAttachment> { new () },
+                new List<MediaStream>());
+
+            var actual = await embeddedImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None);
+            Assert.NotNull(actual);
+            Assert.False(actual.HasImage);
+        }
+
+        [Fact]
+        public async void GetImage_InputWithLabeledAttachments_ReturnsCorrectSelection()
+        {
+            // first tests file extension detection, second uses mimetype, third defaults to jpg
+            MediaAttachment sampleAttachment1 = new () { FileName = "clearlogo.png", Index = 1 };
+            MediaAttachment sampleAttachment2 = new () { FileName = "backdrop", MimeType = "image/bmp", Index = 2 };
+            MediaAttachment sampleAttachment3 = new () { FileName = "poster", Index = 3 };
+            string targetPath1 = "path1.png";
+            string targetPath2 = "path2.bmp";
+            string targetPath3 = "path2.jpg";
+
+            var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict);
+            mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MediaSourceInfo>(), It.IsAny<MediaStream>(), 1, ".png", CancellationToken.None))
+                .Returns(Task.FromResult(targetPath1));
+            mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MediaSourceInfo>(), It.IsAny<MediaStream>(), 2, ".bmp", CancellationToken.None))
+                .Returns(Task.FromResult(targetPath2));
+            mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MediaSourceInfo>(), It.IsAny<MediaStream>(), 3, ".jpg", CancellationToken.None))
+                .Returns(Task.FromResult(targetPath3));
+            var embeddedImageProvider = GetEmbeddedImageProvider(mediaEncoder.Object);
+
+            var input = GetMovie(
+                new List<MediaAttachment> { sampleAttachment1, sampleAttachment2, sampleAttachment3 },
+                new List<MediaStream>());
+
+            var actualLogo = await embeddedImageProvider.GetImage(input, ImageType.Logo, CancellationToken.None);
+            Assert.NotNull(actualLogo);
+            Assert.True(actualLogo.HasImage);
+            Assert.Equal(targetPath1, actualLogo.Path);
+            Assert.Equal(ImageFormat.Png, actualLogo.Format);
+
+            var actualBackdrop = await embeddedImageProvider.GetImage(input, ImageType.Backdrop, CancellationToken.None);
+            Assert.NotNull(actualBackdrop);
+            Assert.True(actualBackdrop.HasImage);
+            Assert.Equal(targetPath2, actualBackdrop.Path);
+            Assert.Equal(ImageFormat.Bmp, actualBackdrop.Format);
+
+            var actualPrimary = await embeddedImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None);
+            Assert.NotNull(actualPrimary);
+            Assert.True(actualPrimary.HasImage);
+            Assert.Equal(targetPath3, actualPrimary.Path);
+            Assert.Equal(ImageFormat.Jpg, actualPrimary.Format);
+        }
+
+        [Fact]
+        public async void GetImage_InputWithUnlabeledEmbeddedImages_BackdropReturnsNoImage()
+        {
+            var embeddedImageProvider = GetEmbeddedImageProvider(null);
+
+            var input = GetMovie(
+                new List<MediaAttachment>(),
+                new List<MediaStream> { new () { Type = MediaStreamType.EmbeddedImage } });
+
+            var actual = await embeddedImageProvider.GetImage(input, ImageType.Backdrop, CancellationToken.None);
+            Assert.NotNull(actual);
+            Assert.False(actual.HasImage);
+        }
+
+        [Fact]
+        public async void GetImage_InputWithUnlabeledEmbeddedImages_PrimaryReturnsImage()
+        {
+            MediaStream sampleStream = new () { Type = MediaStreamType.EmbeddedImage, Index = 1 };
+            string targetPath = "path";
+
+            var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict);
+            mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MediaSourceInfo>(), sampleStream, 1, ".jpg", CancellationToken.None))
+                .Returns(Task.FromResult(targetPath));
+            var embeddedImageProvider = GetEmbeddedImageProvider(mediaEncoder.Object);
+
+            var input = GetMovie(
+                new List<MediaAttachment>(),
+                new List<MediaStream> { sampleStream });
+
+            var actual = await embeddedImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None);
+            Assert.NotNull(actual);
+            Assert.True(actual.HasImage);
+            Assert.Equal(targetPath, actual.Path);
+            Assert.Equal(ImageFormat.Jpg, actual.Format);
+        }
+
+        [Fact]
+        public async void GetImage_InputWithLabeledEmbeddedImages_ReturnsCorrectSelection()
+        {
+            // primary is second stream to ensure it's not defaulting, backdrop is first
+            MediaStream sampleStream1 = new () { Type = MediaStreamType.EmbeddedImage, Index = 1, Comment = "backdrop" };
+            MediaStream sampleStream2 = new () { Type = MediaStreamType.EmbeddedImage, Index = 2, Comment = "cover" };
+            string targetPath1 = "path1.jpg";
+            string targetPath2 = "path2.jpg";
+
+            var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict);
+            mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MediaSourceInfo>(), sampleStream1, 1, ".jpg", CancellationToken.None))
+                .Returns(Task.FromResult(targetPath1));
+            mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MediaSourceInfo>(), sampleStream2, 2, ".jpg", CancellationToken.None))
+                .Returns(Task.FromResult(targetPath2));
+            var embeddedImageProvider = GetEmbeddedImageProvider(mediaEncoder.Object);
+
+            var input = GetMovie(
+                new List<MediaAttachment>(),
+                new List<MediaStream> { sampleStream1, sampleStream2 });
+
+            var actualPrimary = await embeddedImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None);
+            Assert.NotNull(actualPrimary);
+            Assert.True(actualPrimary.HasImage);
+            Assert.Equal(targetPath2, actualPrimary.Path);
+            Assert.Equal(ImageFormat.Jpg, actualPrimary.Format);
+
+            var actualBackdrop = await embeddedImageProvider.GetImage(input, ImageType.Backdrop, CancellationToken.None);
+            Assert.NotNull(actualBackdrop);
+            Assert.True(actualBackdrop.HasImage);
+            Assert.Equal(targetPath1, actualBackdrop.Path);
+            Assert.Equal(ImageFormat.Jpg, actualBackdrop.Format);
+        }
+
+        private static EmbeddedImageProvider GetEmbeddedImageProvider(IMediaEncoder? mediaEncoder)
+        {
+            return new EmbeddedImageProvider(mediaEncoder);
+        }
+
+        private static Movie GetMovie(List<MediaAttachment> mediaAttachments, List<MediaStream> mediaStreams)
+        {
+            // Mocking IMediaSourceManager GetMediaAttachments and GetMediaStreams instead of mocking Movie works, but
+            // has concurrency problems between this and VideoImageProviderTests due to BaseItem.MediaSourceManager
+            // being static
+            var movie = new Mock<Movie>();
+
+            movie.Setup(item => item.GetMediaSources(It.IsAny<bool>()))
+                .Returns(new List<MediaSourceInfo> { new () { MediaAttachments = mediaAttachments } } );
+            movie.Setup(item => item.GetMediaStreams())
+                .Returns(mediaStreams);
+
+            return movie.Object;
+        }
+    }
+}

+ 176 - 0
tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs

@@ -0,0 +1,176 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Drawing;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.MediaInfo;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.MediaInfo
+{
+    public class VideoImageProviderTests
+    {
+        [Fact]
+        public async void GetImage_InputIsPlaceholder_ReturnsNoImage()
+        {
+            var videoImageProvider = GetVideoImageProvider(null);
+
+            var input = new Movie
+            {
+                IsPlaceHolder = true
+            };
+
+            var actual = await videoImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None);
+            Assert.NotNull(actual);
+            Assert.False(actual.HasImage);
+        }
+
+        [Fact]
+        public async void GetImage_NoDefaultVideoStream_ReturnsNoImage()
+        {
+            var videoImageProvider = GetVideoImageProvider(null);
+
+            var input = new Movie
+            {
+                DefaultVideoStreamIndex = null
+            };
+
+            var actual = await videoImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None);
+            Assert.NotNull(actual);
+            Assert.False(actual.HasImage);
+        }
+
+        [Fact]
+        public async void GetImage_DefaultSetButNoVideoStream_ReturnsNoImage()
+        {
+            var videoImageProvider = GetVideoImageProvider(null);
+
+            // set a default index but don't put anything there (invalid input, but provider shouldn't break)
+            var input = GetMovie(0, null, new List<MediaStream>());
+
+            var actual = await videoImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None);
+            Assert.NotNull(actual);
+            Assert.False(actual.HasImage);
+        }
+
+        [Fact]
+        public async void GetImage_DefaultSetMultipleVideoStreams_ReturnsDefaultStreamImage()
+        {
+            MediaStream firstStream = new () { Type = MediaStreamType.Video, Index = 0 };
+            MediaStream targetStream = new () { Type = MediaStreamType.Video, Index = 1 };
+            string targetPath = "path.jpg";
+
+            var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict);
+            mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MediaSourceInfo>(), firstStream, It.IsAny<Video3DFormat?>(), It.IsAny<TimeSpan?>(), CancellationToken.None))
+                .Returns(Task.FromResult("wrong stream called!"));
+            mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MediaSourceInfo>(), targetStream, It.IsAny<Video3DFormat?>(), It.IsAny<TimeSpan?>(), CancellationToken.None))
+                .Returns(Task.FromResult(targetPath));
+            var videoImageProvider = GetVideoImageProvider(mediaEncoder.Object);
+
+            var input = GetMovie(1, targetStream, new List<MediaStream> { firstStream, targetStream } );
+
+            var actual = await videoImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None);
+            Assert.NotNull(actual);
+            Assert.True(actual.HasImage);
+            Assert.Equal(targetPath, actual.Path);
+            Assert.Equal(ImageFormat.Jpg, actual.Format);
+        }
+
+        [Fact]
+        public async void GetImage_InvalidDefaultSingleVideoStream_ReturnsFirstVideoStreamImage()
+        {
+            MediaStream targetStream = new () { Type = MediaStreamType.Video, Index = 0 };
+            string targetPath = "path.jpg";
+
+            var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict);
+            mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MediaSourceInfo>(), targetStream, It.IsAny<Video3DFormat?>(), It.IsAny<TimeSpan?>(), CancellationToken.None))
+                .Returns(Task.FromResult(targetPath));
+            var videoImageProvider = GetVideoImageProvider(mediaEncoder.Object);
+
+            // provide query results for default (empty) and all streams (populated)
+            var input = GetMovie(5, null, new List<MediaStream> { targetStream });
+
+            var actual = await videoImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None);
+            Assert.NotNull(actual);
+            Assert.True(actual.HasImage);
+            Assert.Equal(targetPath, actual.Path);
+            Assert.Equal(ImageFormat.Jpg, actual.Format);
+        }
+
+        [Fact]
+        public async void GetImage_NoTimeSpanSet_CallsEncoderWithDefaultTime()
+        {
+            MediaStream targetStream = new () { Type = MediaStreamType.Video, Index = 0 };
+
+            // use a callback to catch the actual value
+            // provides more information on failure than verifying a specific input was called on the mock
+            TimeSpan? actualTimeSpan = null;
+            var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict);
+            mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MediaSourceInfo>(), It.IsAny<MediaStream>(), It.IsAny<Video3DFormat?>(), It.IsAny<TimeSpan?>(), CancellationToken.None))
+                .Callback<string, string, MediaSourceInfo, MediaStream, Video3DFormat?, TimeSpan?, CancellationToken>((_, _, _, _, _, timeSpan, _) => actualTimeSpan = timeSpan)
+                .Returns(Task.FromResult("path"));
+            var videoImageProvider = GetVideoImageProvider(mediaEncoder.Object);
+
+            var input = GetMovie(0, targetStream, new List<MediaStream> { targetStream });
+
+            // not testing return, just verifying what gets requested for time span
+            await videoImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None);
+
+            Assert.Equal(TimeSpan.FromSeconds(10), actualTimeSpan);
+        }
+
+        [Fact]
+        public async void GetImage_TimeSpanSet_CallsEncoderWithCalculatedTime()
+        {
+            MediaStream targetStream = new () { Type = MediaStreamType.Video, Index = 0 };
+
+            TimeSpan? actualTimeSpan = null;
+            var mediaEncoder = new Mock<IMediaEncoder>(MockBehavior.Strict);
+            mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MediaSourceInfo>(), It.IsAny<MediaStream>(), It.IsAny<Video3DFormat?>(), It.IsAny<TimeSpan?>(), CancellationToken.None))
+                .Callback<string, string, MediaSourceInfo, MediaStream, Video3DFormat?, TimeSpan?, CancellationToken>((_, _, _, _, _, timeSpan, _) => actualTimeSpan = timeSpan)
+                .Returns(Task.FromResult("path"));
+            var videoImageProvider = GetVideoImageProvider(mediaEncoder.Object);
+
+            var input = GetMovie(0, targetStream, new List<MediaStream> { targetStream });
+            input.RunTimeTicks = 5000;
+
+            // not testing return, just verifying what gets requested for time span
+            await videoImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None);
+
+            Assert.Equal(TimeSpan.FromTicks(500), actualTimeSpan);
+        }
+
+        private static VideoImageProvider GetVideoImageProvider(IMediaEncoder? mediaEncoder)
+        {
+            // strict to ensure this isn't accidentally used where a prepared mock is intended
+            mediaEncoder ??= new Mock<IMediaEncoder>(MockBehavior.Strict).Object;
+            return new VideoImageProvider(mediaEncoder, new NullLogger<VideoImageProvider>());
+        }
+
+        private static Movie GetMovie(int defaultVideoStreamIndex, MediaStream? defaultStream, List<MediaStream> mediaStreams)
+        {
+            // Mocking IMediaSourceManager GetMediaStreams instead of mocking Movie works, but has concurrency problems
+            // between this and EmbeddedImageProviderTests due to BaseItem.MediaSourceManager being static
+            var movie = new Mock<Movie>
+            {
+                Object =
+                {
+                    DefaultVideoStreamIndex = defaultVideoStreamIndex
+                }
+            };
+
+            movie.Setup(item => item.GetDefaultVideoStream())
+                .Returns(defaultStream!);
+            movie.Setup(item => item.GetMediaStreams())
+                .Returns(mediaStreams);
+
+            return movie.Object;
+        }
+    }
+}