浏览代码

Merge pull request #9554 from nicknsy/trickplay

Joshua M. Boniface 1 年之前
父节点
当前提交
8859a3ac8e
共有 32 个文件被更改,包括 2278 次插入7 次删除
  1. 2 0
      CONTRIBUTORS.md
  2. 10 1
      Emby.Server.Implementations/Dto/DtoService.cs
  3. 2 0
      Emby.Server.Implementations/Localization/Core/en-US.json
  4. 5 2
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  5. 101 0
      Jellyfin.Api/Controllers/TrickplayController.cs
  6. 49 1
      Jellyfin.Api/Helpers/DynamicHlsHelper.cs
  7. 6 1
      Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs
  8. 75 0
      Jellyfin.Data/Entities/TrickplayInfo.cs
  9. 5 0
      Jellyfin.Server.Implementations/JellyfinDbContext.cs
  10. 681 0
      Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs
  11. 40 0
      Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.cs
  12. 32 1
      Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs
  13. 18 0
      Jellyfin.Server.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs
  14. 474 0
      Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
  15. 3 0
      Jellyfin.Server/CoreAppHost.cs
  16. 10 0
      MediaBrowser.Controller/Drawing/IImageEncoder.cs
  17. 39 0
      MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
  18. 32 0
      MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs
  19. 76 0
      MediaBrowser.Controller/Trickplay/ITrickplayManager.cs
  20. 188 0
      MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
  21. 6 0
      MediaBrowser.Model/Configuration/EncodingOptions.cs
  22. 4 0
      MediaBrowser.Model/Configuration/LibraryOptions.cs
  23. 6 0
      MediaBrowser.Model/Configuration/ServerConfiguration.cs
  24. 60 0
      MediaBrowser.Model/Configuration/TrickplayOptions.cs
  25. 17 0
      MediaBrowser.Model/Configuration/TrickplayScanBehavior.cs
  26. 7 0
      MediaBrowser.Model/Dto/BaseItemDto.cs
  27. 5 0
      MediaBrowser.Model/Querying/ItemFields.cs
  28. 1 1
      MediaBrowser.Providers/MediaBrowser.Providers.csproj
  29. 118 0
      MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs
  30. 121 0
      MediaBrowser.Providers/Trickplay/TrickplayProvider.cs
  31. 79 0
      src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
  32. 6 0
      src/Jellyfin.Drawing/NullImageEncoder.cs

+ 2 - 0
CONTRIBUTORS.md

@@ -57,6 +57,7 @@
  - [hawken93](https://github.com/hawken93)
  - [hawken93](https://github.com/hawken93)
  - [HelloWorld017](https://github.com/HelloWorld017)
  - [HelloWorld017](https://github.com/HelloWorld017)
  - [ikomhoog](https://github.com/ikomhoog)
  - [ikomhoog](https://github.com/ikomhoog)
+ - [iwalton3](https://github.com/iwalton3)
  - [jftuga](https://github.com/jftuga)
  - [jftuga](https://github.com/jftuga)
  - [jmshrv](https://github.com/jmshrv)
  - [jmshrv](https://github.com/jmshrv)
  - [joern-h](https://github.com/joern-h)
  - [joern-h](https://github.com/joern-h)
@@ -88,6 +89,7 @@
  - [neilsb](https://github.com/neilsb)
  - [neilsb](https://github.com/neilsb)
  - [nevado](https://github.com/nevado)
  - [nevado](https://github.com/nevado)
  - [Nickbert7](https://github.com/Nickbert7)
  - [Nickbert7](https://github.com/Nickbert7)
+ - [nicknsy](https://github.com/nicknsy)
  - [nvllsvm](https://github.com/nvllsvm)
  - [nvllsvm](https://github.com/nvllsvm)
  - [nyanmisaka](https://github.com/nyanmisaka)
  - [nyanmisaka](https://github.com/nyanmisaka)
  - [OancaAndrei](https://github.com/OancaAndrei)
  - [OancaAndrei](https://github.com/OancaAndrei)

+ 10 - 1
Emby.Server.Implementations/Dto/DtoService.cs

@@ -22,6 +22,7 @@ using MediaBrowser.Controller.Lyrics;
 using MediaBrowser.Controller.Persistence;
 using MediaBrowser.Controller.Persistence;
 using MediaBrowser.Controller.Playlists;
 using MediaBrowser.Controller.Playlists;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Trickplay;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Querying;
@@ -52,6 +53,7 @@ namespace Emby.Server.Implementations.Dto
         private readonly Lazy<ILiveTvManager> _livetvManagerFactory;
         private readonly Lazy<ILiveTvManager> _livetvManagerFactory;
 
 
         private readonly ILyricManager _lyricManager;
         private readonly ILyricManager _lyricManager;
+        private readonly ITrickplayManager _trickplayManager;
 
 
         public DtoService(
         public DtoService(
             ILogger<DtoService> logger,
             ILogger<DtoService> logger,
@@ -63,7 +65,8 @@ namespace Emby.Server.Implementations.Dto
             IApplicationHost appHost,
             IApplicationHost appHost,
             IMediaSourceManager mediaSourceManager,
             IMediaSourceManager mediaSourceManager,
             Lazy<ILiveTvManager> livetvManagerFactory,
             Lazy<ILiveTvManager> livetvManagerFactory,
-            ILyricManager lyricManager)
+            ILyricManager lyricManager,
+            ITrickplayManager trickplayManager)
         {
         {
             _logger = logger;
             _logger = logger;
             _libraryManager = libraryManager;
             _libraryManager = libraryManager;
@@ -75,6 +78,7 @@ namespace Emby.Server.Implementations.Dto
             _mediaSourceManager = mediaSourceManager;
             _mediaSourceManager = mediaSourceManager;
             _livetvManagerFactory = livetvManagerFactory;
             _livetvManagerFactory = livetvManagerFactory;
             _lyricManager = lyricManager;
             _lyricManager = lyricManager;
+            _trickplayManager = trickplayManager;
         }
         }
 
 
         private ILiveTvManager LivetvManager => _livetvManagerFactory.Value;
         private ILiveTvManager LivetvManager => _livetvManagerFactory.Value;
@@ -1059,6 +1063,11 @@ namespace Emby.Server.Implementations.Dto
                     dto.Chapters = _itemRepo.GetChapters(item);
                     dto.Chapters = _itemRepo.GetChapters(item);
                 }
                 }
 
 
+                if (options.ContainsField(ItemFields.Trickplay))
+                {
+                    dto.Trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult();
+                }
+
                 if (video.ExtraType.HasValue)
                 if (video.ExtraType.HasValue)
                 {
                 {
                     dto.ExtraType = video.ExtraType.Value.ToString();
                     dto.ExtraType = video.ExtraType.Value.ToString();

+ 2 - 0
Emby.Server.Implementations/Localization/Core/en-US.json

@@ -112,6 +112,8 @@
     "TaskCleanLogsDescription": "Deletes log files that are more than {0} days old.",
     "TaskCleanLogsDescription": "Deletes log files that are more than {0} days old.",
     "TaskRefreshPeople": "Refresh People",
     "TaskRefreshPeople": "Refresh People",
     "TaskRefreshPeopleDescription": "Updates metadata for actors and directors in your media library.",
     "TaskRefreshPeopleDescription": "Updates metadata for actors and directors in your media library.",
+    "TaskRefreshTrickplayImages": "Generate Trickplay Images",
+    "TaskRefreshTrickplayImagesDescription": "Creates trickplay previews for videos in enabled libraries.",
     "TaskUpdatePlugins": "Update Plugins",
     "TaskUpdatePlugins": "Update Plugins",
     "TaskUpdatePluginsDescription": "Downloads and installs updates for plugins that are configured to update automatically.",
     "TaskUpdatePluginsDescription": "Downloads and installs updates for plugins that are configured to update automatically.",
     "TaskCleanTranscode": "Clean Transcode Directory",
     "TaskCleanTranscode": "Clean Transcode Directory",

+ 5 - 2
Jellyfin.Api/Controllers/DynamicHlsController.cs

@@ -410,6 +410,7 @@ public class DynamicHlsController : BaseJellyfinApiController
     /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
     /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
     /// <param name="streamOptions">Optional. The streaming options.</param>
     /// <param name="streamOptions">Optional. The streaming options.</param>
     /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
     /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
+    /// <param name="enableTrickplay">Enable trickplay image playlists being added to master playlist.</param>
     /// <response code="200">Video stream returned.</response>
     /// <response code="200">Video stream returned.</response>
     /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
     /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
     [HttpGet("Videos/{itemId}/master.m3u8")]
     [HttpGet("Videos/{itemId}/master.m3u8")]
@@ -467,7 +468,8 @@ public class DynamicHlsController : BaseJellyfinApiController
         [FromQuery] int? videoStreamIndex,
         [FromQuery] int? videoStreamIndex,
         [FromQuery] EncodingContext? context,
         [FromQuery] EncodingContext? context,
         [FromQuery] Dictionary<string, string> streamOptions,
         [FromQuery] Dictionary<string, string> streamOptions,
-        [FromQuery] bool enableAdaptiveBitrateStreaming = true)
+        [FromQuery] bool enableAdaptiveBitrateStreaming = true,
+        [FromQuery] bool enableTrickplay = true)
     {
     {
         var streamingRequest = new HlsVideoRequestDto
         var streamingRequest = new HlsVideoRequestDto
         {
         {
@@ -521,7 +523,8 @@ public class DynamicHlsController : BaseJellyfinApiController
             VideoStreamIndex = videoStreamIndex,
             VideoStreamIndex = videoStreamIndex,
             Context = context ?? EncodingContext.Streaming,
             Context = context ?? EncodingContext.Streaming,
             StreamOptions = streamOptions,
             StreamOptions = streamOptions,
-            EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
+            EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming,
+            EnableTrickplay = enableTrickplay
         };
         };
 
 
         return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
         return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);

+ 101 - 0
Jellyfin.Api/Controllers/TrickplayController.cs

@@ -0,0 +1,101 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Net.Mime;
+using System.Text;
+using System.Threading.Tasks;
+using Jellyfin.Api.Attributes;
+using Jellyfin.Api.Extensions;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Trickplay;
+using MediaBrowser.Model;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Trickplay controller.
+/// </summary>
+[Route("")]
+[Authorize]
+public class TrickplayController : BaseJellyfinApiController
+{
+    private readonly ILibraryManager _libraryManager;
+    private readonly ITrickplayManager _trickplayManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="TrickplayController"/> class.
+    /// </summary>
+    /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/>.</param>
+    /// <param name="trickplayManager">Instance of <see cref="ITrickplayManager"/>.</param>
+    public TrickplayController(
+        ILibraryManager libraryManager,
+        ITrickplayManager trickplayManager)
+    {
+        _libraryManager = libraryManager;
+        _trickplayManager = trickplayManager;
+    }
+
+    /// <summary>
+    /// Gets an image tiles playlist for trickplay.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="width">The width of a single tile.</param>
+    /// <param name="mediaSourceId">The media version id, if using an alternate version.</param>
+    /// <response code="200">Tiles playlist returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the trickplay playlist file.</returns>
+    [HttpGet("Videos/{itemId}/Trickplay/{width}/tiles.m3u8")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesPlaylistFile]
+    public async Task<ActionResult> GetTrickplayHlsPlaylist(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] int width,
+        [FromQuery] Guid? mediaSourceId)
+    {
+        string? playlist = await _trickplayManager.GetHlsPlaylist(mediaSourceId ?? itemId, width, User.GetToken()).ConfigureAwait(false);
+
+        if (string.IsNullOrEmpty(playlist))
+        {
+            return NotFound();
+        }
+
+        return Content(playlist, MimeTypes.GetMimeType("playlist.m3u8"), Encoding.UTF8);
+    }
+
+    /// <summary>
+    /// Gets a trickplay tile image.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="width">The width of a single tile.</param>
+    /// <param name="index">The index of the desired tile.</param>
+    /// <param name="mediaSourceId">The media version id, if using an alternate version.</param>
+    /// <response code="200">Tile image returned.</response>
+    /// <response code="200">Tile image not found at specified index.</response>
+    /// <returns>A <see cref="FileResult"/> containing the trickplay tiles image.</returns>
+    [HttpGet("Videos/{itemId}/Trickplay/{width}/{index}.jpg")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public ActionResult GetTrickplayTileImage(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] int width,
+        [FromRoute, Required] int index,
+        [FromQuery] Guid? mediaSourceId)
+    {
+        var item = _libraryManager.GetItemById(mediaSourceId ?? itemId);
+        if (item is null)
+        {
+            return NotFound();
+        }
+
+        var path = _trickplayManager.GetTrickplayTilePath(item, width, index);
+        if (System.IO.File.Exists(path))
+        {
+            return PhysicalFile(path, MediaTypeNames.Image.Jpeg);
+        }
+
+        return NotFound();
+    }
+}

+ 49 - 1
Jellyfin.Api/Helpers/DynamicHlsHelper.cs

@@ -9,6 +9,7 @@ using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Models.StreamingDtos;
 using Jellyfin.Api.Models.StreamingDtos;
+using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
 using Jellyfin.Extensions;
 using Jellyfin.Extensions;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
@@ -19,6 +20,7 @@ using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Trickplay;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Net;
@@ -46,6 +48,7 @@ public class DynamicHlsHelper
     private readonly ILogger<DynamicHlsHelper> _logger;
     private readonly ILogger<DynamicHlsHelper> _logger;
     private readonly IHttpContextAccessor _httpContextAccessor;
     private readonly IHttpContextAccessor _httpContextAccessor;
     private readonly EncodingHelper _encodingHelper;
     private readonly EncodingHelper _encodingHelper;
+    private readonly ITrickplayManager _trickplayManager;
 
 
     /// <summary>
     /// <summary>
     /// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class.
     /// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class.
@@ -62,6 +65,7 @@ public class DynamicHlsHelper
     /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param>
     /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param>
     /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
     /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
     /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
     /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
+    /// <param name="trickplayManager">Instance of <see cref="ITrickplayManager"/>.</param>
     public DynamicHlsHelper(
     public DynamicHlsHelper(
         ILibraryManager libraryManager,
         ILibraryManager libraryManager,
         IUserManager userManager,
         IUserManager userManager,
@@ -74,7 +78,8 @@ public class DynamicHlsHelper
         INetworkManager networkManager,
         INetworkManager networkManager,
         ILogger<DynamicHlsHelper> logger,
         ILogger<DynamicHlsHelper> logger,
         IHttpContextAccessor httpContextAccessor,
         IHttpContextAccessor httpContextAccessor,
-        EncodingHelper encodingHelper)
+        EncodingHelper encodingHelper,
+        ITrickplayManager trickplayManager)
     {
     {
         _libraryManager = libraryManager;
         _libraryManager = libraryManager;
         _userManager = userManager;
         _userManager = userManager;
@@ -88,6 +93,7 @@ public class DynamicHlsHelper
         _logger = logger;
         _logger = logger;
         _httpContextAccessor = httpContextAccessor;
         _httpContextAccessor = httpContextAccessor;
         _encodingHelper = encodingHelper;
         _encodingHelper = encodingHelper;
+        _trickplayManager = trickplayManager;
     }
     }
 
 
     /// <summary>
     /// <summary>
@@ -280,6 +286,13 @@ public class DynamicHlsHelper
             AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
             AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
         }
         }
 
 
+        if (!isLiveStream && (state.VideoRequest?.EnableTrickplay ?? false))
+        {
+            var sourceId = Guid.Parse(state.Request.MediaSourceId);
+            var trickplayResolutions = await _trickplayManager.GetTrickplayResolutions(sourceId).ConfigureAwait(false);
+            AddTrickplay(state, trickplayResolutions, builder, _httpContextAccessor.HttpContext.User);
+        }
+
         return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
         return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
     }
     }
 
 
@@ -508,6 +521,41 @@ public class DynamicHlsHelper
         }
         }
     }
     }
 
 
+    /// <summary>
+    /// Appends EXT-X-IMAGE-STREAM-INF playlists for each available trickplay resolution.
+    /// </summary>
+    /// <param name="state">StreamState of the current stream.</param>
+    /// <param name="trickplayResolutions">Dictionary of widths to corresponding tiles info.</param>
+    /// <param name="builder">StringBuilder to append the field to.</param>
+    /// <param name="user">Http user context.</param>
+    private void AddTrickplay(StreamState state, Dictionary<int, TrickplayInfo> trickplayResolutions, StringBuilder builder, ClaimsPrincipal user)
+    {
+        const string playlistFormat = "#EXT-X-IMAGE-STREAM-INF:BANDWIDTH={0},RESOLUTION={1}x{2},CODECS=\"jpeg\",URI=\"{3}\"";
+
+        foreach (var resolution in trickplayResolutions)
+        {
+            var width = resolution.Key;
+            var trickplayInfo = resolution.Value;
+
+            var url = string.Format(
+                CultureInfo.InvariantCulture,
+                "Trickplay/{0}/tiles.m3u8?MediaSourceId={1}&api_key={2}",
+                width.ToString(CultureInfo.InvariantCulture),
+                state.Request.MediaSourceId,
+                user.GetToken());
+
+            builder.AppendFormat(
+                CultureInfo.InvariantCulture,
+                playlistFormat,
+                trickplayInfo.Bandwidth.ToString(CultureInfo.InvariantCulture),
+                trickplayInfo.Width.ToString(CultureInfo.InvariantCulture),
+                trickplayInfo.Height.ToString(CultureInfo.InvariantCulture),
+                url);
+
+            builder.AppendLine();
+        }
+    }
+
     /// <summary>
     /// <summary>
     /// Get the H.26X level of the output video stream.
     /// Get the H.26X level of the output video stream.
     /// </summary>
     /// </summary>

+ 6 - 1
Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs

@@ -1,4 +1,4 @@
-namespace Jellyfin.Api.Models.StreamingDtos;
+namespace Jellyfin.Api.Models.StreamingDtos;
 
 
 /// <summary>
 /// <summary>
 /// The video request dto.
 /// The video request dto.
@@ -15,4 +15,9 @@ public class VideoRequestDto : StreamingRequestDto
     /// Gets or sets a value indicating whether to enable subtitles in the manifest.
     /// Gets or sets a value indicating whether to enable subtitles in the manifest.
     /// </summary>
     /// </summary>
     public bool EnableSubtitlesInManifest { get; set; }
     public bool EnableSubtitlesInManifest { get; set; }
+
+    /// <summary>
+    /// Gets or sets a value indicating whether to enable trickplay images.
+    /// </summary>
+    public bool EnableTrickplay { get; set; }
 }
 }

+ 75 - 0
Jellyfin.Data/Entities/TrickplayInfo.cs

@@ -0,0 +1,75 @@
+using System;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Data.Entities;
+
+/// <summary>
+/// An entity representing the metadata for a group of trickplay tiles.
+/// </summary>
+public class TrickplayInfo
+{
+    /// <summary>
+    /// Gets or sets the id of the associated item.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    [JsonIgnore]
+    public Guid ItemId { get; set; }
+
+    /// <summary>
+    /// Gets or sets width of an individual thumbnail.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    public int Width { get; set; }
+
+    /// <summary>
+    /// Gets or sets height of an individual thumbnail.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    public int Height { get; set; }
+
+    /// <summary>
+    /// Gets or sets amount of thumbnails per row.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    public int TileWidth { get; set; }
+
+    /// <summary>
+    /// Gets or sets amount of thumbnails per column.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    public int TileHeight { get; set; }
+
+    /// <summary>
+    /// Gets or sets total amount of non-black thumbnails.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    public int ThumbnailCount { get; set; }
+
+    /// <summary>
+    /// Gets or sets interval in milliseconds between each trickplay thumbnail.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    public int Interval { get; set; }
+
+    /// <summary>
+    /// Gets or sets peak bandwith usage in bits per second.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    public int Bandwidth { get; set; }
+}

+ 5 - 0
Jellyfin.Server.Implementations/JellyfinDbContext.cs

@@ -78,6 +78,11 @@ public class JellyfinDbContext : DbContext
     /// </summary>
     /// </summary>
     public DbSet<User> Users => Set<User>();
     public DbSet<User> Users => Set<User>();
 
 
+    /// <summary>
+    /// Gets the <see cref="DbSet{TEntity}"/> containing the trickplay metadata.
+    /// </summary>
+    public DbSet<TrickplayInfo> TrickplayInfos => Set<TrickplayInfo>();
+
     /*public DbSet<Artwork> Artwork => Set<Artwork>();
     /*public DbSet<Artwork> Artwork => Set<Artwork>();
 
 
     public DbSet<Book> Books => Set<Book>();
     public DbSet<Book> Books => Set<Book>();

+ 681 - 0
Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs

@@ -0,0 +1,681 @@
+// <auto-generated />
+using System;
+using Jellyfin.Server.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+    [DbContext(typeof(JellyfinDbContext))]
+    [Migration("20230626233818_AddTrickplayInfos")]
+    partial class AddTrickplayInfos
+    {
+        /// <inheritdoc />
+        protected override void BuildTargetModel(ModelBuilder modelBuilder)
+        {
+#pragma warning disable 612, 618
+            modelBuilder.HasAnnotation("ProductVersion", "7.0.7");
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("DayOfWeek")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<double>("EndHour")
+                        .HasColumnType("REAL");
+
+                    b.Property<double>("StartHour")
+                        .HasColumnType("REAL");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("AccessSchedules");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ItemId")
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("LogSeverity")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Overview")
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("ShortOverview")
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Type")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DateCreated");
+
+                    b.ToTable("ActivityLogs");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Key")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Value")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "ItemId", "Client", "Key")
+                        .IsUnique();
+
+                    b.ToTable("CustomItemDisplayPreferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("ChromecastVersion")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DashboardTheme")
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("EnableNextVideoInfoOverlay")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("IndexBy")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("ScrollDirection")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("ShowBackdrop")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("ShowSidebar")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SkipBackwardLength")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SkipForwardLength")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("TvHome")
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "ItemId", "Client")
+                        .IsUnique();
+
+                    b.ToTable("DisplayPreferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("DisplayPreferencesId")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Order")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Type")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DisplayPreferencesId");
+
+                    b.ToTable("HomeSection");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime>("LastModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Path")
+                        .IsRequired()
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid?>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId")
+                        .IsUnique();
+
+                    b.ToTable("ImageInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("IndexBy")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("RememberIndexing")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("RememberSorting")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("SortBy")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("SortOrder")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("ViewType")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("ItemDisplayPreferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Kind")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("Permission_Permissions_Guid")
+                        .HasColumnType("TEXT");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("Value")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "Kind")
+                        .IsUnique()
+                        .HasFilter("[UserId] IS NOT NULL");
+
+                    b.ToTable("Permissions");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Kind")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("Preference_Preferences_Guid")
+                        .HasColumnType("TEXT");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Value")
+                        .IsRequired()
+                        .HasMaxLength(65535)
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "Kind")
+                        .IsUnique()
+                        .HasFilter("[UserId] IS NOT NULL");
+
+                    b.ToTable("Preferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("AccessToken")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateLastActivity")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("AccessToken")
+                        .IsUnique();
+
+                    b.ToTable("ApiKeys");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("AccessToken")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AppName")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AppVersion")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateLastActivity")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DeviceId")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DeviceName")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("IsActive")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DeviceId");
+
+                    b.HasIndex("AccessToken", "DateLastActivity");
+
+                    b.HasIndex("DeviceId", "DateLastActivity");
+
+                    b.HasIndex("UserId", "DeviceId");
+
+                    b.ToTable("Devices");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("CustomName")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DeviceId")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DeviceId")
+                        .IsUnique();
+
+                    b.ToTable("DeviceOptions");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Width")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Bandwidth")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Height")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Interval")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("ThumbnailCount")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("TileHeight")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("TileWidth")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("ItemId", "Width");
+
+                    b.ToTable("TrickplayInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AudioLanguagePreference")
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AuthenticationProviderId")
+                        .IsRequired()
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("DisplayCollectionsView")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("DisplayMissingEpisodes")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableAutoLogin")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableLocalPassword")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableNextEpisodeAutoPlay")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableUserPreferenceAccess")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("HidePlayedInLatest")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<long>("InternalId")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("InvalidLoginAttemptCount")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime?>("LastActivityDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("LastLoginDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("LoginAttemptsBeforeLockout")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("MaxActiveSessions")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("MaxParentalAgeRating")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("MustUpdatePassword")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Password")
+                        .HasMaxLength(65535)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PasswordResetProviderId")
+                        .IsRequired()
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("PlayDefaultAudioTrack")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("RememberAudioSelections")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("RememberSubtitleSelections")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("RemoteClientBitrateLimit")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("SubtitleLanguagePreference")
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("SubtitleMode")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SyncPlayAccess")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Username")
+                        .IsRequired()
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT")
+                        .UseCollation("NOCASE");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("Username")
+                        .IsUnique();
+
+                    b.ToTable("Users");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("AccessSchedules")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("DisplayPreferences")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null)
+                        .WithMany("HomeSections")
+                        .HasForeignKey("DisplayPreferencesId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithOne("ProfileImage")
+                        .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("ItemDisplayPreferences")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("Permissions")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("Preferences")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", "User")
+                        .WithMany()
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("User");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.Navigation("HomeSections");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+                {
+                    b.Navigation("AccessSchedules");
+
+                    b.Navigation("DisplayPreferences");
+
+                    b.Navigation("ItemDisplayPreferences");
+
+                    b.Navigation("Permissions");
+
+                    b.Navigation("Preferences");
+
+                    b.Navigation("ProfileImage");
+                });
+#pragma warning restore 612, 618
+        }
+    }
+}

+ 40 - 0
Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.cs

@@ -0,0 +1,40 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+    /// <inheritdoc />
+    public partial class AddTrickplayInfos : Migration
+    {
+        /// <inheritdoc />
+        protected override void Up(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.CreateTable(
+                name: "TrickplayInfos",
+                columns: table => new
+                {
+                    ItemId = table.Column<Guid>(type: "TEXT", nullable: false),
+                    Width = table.Column<int>(type: "INTEGER", nullable: false),
+                    Height = table.Column<int>(type: "INTEGER", nullable: false),
+                    TileWidth = table.Column<int>(type: "INTEGER", nullable: false),
+                    TileHeight = table.Column<int>(type: "INTEGER", nullable: false),
+                    ThumbnailCount = table.Column<int>(type: "INTEGER", nullable: false),
+                    Interval = table.Column<int>(type: "INTEGER", nullable: false),
+                    Bandwidth = table.Column<int>(type: "INTEGER", nullable: false)
+                },
+                constraints: table =>
+                {
+                    table.PrimaryKey("PK_TrickplayInfos", x => new { x.ItemId, x.Width });
+                });
+        }
+
+        /// <inheritdoc />
+        protected override void Down(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.DropTable(
+                name: "TrickplayInfos");
+        }
+    }
+}

+ 32 - 1
Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs

@@ -1,4 +1,4 @@
-// <auto-generated />
+// <auto-generated />
 using System;
 using System;
 using Jellyfin.Server.Implementations;
 using Jellyfin.Server.Implementations;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.EntityFrameworkCore;
@@ -442,6 +442,37 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.ToTable("DeviceOptions");
                     b.ToTable("DeviceOptions");
                 });
                 });
 
 
+            modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Width")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Bandwidth")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Height")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Interval")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("ThumbnailCount")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("TileHeight")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("TileWidth")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("ItemId", "Width");
+
+                    b.ToTable("TrickplayInfos");
+                });
+
             modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
             modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
                 {
                 {
                     b.Property<Guid>("Id")
                     b.Property<Guid>("Id")

+ 18 - 0
Jellyfin.Server.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs

@@ -0,0 +1,18 @@
+using Jellyfin.Data.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Jellyfin.Server.Implementations.ModelConfiguration
+{
+    /// <summary>
+    /// FluentAPI configuration for the TrickplayInfo entity.
+    /// </summary>
+    public class TrickplayInfoConfiguration : IEntityTypeConfiguration<TrickplayInfo>
+    {
+        /// <inheritdoc/>
+        public void Configure(EntityTypeBuilder<TrickplayInfo> builder)
+        {
+            builder.HasKey(info => new { info.ItemId, info.Width });
+        }
+    }
+}

+ 474 - 0
Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs

@@ -0,0 +1,474 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Trickplay;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Implementations.Trickplay;
+
+/// <summary>
+/// ITrickplayManager implementation.
+/// </summary>
+public class TrickplayManager : ITrickplayManager
+{
+    private readonly ILogger<TrickplayManager> _logger;
+    private readonly IMediaEncoder _mediaEncoder;
+    private readonly IFileSystem _fileSystem;
+    private readonly EncodingHelper _encodingHelper;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IServerConfigurationManager _config;
+    private readonly IImageEncoder _imageEncoder;
+    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+    private readonly IApplicationPaths _appPaths;
+
+    private static readonly SemaphoreSlim _resourcePool = new(1, 1);
+    private static readonly string[] _trickplayImgExtensions = { ".jpg" };
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="TrickplayManager"/> class.
+    /// </summary>
+    /// <param name="logger">The logger.</param>
+    /// <param name="mediaEncoder">The media encoder.</param>
+    /// <param name="fileSystem">The file systen.</param>
+    /// <param name="encodingHelper">The encoding helper.</param>
+    /// <param name="libraryManager">The library manager.</param>
+    /// <param name="config">The server configuration manager.</param>
+    /// <param name="imageEncoder">The image encoder.</param>
+    /// <param name="dbProvider">The database provider.</param>
+    /// <param name="appPaths">The application paths.</param>
+    public TrickplayManager(
+        ILogger<TrickplayManager> logger,
+        IMediaEncoder mediaEncoder,
+        IFileSystem fileSystem,
+        EncodingHelper encodingHelper,
+        ILibraryManager libraryManager,
+        IServerConfigurationManager config,
+        IImageEncoder imageEncoder,
+        IDbContextFactory<JellyfinDbContext> dbProvider,
+        IApplicationPaths appPaths)
+    {
+        _logger = logger;
+        _mediaEncoder = mediaEncoder;
+        _fileSystem = fileSystem;
+        _encodingHelper = encodingHelper;
+        _libraryManager = libraryManager;
+        _config = config;
+        _imageEncoder = imageEncoder;
+        _dbProvider = dbProvider;
+        _appPaths = appPaths;
+    }
+
+    /// <inheritdoc />
+    public async Task RefreshTrickplayDataAsync(Video video, bool replace, CancellationToken cancellationToken)
+    {
+        _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace);
+
+        var options = _config.Configuration.TrickplayOptions;
+        foreach (var width in options.WidthResolutions)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            await RefreshTrickplayDataInternal(
+                video,
+                replace,
+                width,
+                options,
+                cancellationToken).ConfigureAwait(false);
+        }
+    }
+
+    private async Task RefreshTrickplayDataInternal(
+        Video video,
+        bool replace,
+        int width,
+        TrickplayOptions options,
+        CancellationToken cancellationToken)
+    {
+        if (!CanGenerateTrickplay(video, options.Interval))
+        {
+            return;
+        }
+
+        var imgTempDir = string.Empty;
+        var outputDir = GetTrickplayDirectory(video, width);
+
+        await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+        try
+        {
+            if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(width))
+            {
+                _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting.", video.Id);
+                return;
+            }
+
+            // Extract images
+            // Note: Media sources under parent items exist as their own video/item as well. Only use this video stream for trickplay.
+            var mediaSource = video.GetMediaSources(false).Find(source => Guid.Parse(source.Id).Equals(video.Id));
+
+            if (mediaSource is null)
+            {
+                _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id);
+                return;
+            }
+
+            var mediaPath = mediaSource.Path;
+            var mediaStream = mediaSource.VideoStream;
+            var container = mediaSource.Container;
+
+            _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", width, mediaPath, video.Id);
+            imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated(
+                mediaPath,
+                container,
+                mediaSource,
+                mediaStream,
+                width,
+                TimeSpan.FromMilliseconds(options.Interval),
+                options.EnableHwAcceleration,
+                options.ProcessThreads,
+                options.Qscale,
+                options.ProcessPriority,
+                _encodingHelper,
+                cancellationToken).ConfigureAwait(false);
+
+            if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir))
+            {
+                throw new InvalidOperationException("Null or invalid directory from media encoder.");
+            }
+
+            var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false)
+                .Select(i => i.FullName)
+                .OrderBy(i => i)
+                .ToList();
+
+            // Create tiles
+            var trickplayInfo = CreateTiles(images, width, options, outputDir);
+
+            // Save tiles info
+            try
+            {
+                if (trickplayInfo is not null)
+                {
+                    trickplayInfo.ItemId = video.Id;
+                    await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false);
+
+                    _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath);
+                }
+                else
+                {
+                    throw new InvalidOperationException("Null trickplay tiles info from CreateTiles.");
+                }
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error while saving trickplay tiles info.");
+
+                // Make sure no files stay in metadata folders on failure
+                // if tiles info wasn't saved.
+                Directory.Delete(outputDir, true);
+            }
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Error creating trickplay images.");
+        }
+        finally
+        {
+            _resourcePool.Release();
+
+            if (!string.IsNullOrEmpty(imgTempDir))
+            {
+                Directory.Delete(imgTempDir, true);
+            }
+        }
+    }
+
+    /// <inheritdoc />
+    public TrickplayInfo CreateTiles(List<string> images, int width, TrickplayOptions options, string outputDir)
+    {
+        if (images.Count == 0)
+        {
+            throw new ArgumentException("Can't create trickplay from 0 images.");
+        }
+
+        var workDir = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid().ToString("N"));
+        Directory.CreateDirectory(workDir);
+
+        var trickplayInfo = new TrickplayInfo
+        {
+            Width = width,
+            Interval = options.Interval,
+            TileWidth = options.TileWidth,
+            TileHeight = options.TileHeight,
+            ThumbnailCount = images.Count,
+            // Set during image generation
+            Height = 0,
+            Bandwidth = 0
+        };
+
+        /*
+         * Generate trickplay tiles from sets of thumbnails
+         */
+        var imageOptions = new ImageCollageOptions
+        {
+            Width = trickplayInfo.TileWidth,
+            Height = trickplayInfo.TileHeight
+        };
+
+        var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
+        var requiredTiles = (int)Math.Ceiling((double)images.Count / thumbnailsPerTile);
+
+        for (int i = 0; i < requiredTiles; i++)
+        {
+            // Set output/input paths
+            var tilePath = Path.Combine(workDir, $"{i}.jpg");
+
+            imageOptions.OutputPath = tilePath;
+            imageOptions.InputPaths = images.GetRange(i * thumbnailsPerTile, Math.Min(thumbnailsPerTile, images.Count - (i * thumbnailsPerTile)));
+
+            // Generate image and use returned height for tiles info
+            var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, trickplayInfo.Height != 0 ? trickplayInfo.Height : null);
+            if (trickplayInfo.Height == 0)
+            {
+                trickplayInfo.Height = height;
+            }
+
+            // Update bitrate
+            var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tilePath).Length * 8 / trickplayInfo.TileWidth / trickplayInfo.TileHeight / (trickplayInfo.Interval / 1000));
+            trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate);
+        }
+
+        /*
+         * Move trickplay tiles to output directory
+         */
+        Directory.CreateDirectory(Directory.GetParent(outputDir)!.FullName);
+
+        // Replace existing tiles if they already exist
+        if (Directory.Exists(outputDir))
+        {
+            Directory.Delete(outputDir, true);
+        }
+
+        MoveDirectory(workDir, outputDir);
+
+        return trickplayInfo;
+    }
+
+    private bool CanGenerateTrickplay(Video video, int interval)
+    {
+        var videoType = video.VideoType;
+        if (videoType == VideoType.Iso || videoType == VideoType.Dvd || videoType == VideoType.BluRay)
+        {
+            return false;
+        }
+
+        if (video.IsPlaceHolder)
+        {
+            return false;
+        }
+
+        if (video.IsShortcut)
+        {
+            return false;
+        }
+
+        if (!video.IsCompleteMedia)
+        {
+            return false;
+        }
+
+        if (!video.RunTimeTicks.HasValue || video.RunTimeTicks.Value < TimeSpan.FromMilliseconds(interval).Ticks)
+        {
+            return false;
+        }
+
+        var libraryOptions = _libraryManager.GetLibraryOptions(video);
+        if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction)
+        {
+            return false;
+        }
+
+        // Can't extract images if there are no video streams
+        return video.GetMediaStreams().Count > 0;
+    }
+
+    /// <inheritdoc />
+    public async Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId)
+    {
+        var trickplayResolutions = new Dictionary<int, TrickplayInfo>();
+
+        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+        await using (dbContext.ConfigureAwait(false))
+        {
+            var trickplayInfos = await dbContext.TrickplayInfos
+                .AsNoTracking()
+                .Where(i => i.ItemId.Equals(itemId))
+                .ToListAsync()
+                .ConfigureAwait(false);
+
+            foreach (var info in trickplayInfos)
+            {
+                trickplayResolutions[info.Width] = info;
+            }
+        }
+
+        return trickplayResolutions;
+    }
+
+    /// <inheritdoc />
+    public async Task SaveTrickplayInfo(TrickplayInfo info)
+    {
+        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+        await using (dbContext.ConfigureAwait(false))
+        {
+            var oldInfo = await dbContext.TrickplayInfos.FindAsync(info.ItemId, info.Width).ConfigureAwait(false);
+            if (oldInfo is not null)
+            {
+                dbContext.TrickplayInfos.Remove(oldInfo);
+            }
+
+            dbContext.Add(info);
+
+            await dbContext.SaveChangesAsync().ConfigureAwait(false);
+        }
+    }
+
+    /// <inheritdoc />
+    public async Task<Dictionary<string, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item)
+    {
+        var trickplayManifest = new Dictionary<string, Dictionary<int, TrickplayInfo>>();
+        foreach (var mediaSource in item.GetMediaSources(false))
+        {
+            var mediaSourceId = Guid.Parse(mediaSource.Id);
+            var trickplayResolutions = await GetTrickplayResolutions(mediaSourceId).ConfigureAwait(false);
+
+            if (trickplayResolutions.Count > 0)
+            {
+                trickplayManifest[mediaSource.Id] = trickplayResolutions;
+            }
+        }
+
+        return trickplayManifest;
+    }
+
+    /// <inheritdoc />
+    public string GetTrickplayTilePath(BaseItem item, int width, int index)
+    {
+        return Path.Combine(GetTrickplayDirectory(item, width), index + ".jpg");
+    }
+
+    /// <inheritdoc />
+    public async Task<string?> GetHlsPlaylist(Guid itemId, int width, string? apiKey)
+    {
+        var trickplayResolutions = await GetTrickplayResolutions(itemId).ConfigureAwait(false);
+        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
+        {
+            var builder = new StringBuilder(128);
+
+            if (trickplayInfo.ThumbnailCount > 0)
+            {
+                const string urlFormat = "Trickplay/{0}/{1}.jpg?MediaSourceId={2}&api_key={3}";
+                const string decimalFormat = "{0:0.###}";
+
+                var resolution = $"{trickplayInfo.Width}x{trickplayInfo.Height}";
+                var layout = $"{trickplayInfo.TileWidth}x{trickplayInfo.TileHeight}";
+                var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
+                var thumbnailDuration = trickplayInfo.Interval / 1000d;
+                var infDuration = thumbnailDuration * thumbnailsPerTile;
+                var tileCount = (int)Math.Ceiling((decimal)trickplayInfo.ThumbnailCount / thumbnailsPerTile);
+
+                builder
+                    .AppendLine("#EXTM3U")
+                    .Append("#EXT-X-TARGETDURATION:")
+                    .AppendLine(tileCount.ToString(CultureInfo.InvariantCulture))
+                    .AppendLine("#EXT-X-VERSION:7")
+                    .AppendLine("#EXT-X-MEDIA-SEQUENCE:1")
+                    .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD")
+                    .AppendLine("#EXT-X-IMAGES-ONLY");
+
+                for (int i = 0; i < tileCount; i++)
+                {
+                    // All tiles prior to the last must contain full amount of thumbnails (no black).
+                    if (i == tileCount - 1)
+                    {
+                        thumbnailsPerTile = trickplayInfo.ThumbnailCount - (i * thumbnailsPerTile);
+                        infDuration = thumbnailDuration * thumbnailsPerTile;
+                    }
+
+                    // EXTINF
+                    builder
+                        .Append("#EXTINF:")
+                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, infDuration)
+                        .AppendLine(",");
+
+                    // EXT-X-TILES
+                    builder
+                        .Append("#EXT-X-TILES:RESOLUTION=")
+                        .Append(resolution)
+                        .Append(",LAYOUT=")
+                        .Append(layout)
+                        .Append(",DURATION=")
+                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, thumbnailDuration)
+                        .AppendLine();
+
+                    // URL
+                    builder
+                        .AppendFormat(
+                            CultureInfo.InvariantCulture,
+                            urlFormat,
+                            width.ToString(CultureInfo.InvariantCulture),
+                            i.ToString(CultureInfo.InvariantCulture),
+                            itemId.ToString("N"),
+                            apiKey)
+                        .AppendLine();
+                }
+
+                builder.AppendLine("#EXT-X-ENDLIST");
+                return builder.ToString();
+            }
+        }
+
+        return null;
+    }
+
+    private string GetTrickplayDirectory(BaseItem item, int? width = null)
+    {
+        var path = Path.Combine(item.GetInternalMetadataPath(), "trickplay");
+
+        return width.HasValue ? Path.Combine(path, width.Value.ToString(CultureInfo.InvariantCulture)) : path;
+    }
+
+    private void MoveDirectory(string source, string destination)
+    {
+        try
+        {
+            Directory.Move(source, destination);
+        }
+        catch (IOException)
+        {
+            // Cross device move requires a copy
+            Directory.CreateDirectory(destination);
+            foreach (string file in Directory.GetFiles(source))
+            {
+                File.Copy(file, Path.Join(destination, Path.GetFileName(file)), true);
+            }
+
+            Directory.Delete(source, true);
+        }
+    }
+}

+ 3 - 0
Jellyfin.Server/CoreAppHost.cs

@@ -11,6 +11,7 @@ using Jellyfin.Server.Implementations.Activity;
 using Jellyfin.Server.Implementations.Devices;
 using Jellyfin.Server.Implementations.Devices;
 using Jellyfin.Server.Implementations.Events;
 using Jellyfin.Server.Implementations.Events;
 using Jellyfin.Server.Implementations.Security;
 using Jellyfin.Server.Implementations.Security;
+using Jellyfin.Server.Implementations.Trickplay;
 using Jellyfin.Server.Implementations.Users;
 using Jellyfin.Server.Implementations.Users;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.BaseItemManager;
 using MediaBrowser.Controller.BaseItemManager;
@@ -21,6 +22,7 @@ using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Lyrics;
 using MediaBrowser.Controller.Lyrics;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Security;
 using MediaBrowser.Controller.Security;
+using MediaBrowser.Controller.Trickplay;
 using MediaBrowser.Model.Activity;
 using MediaBrowser.Model.Activity;
 using MediaBrowser.Providers.Lyric;
 using MediaBrowser.Providers.Lyric;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Configuration;
@@ -78,6 +80,7 @@ namespace Jellyfin.Server
             serviceCollection.AddSingleton<IUserManager, UserManager>();
             serviceCollection.AddSingleton<IUserManager, UserManager>();
             serviceCollection.AddScoped<IDisplayPreferencesManager, DisplayPreferencesManager>();
             serviceCollection.AddScoped<IDisplayPreferencesManager, DisplayPreferencesManager>();
             serviceCollection.AddSingleton<IDeviceManager, DeviceManager>();
             serviceCollection.AddSingleton<IDeviceManager, DeviceManager>();
+            serviceCollection.AddSingleton<ITrickplayManager, TrickplayManager>();
 
 
             // TODO search the assemblies instead of adding them manually?
             // TODO search the assemblies instead of adding them manually?
             serviceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>();
             serviceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>();

+ 10 - 0
MediaBrowser.Controller/Drawing/IImageEncoder.cs

@@ -81,5 +81,15 @@ namespace MediaBrowser.Controller.Drawing
         /// <param name="posters">The list of poster paths.</param>
         /// <param name="posters">The list of poster paths.</param>
         /// <param name="backdrops">The list of backdrop paths.</param>
         /// <param name="backdrops">The list of backdrop paths.</param>
         void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops);
         void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops);
+
+        /// <summary>
+        /// Creates a new trickplay tile image.
+        /// </summary>
+        /// <param name="options">The options to use when creating the image. Width and Height are a quantity of thumbnails in this case, not pixels.</param>
+        /// <param name="quality">The image encode quality.</param>
+        /// <param name="imgWidth">The width of a single trickplay thumbnail.</param>
+        /// <param name="imgHeight">Optional height of a single trickplay thumbnail, if it is known.</param>
+        /// <returns>Height of single decoded trickplay thumbnail.</returns>
+        int CreateTrickplayTile(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight);
     }
     }
 }
 }

+ 39 - 0
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs

@@ -100,6 +100,13 @@ namespace MediaBrowser.Controller.MediaEncoding
             { "truehd", 6 },
             { "truehd", 6 },
         };
         };
 
 
+        private static readonly string _defaultMjpegEncoder = "mjpeg";
+        private static readonly Dictionary<string, string> _mjpegCodecMap = new(StringComparer.OrdinalIgnoreCase)
+        {
+            { "vaapi", _defaultMjpegEncoder + "_vaapi" },
+            { "qsv", _defaultMjpegEncoder + "_qsv" }
+        };
+
         public static readonly string[] LosslessAudioCodecs = new string[]
         public static readonly string[] LosslessAudioCodecs = new string[]
         {
         {
             "alac",
             "alac",
@@ -167,6 +174,24 @@ namespace MediaBrowser.Controller.MediaEncoding
             return defaultEncoder;
             return defaultEncoder;
         }
         }
 
 
+        private string GetMjpegEncoder(EncodingJobInfo state, EncodingOptions encodingOptions)
+        {
+            if (state.VideoType == VideoType.VideoFile)
+            {
+                var hwType = encodingOptions.HardwareAccelerationType;
+
+                if (!string.IsNullOrEmpty(hwType)
+                    && encodingOptions.EnableHardwareEncoding
+                    && _mjpegCodecMap.TryGetValue(hwType, out var preferredEncoder)
+                    && _mediaEncoder.SupportsEncoder(preferredEncoder))
+                {
+                    return preferredEncoder;
+                }
+            }
+
+            return _defaultMjpegEncoder;
+        }
+
         private bool IsVaapiSupported(EncodingJobInfo state)
         private bool IsVaapiSupported(EncodingJobInfo state)
         {
         {
             // vaapi will throw an error with this input
             // vaapi will throw an error with this input
@@ -300,6 +325,11 @@ namespace MediaBrowser.Controller.MediaEncoding
                     return GetH264Encoder(state, encodingOptions);
                     return GetH264Encoder(state, encodingOptions);
                 }
                 }
 
 
+                if (string.Equals(codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
+                {
+                    return GetMjpegEncoder(state, encodingOptions);
+                }
+
                 if (string.Equals(codec, "vp8", StringComparison.OrdinalIgnoreCase)
                 if (string.Equals(codec, "vp8", StringComparison.OrdinalIgnoreCase)
                     || string.Equals(codec, "vpx", StringComparison.OrdinalIgnoreCase))
                     || string.Equals(codec, "vpx", StringComparison.OrdinalIgnoreCase))
                 {
                 {
@@ -4917,6 +4947,15 @@ namespace MediaBrowser.Controller.MediaEncoding
             subFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter));
             subFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter));
             overlayFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter));
             overlayFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter));
 
 
+            var framerate = GetFramerateParam(state);
+            if (framerate.HasValue)
+            {
+                mainFilters.Insert(0, string.Format(
+                    CultureInfo.InvariantCulture,
+                    "fps={0}",
+                    framerate.Value));
+            }
+
             var mainStr = string.Empty;
             var mainStr = string.Empty;
             if (mainFilters?.Count > 0)
             if (mainFilters?.Count > 0)
             {
             {

+ 32 - 0
MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs

@@ -4,8 +4,10 @@
 
 
 using System;
 using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
+using System.Diagnostics;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
+using MediaBrowser.Model.Configuration;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Drawing;
 using MediaBrowser.Model.Drawing;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Dto;
@@ -137,6 +139,36 @@ namespace MediaBrowser.Controller.MediaEncoding
         /// <returns>Location of video image.</returns>
         /// <returns>Location of video image.</returns>
         Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, ImageFormat? targetFormat, CancellationToken cancellationToken);
         Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, ImageFormat? targetFormat, CancellationToken cancellationToken);
 
 
+        /// <summary>
+        /// Extracts the video images on interval.
+        /// </summary>
+        /// <param name="inputFile">Input file.</param>
+        /// <param name="container">Video container type.</param>
+        /// <param name="mediaSource">Media source information.</param>
+        /// <param name="imageStream">Media stream information.</param>
+        /// <param name="maxWidth">The maximum width.</param>
+        /// <param name="interval">The interval.</param>
+        /// <param name="allowHwAccel">Allow for hardware acceleration.</param>
+        /// <param name="threads">The input/output thread count for ffmpeg.</param>
+        /// <param name="qualityScale">The qscale value for ffmpeg.</param>
+        /// <param name="priority">The process priority for the ffmpeg process.</param>
+        /// <param name="encodingHelper">EncodingHelper instance.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>Directory where images where extracted. A given image made before another will always be named with a lower number.</returns>
+        Task<string> ExtractVideoImagesOnIntervalAccelerated(
+            string inputFile,
+            string container,
+            MediaSourceInfo mediaSource,
+            MediaStream imageStream,
+            int maxWidth,
+            TimeSpan interval,
+            bool allowHwAccel,
+            int? threads,
+            int? qualityScale,
+            ProcessPriorityClass? priority,
+            EncodingHelper encodingHelper,
+            CancellationToken cancellationToken);
+
         /// <summary>
         /// <summary>
         /// Gets the media info.
         /// Gets the media info.
         /// </summary>
         /// </summary>

+ 76 - 0
MediaBrowser.Controller/Trickplay/ITrickplayManager.cs

@@ -0,0 +1,76 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Configuration;
+
+namespace MediaBrowser.Controller.Trickplay;
+
+/// <summary>
+/// Interface ITrickplayManager.
+/// </summary>
+public interface ITrickplayManager
+{
+    /// <summary>
+    /// Generates new trickplay images and metadata.
+    /// </summary>
+    /// <param name="video">The video.</param>
+    /// <param name="replace">Whether or not existing data should be replaced.</param>
+    /// <param name="cancellationToken">CancellationToken to use for operation.</param>
+    /// <returns>Task.</returns>
+    Task RefreshTrickplayDataAsync(Video video, bool replace, CancellationToken cancellationToken);
+
+    /// <summary>
+    /// Creates trickplay tiles out of individual thumbnails.
+    /// </summary>
+    /// <param name="images">Ordered file paths of the thumbnails to be used.</param>
+    /// <param name="width">The width of a single thumbnail.</param>
+    /// <param name="options">The trickplay options.</param>
+    /// <param name="outputDir">The output directory.</param>
+    /// <returns>The associated trickplay information.</returns>
+    /// <remarks>
+    /// The output directory will be DELETED and replaced if it already exists.
+    /// </remarks>
+    TrickplayInfo CreateTiles(List<string> images, int width, TrickplayOptions options, string outputDir);
+
+    /// <summary>
+    /// Get available trickplay resolutions and corresponding info.
+    /// </summary>
+    /// <param name="itemId">The item.</param>
+    /// <returns>Map of width resolutions to trickplay tiles info.</returns>
+    Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId);
+
+    /// <summary>
+    /// Saves trickplay info.
+    /// </summary>
+    /// <param name="info">The trickplay info.</param>
+    /// <returns>Task.</returns>
+    Task SaveTrickplayInfo(TrickplayInfo info);
+
+    /// <summary>
+    /// Gets all trickplay infos for all media streams of an item.
+    /// </summary>
+    /// <param name="item">The item.</param>
+    /// <returns>A map of media source id to a map of tile width to trickplay info.</returns>
+    Task<Dictionary<string, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item);
+
+    /// <summary>
+    /// Gets the path to a trickplay tile image.
+    /// </summary>
+    /// <param name="item">The item.</param>
+    /// <param name="width">The width of a single thumbnail.</param>
+    /// <param name="index">The tile's index.</param>
+    /// <returns>The absolute path.</returns>
+    string GetTrickplayTilePath(BaseItem item, int width, int index);
+
+    /// <summary>
+    /// Gets the trickplay HLS playlist.
+    /// </summary>
+    /// <param name="itemId">The item.</param>
+    /// <param name="width">The width of a single thumbnail.</param>
+    /// <param name="apiKey">Optional api key of the requesting user.</param>
+    /// <returns>The text content of the .m3u8 playlist.</returns>
+    Task<string?> GetHlsPlaylist(Guid itemId, int width, string? apiKey);
+}

+ 188 - 0
MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs

@@ -21,6 +21,7 @@ using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Extensions;
 using MediaBrowser.Controller.Extensions;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.MediaEncoding.Probing;
 using MediaBrowser.MediaEncoding.Probing;
+using MediaBrowser.Model.Configuration;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Drawing;
 using MediaBrowser.Model.Drawing;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Dto;
@@ -28,8 +29,10 @@ using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.MediaInfo;
 using MediaBrowser.Model.MediaInfo;
+using Microsoft.AspNetCore.Components.Forms;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
+using static Nikse.SubtitleEdit.Core.Common.IfoParser;
 
 
 namespace MediaBrowser.MediaEncoding.Encoder
 namespace MediaBrowser.MediaEncoding.Encoder
 {
 {
@@ -781,6 +784,191 @@ namespace MediaBrowser.MediaEncoding.Encoder
         }
         }
 
 
         /// <inheritdoc />
         /// <inheritdoc />
+        public Task<string> ExtractVideoImagesOnIntervalAccelerated(
+            string inputFile,
+            string container,
+            MediaSourceInfo mediaSource,
+            MediaStream imageStream,
+            int maxWidth,
+            TimeSpan interval,
+            bool allowHwAccel,
+            int? threads,
+            int? qualityScale,
+            ProcessPriorityClass? priority,
+            EncodingHelper encodingHelper,
+            CancellationToken cancellationToken)
+        {
+            var options = allowHwAccel ? _configurationManager.GetEncodingOptions() : new EncodingOptions();
+            threads ??= _threads;
+
+            // A new EncodingOptions instance must be used as to not disable HW acceleration for all of Jellyfin.
+            // Additionally, we must set a few fields without defaults to prevent null pointer exceptions.
+            if (!allowHwAccel)
+            {
+                options.EnableHardwareEncoding = false;
+                options.HardwareAccelerationType = string.Empty;
+                options.EnableTonemapping = false;
+            }
+
+            var baseRequest = new BaseEncodingJobOptions { MaxWidth = maxWidth, MaxFramerate = (float)(1.0 / interval.TotalSeconds) };
+            var jobState = new EncodingJobInfo(TranscodingJobType.Progressive)
+            {
+                IsVideoRequest = true,  // must be true for InputVideoHwaccelArgs to return non-empty value
+                MediaSource = mediaSource,
+                VideoStream = imageStream,
+                BaseRequest = baseRequest,  // GetVideoProcessingFilterParam errors if null
+                MediaPath = inputFile,
+                OutputVideoCodec = "mjpeg"
+            };
+            var vidEncoder = options.AllowMjpegEncoding ? encodingHelper.GetVideoEncoder(jobState, options) : jobState.OutputVideoCodec;
+
+            // Get input and filter arguments
+            var inputArg = encodingHelper.GetInputArgument(jobState, options, container).Trim();
+            if (string.IsNullOrWhiteSpace(inputArg))
+            {
+                throw new InvalidOperationException("EncodingHelper returned empty input arguments.");
+            }
+
+            if (!allowHwAccel)
+            {
+                inputArg = "-threads " + threads + " " + inputArg; // HW accel args set a different input thread count, only set if disabled
+            }
+
+            var filterParam = encodingHelper.GetVideoProcessingFilterParam(jobState, options, jobState.OutputVideoCodec).Trim();
+            if (string.IsNullOrWhiteSpace(filterParam))
+            {
+                throw new InvalidOperationException("EncodingHelper returned empty or invalid filter parameters.");
+            }
+
+            return ExtractVideoImagesOnIntervalInternal(inputArg, filterParam, vidEncoder, threads, qualityScale, priority, cancellationToken);
+        }
+
+        private async Task<string> ExtractVideoImagesOnIntervalInternal(
+            string inputArg,
+            string filterParam,
+            string vidEncoder,
+            int? outputThreads,
+            int? qualityScale,
+            ProcessPriorityClass? priority,
+            CancellationToken cancellationToken)
+        {
+            if (string.IsNullOrWhiteSpace(inputArg))
+            {
+                throw new InvalidOperationException("Empty or invalid input argument.");
+            }
+
+            // Output arguments
+            var targetDirectory = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid().ToString("N"));
+            Directory.CreateDirectory(targetDirectory);
+            var outputPath = Path.Combine(targetDirectory, "%08d.jpg");
+
+            // Final command arguments
+            var args = string.Format(
+                CultureInfo.InvariantCulture,
+                "-loglevel error {0} -an -sn {1} -threads {2} -c:v {3} {4}-f {5} \"{6}\"",
+                inputArg,
+                filterParam,
+                outputThreads.GetValueOrDefault(_threads),
+                vidEncoder,
+                qualityScale.HasValue ? "-qscale:v " + qualityScale.Value.ToString(CultureInfo.InvariantCulture) + " " : string.Empty,
+                "image2",
+                outputPath);
+
+            // Start ffmpeg process
+            var process = new Process
+            {
+                StartInfo = new ProcessStartInfo
+                {
+                    CreateNoWindow = true,
+                    UseShellExecute = false,
+                    FileName = _ffmpegPath,
+                    Arguments = args,
+                    WindowStyle = ProcessWindowStyle.Hidden,
+                    ErrorDialog = false,
+                },
+                EnableRaisingEvents = true
+            };
+
+            var processDescription = string.Format(CultureInfo.InvariantCulture, "{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
+            _logger.LogInformation("Trickplay generation: {ProcessDescription}", processDescription);
+
+            using (var processWrapper = new ProcessWrapper(process, this))
+            {
+                bool ranToCompletion = false;
+
+                await _thumbnailResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
+                try
+                {
+                    StartProcess(processWrapper);
+
+                    // Set process priority
+                    if (priority.HasValue)
+                    {
+                        try
+                        {
+                            processWrapper.Process.PriorityClass = priority.Value;
+                        }
+                        catch (Exception ex)
+                        {
+                            _logger.LogDebug(ex, "Unable to set process priority to {Priority} for {Description}", priority.Value, processDescription);
+                        }
+                    }
+
+                    // Need to give ffmpeg enough time to make all the thumbnails, which could be a while,
+                    // but we still need to detect if the process hangs.
+                    // Making the assumption that as long as new jpegs are showing up, everything is good.
+
+                    bool isResponsive = true;
+                    int lastCount = 0;
+                    var timeoutMs = _configurationManager.Configuration.ImageExtractionTimeoutMs;
+                    timeoutMs = timeoutMs <= 0 ? DefaultHdrImageExtractionTimeout : timeoutMs;
+
+                    while (isResponsive)
+                    {
+                        try
+                        {
+                            await process.WaitForExitAsync(TimeSpan.FromMilliseconds(timeoutMs)).ConfigureAwait(false);
+
+                            ranToCompletion = true;
+                            break;
+                        }
+                        catch (OperationCanceledException)
+                        {
+                            // We don't actually expect the process to be finished in one timeout span, just that one image has been generated.
+                        }
+
+                        cancellationToken.ThrowIfCancellationRequested();
+
+                        var jpegCount = _fileSystem.GetFilePaths(targetDirectory).Count();
+
+                        isResponsive = jpegCount > lastCount;
+                        lastCount = jpegCount;
+                    }
+
+                    if (!ranToCompletion)
+                    {
+                        _logger.LogInformation("Stopping trickplay extraction due to process inactivity.");
+                        StopProcess(processWrapper, 1000);
+                    }
+                }
+                finally
+                {
+                    _thumbnailResourcePool.Release();
+                }
+
+                var exitCode = ranToCompletion ? processWrapper.ExitCode ?? 0 : -1;
+
+                if (exitCode == -1)
+                {
+                    _logger.LogError("ffmpeg image extraction failed for {ProcessDescription}", processDescription);
+
+                    throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "ffmpeg image extraction failed for {0}", processDescription));
+                }
+
+                return targetDirectory;
+            }
+        }
+
         public string GetTimeParameter(long ticks)
         public string GetTimeParameter(long ticks)
         {
         {
             var time = TimeSpan.FromTicks(ticks);
             var time = TimeSpan.FromTicks(ticks);

+ 6 - 0
MediaBrowser.Model/Configuration/EncodingOptions.cs

@@ -50,6 +50,7 @@ public class EncodingOptions
         EnableHardwareEncoding = true;
         EnableHardwareEncoding = true;
         AllowHevcEncoding = false;
         AllowHevcEncoding = false;
         AllowAv1Encoding = false;
         AllowAv1Encoding = false;
+        AllowMjpegEncoding = false;
         EnableSubtitleExtraction = true;
         EnableSubtitleExtraction = true;
         AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = new[] { "mkv" };
         AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = new[] { "mkv" };
         HardwareDecodingCodecs = new string[] { "h264", "vc1" };
         HardwareDecodingCodecs = new string[] { "h264", "vc1" };
@@ -255,6 +256,11 @@ public class EncodingOptions
     /// </summary>
     /// </summary>
     public bool AllowAv1Encoding { get; set; }
     public bool AllowAv1Encoding { get; set; }
 
 
+    /// <summary>
+    /// Gets or sets a value indicating whether MJPEG encoding is enabled.
+    /// </summary>
+    public bool AllowMjpegEncoding { get; set; }
+
     /// <summary>
     /// <summary>
     /// Gets or sets a value indicating whether subtitle extraction is enabled.
     /// Gets or sets a value indicating whether subtitle extraction is enabled.
     /// </summary>
     /// </summary>

+ 4 - 0
MediaBrowser.Model/Configuration/LibraryOptions.cs

@@ -35,6 +35,10 @@ namespace MediaBrowser.Model.Configuration
 
 
         public bool ExtractChapterImagesDuringLibraryScan { get; set; }
         public bool ExtractChapterImagesDuringLibraryScan { get; set; }
 
 
+        public bool EnableTrickplayImageExtraction { get; set; }
+
+        public bool ExtractTrickplayImagesDuringLibraryScan { get; set; }
+
         public MediaPathInfo[] PathInfos { get; set; }
         public MediaPathInfo[] PathInfos { get; set; }
 
 
         public bool SaveLocalMetadata { get; set; }
         public bool SaveLocalMetadata { get; set; }

+ 6 - 0
MediaBrowser.Model/Configuration/ServerConfiguration.cs

@@ -270,4 +270,10 @@ public class ServerConfiguration : BaseApplicationConfiguration
     /// Gets or sets the list of cast receiver applications.
     /// Gets or sets the list of cast receiver applications.
     /// </summary>
     /// </summary>
     public CastReceiverApplication[] CastReceiverApplications { get; set; } = Array.Empty<CastReceiverApplication>();
     public CastReceiverApplication[] CastReceiverApplications { get; set; } = Array.Empty<CastReceiverApplication>();
+
+    /// <summary>
+    /// Gets or sets the trickplay options.
+    /// </summary>
+    /// <value>The trickplay options.</value>
+    public TrickplayOptions TrickplayOptions { get; set; } = new TrickplayOptions();
 }
 }

+ 60 - 0
MediaBrowser.Model/Configuration/TrickplayOptions.cs

@@ -0,0 +1,60 @@
+using System.Collections.Generic;
+using System.Diagnostics;
+
+namespace MediaBrowser.Model.Configuration;
+
+/// <summary>
+/// Class TrickplayOptions.
+/// </summary>
+public class TrickplayOptions
+{
+    /// <summary>
+    /// Gets or sets a value indicating whether or not to use HW acceleration.
+    /// </summary>
+    public bool EnableHwAcceleration { get; set; } = false;
+
+    /// <summary>
+    /// Gets or sets the behavior used by trickplay provider on library scan/update.
+    /// </summary>
+    public TrickplayScanBehavior ScanBehavior { get; set; } = TrickplayScanBehavior.NonBlocking;
+
+    /// <summary>
+    /// Gets or sets the process priority for the ffmpeg process.
+    /// </summary>
+    public ProcessPriorityClass ProcessPriority { get; set; } = ProcessPriorityClass.BelowNormal;
+
+    /// <summary>
+    /// Gets or sets the interval, in ms, between each new trickplay image.
+    /// </summary>
+    public int Interval { get; set; } = 10000;
+
+    /// <summary>
+    /// Gets or sets the target width resolutions, in px, to generates preview images for.
+    /// </summary>
+    public int[] WidthResolutions { get; set; } = new[] { 320 };
+
+    /// <summary>
+    /// Gets or sets number of tile images to allow in X dimension.
+    /// </summary>
+    public int TileWidth { get; set; } = 10;
+
+    /// <summary>
+    /// Gets or sets number of tile images to allow in Y dimension.
+    /// </summary>
+    public int TileHeight { get; set; } = 10;
+
+    /// <summary>
+    /// Gets or sets the ffmpeg output quality level.
+    /// </summary>
+    public int Qscale { get; set; } = 4;
+
+    /// <summary>
+    /// Gets or sets the jpeg quality to use for image tiles.
+    /// </summary>
+    public int JpegQuality { get; set; } = 90;
+
+    /// <summary>
+    /// Gets or sets the number of threads to be used by ffmpeg.
+    /// </summary>
+    public int ProcessThreads { get; set; } = 1;
+}

+ 17 - 0
MediaBrowser.Model/Configuration/TrickplayScanBehavior.cs

@@ -0,0 +1,17 @@
+namespace MediaBrowser.Model.Configuration;
+
+/// <summary>
+/// Enum TrickplayScanBehavior.
+/// </summary>
+public enum TrickplayScanBehavior
+{
+    /// <summary>
+    /// Starts generation, only return once complete.
+    /// </summary>
+    Blocking,
+
+    /// <summary>
+    /// Start generation, return immediately.
+    /// </summary>
+    NonBlocking
+}

+ 7 - 0
MediaBrowser.Model/Dto/BaseItemDto.cs

@@ -3,6 +3,7 @@
 
 
 using System;
 using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
+using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Model.Drawing;
 using MediaBrowser.Model.Drawing;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Entities;
@@ -568,6 +569,12 @@ namespace MediaBrowser.Model.Dto
         /// <value>The chapters.</value>
         /// <value>The chapters.</value>
         public List<ChapterInfo> Chapters { get; set; }
         public List<ChapterInfo> Chapters { get; set; }
 
 
+        /// <summary>
+        /// Gets or sets the trickplay manifest.
+        /// </summary>
+        /// <value>The trickplay manifest.</value>
+        public Dictionary<string, Dictionary<int, TrickplayInfo>> Trickplay { get; set; }
+
         /// <summary>
         /// <summary>
         /// Gets or sets the type of the location.
         /// Gets or sets the type of the location.
         /// </summary>
         /// </summary>

+ 5 - 0
MediaBrowser.Model/Querying/ItemFields.cs

@@ -34,6 +34,11 @@ namespace MediaBrowser.Model.Querying
         /// </summary>
         /// </summary>
         Chapters,
         Chapters,
 
 
+        /// <summary>
+        /// The trickplay manifest.
+        /// </summary>
+        Trickplay,
+
         ChildCount,
         ChildCount,
 
 
         /// <summary>
         /// <summary>

+ 1 - 1
MediaBrowser.Providers/MediaBrowser.Providers.csproj

@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
 
 
   <!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
   <!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
   <PropertyGroup>
   <PropertyGroup>

+ 118 - 0
MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs

@@ -0,0 +1,118 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Trickplay;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Tasks;
+using Microsoft.Extensions.Logging;
+using TagLib.Ape;
+
+namespace MediaBrowser.Providers.Trickplay;
+
+/// <summary>
+/// Class TrickplayImagesTask.
+/// </summary>
+public class TrickplayImagesTask : IScheduledTask
+{
+    private const int QueryPageLimit = 100;
+
+    private readonly ILogger<TrickplayImagesTask> _logger;
+    private readonly ILibraryManager _libraryManager;
+    private readonly ILocalizationManager _localization;
+    private readonly ITrickplayManager _trickplayManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="TrickplayImagesTask"/> class.
+    /// </summary>
+    /// <param name="logger">The logger.</param>
+    /// <param name="libraryManager">The library manager.</param>
+    /// <param name="localization">The localization manager.</param>
+    /// <param name="trickplayManager">The trickplay manager.</param>
+    public TrickplayImagesTask(
+        ILogger<TrickplayImagesTask> logger,
+        ILibraryManager libraryManager,
+        ILocalizationManager localization,
+        ITrickplayManager trickplayManager)
+    {
+        _libraryManager = libraryManager;
+        _logger = logger;
+        _localization = localization;
+        _trickplayManager = trickplayManager;
+    }
+
+    /// <inheritdoc />
+    public string Name => _localization.GetLocalizedString("TaskRefreshTrickplayImages");
+
+    /// <inheritdoc />
+    public string Description => _localization.GetLocalizedString("TaskRefreshTrickplayImagesDescription");
+
+    /// <inheritdoc />
+    public string Key => "RefreshTrickplayImages";
+
+    /// <inheritdoc />
+    public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
+
+    /// <inheritdoc />
+    public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+    {
+        return new[]
+        {
+            new TaskTriggerInfo
+            {
+                Type = TaskTriggerInfo.TriggerDaily,
+                TimeOfDayTicks = TimeSpan.FromHours(3).Ticks
+            }
+        };
+    }
+
+    /// <inheritdoc />
+    public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+    {
+        var query = new InternalItemsQuery
+        {
+            MediaTypes = new[] { MediaType.Video },
+            SourceTypes = new[] { SourceType.Library },
+            IsVirtualItem = false,
+            IsFolder = false,
+            Recursive = true,
+            Limit = QueryPageLimit
+        };
+
+        var numberOfVideos = _libraryManager.GetCount(query);
+
+        var startIndex = 0;
+        var numComplete = 0;
+
+        while (startIndex < numberOfVideos)
+        {
+            query.StartIndex = startIndex;
+            var videos = _libraryManager.GetItemList(query).OfType<Video>();
+
+            foreach (var video in videos)
+            {
+                cancellationToken.ThrowIfCancellationRequested();
+
+                try
+                {
+                    await _trickplayManager.RefreshTrickplayDataAsync(video, false, cancellationToken).ConfigureAwait(false);
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error creating trickplay files for {ItemName}", video.Name);
+                }
+
+                numComplete++;
+                progress.Report(100d * numComplete / numberOfVideos);
+            }
+
+            startIndex += QueryPageLimit;
+        }
+
+        progress.Report(100);
+    }
+}

+ 121 - 0
MediaBrowser.Providers/Trickplay/TrickplayProvider.cs

@@ -0,0 +1,121 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Trickplay;
+using MediaBrowser.Model.Configuration;
+
+namespace MediaBrowser.Providers.Trickplay;
+
+/// <summary>
+/// Class TrickplayProvider. Provides images and metadata for trickplay
+/// scrubbing previews.
+/// </summary>
+public class TrickplayProvider : ICustomMetadataProvider<Episode>,
+    ICustomMetadataProvider<MusicVideo>,
+    ICustomMetadataProvider<Movie>,
+    ICustomMetadataProvider<Trailer>,
+    ICustomMetadataProvider<Video>,
+    IHasItemChangeMonitor,
+    IHasOrder,
+    IForcedProvider
+{
+    private readonly IServerConfigurationManager _config;
+    private readonly ITrickplayManager _trickplayManager;
+    private readonly ILibraryManager _libraryManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="TrickplayProvider"/> class.
+    /// </summary>
+    /// <param name="config">The configuration manager.</param>
+    /// <param name="trickplayManager">The trickplay manager.</param>
+    /// <param name="libraryManager">The library manager.</param>
+    public TrickplayProvider(
+        IServerConfigurationManager config,
+        ITrickplayManager trickplayManager,
+        ILibraryManager libraryManager)
+    {
+        _config = config;
+        _trickplayManager = trickplayManager;
+        _libraryManager = libraryManager;
+    }
+
+    /// <inheritdoc />
+    public string Name => "Trickplay Provider";
+
+    /// <inheritdoc />
+    public int Order => 100;
+
+    /// <inheritdoc />
+    public bool HasChanged(BaseItem item, IDirectoryService directoryService)
+    {
+        if (item.IsFileProtocol)
+        {
+            var file = directoryService.GetFile(item.Path);
+            if (file is not null && item.DateModified != file.LastWriteTimeUtc)
+            {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /// <inheritdoc />
+    public Task<ItemUpdateType> FetchAsync(Episode item, MetadataRefreshOptions options, CancellationToken cancellationToken)
+    {
+        return FetchInternal(item, options, cancellationToken);
+    }
+
+    /// <inheritdoc />
+    public Task<ItemUpdateType> FetchAsync(MusicVideo item, MetadataRefreshOptions options, CancellationToken cancellationToken)
+    {
+        return FetchInternal(item, options, cancellationToken);
+    }
+
+    /// <inheritdoc />
+    public Task<ItemUpdateType> FetchAsync(Movie item, MetadataRefreshOptions options, CancellationToken cancellationToken)
+    {
+        return FetchInternal(item, options, cancellationToken);
+    }
+
+    /// <inheritdoc />
+    public Task<ItemUpdateType> FetchAsync(Trailer item, MetadataRefreshOptions options, CancellationToken cancellationToken)
+    {
+        return FetchInternal(item, options, cancellationToken);
+    }
+
+    /// <inheritdoc />
+    public Task<ItemUpdateType> FetchAsync(Video item, MetadataRefreshOptions options, CancellationToken cancellationToken)
+    {
+        return FetchInternal(item, options, cancellationToken);
+    }
+
+    private async Task<ItemUpdateType> FetchInternal(Video video, MetadataRefreshOptions options, CancellationToken cancellationToken)
+    {
+        var libraryOptions = _libraryManager.GetLibraryOptions(video);
+        bool? enableDuringScan = libraryOptions?.ExtractTrickplayImagesDuringLibraryScan;
+        bool replace = options.ReplaceAllImages;
+
+        if (options.IsAutomated && !enableDuringScan.GetValueOrDefault(false))
+        {
+            return ItemUpdateType.None;
+        }
+
+        if (_config.Configuration.TrickplayOptions.ScanBehavior == TrickplayScanBehavior.Blocking)
+        {
+            await _trickplayManager.RefreshTrickplayDataAsync(video, replace, cancellationToken).ConfigureAwait(false);
+        }
+        else
+        {
+            _ = _trickplayManager.RefreshTrickplayDataAsync(video, replace, cancellationToken).ConfigureAwait(false);
+        }
+
+        // The core doesn't need to trigger any save operations over this
+        return ItemUpdateType.None;
+    }
+}

+ 79 - 0
src/Jellyfin.Drawing.Skia/SkiaEncoder.cs

@@ -2,14 +2,18 @@ using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Globalization;
 using System.IO;
 using System.IO;
+using System.Linq;
+using System.Security.Cryptography.Xml;
 using BlurHashSharp.SkiaSharp;
 using BlurHashSharp.SkiaSharp;
 using Jellyfin.Extensions;
 using Jellyfin.Extensions;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.LiveTv;
 using MediaBrowser.Model.Drawing;
 using MediaBrowser.Model.Drawing;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 using SkiaSharp;
 using SkiaSharp;
+using static System.Net.Mime.MediaTypeNames;
 using SKSvg = SkiaSharp.Extended.Svg.SKSvg;
 using SKSvg = SkiaSharp.Extended.Svg.SKSvg;
 
 
 namespace Jellyfin.Drawing.Skia;
 namespace Jellyfin.Drawing.Skia;
@@ -515,6 +519,81 @@ public class SkiaEncoder : IImageEncoder
         splashBuilder.GenerateSplash(posters, backdrops, outputPath);
         splashBuilder.GenerateSplash(posters, backdrops, outputPath);
     }
     }
 
 
+    /// <inheritdoc />
+    public int CreateTrickplayTile(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight)
+    {
+        var paths = options.InputPaths;
+        var tileWidth = options.Width;
+        var tileHeight = options.Height;
+
+        if (paths.Count < 1)
+        {
+            throw new ArgumentException("InputPaths cannot be empty.");
+        }
+        else if (paths.Count > tileWidth * tileHeight)
+        {
+            throw new ArgumentException($"InputPaths contains more images than would fit on {tileWidth}x{tileHeight} grid.");
+        }
+
+        // If no height provided, use height of first image.
+        if (!imgHeight.HasValue)
+        {
+            using var firstImg = Decode(paths[0], false, null, out _);
+
+            if (firstImg is null)
+            {
+                throw new InvalidDataException("Could not decode image data.");
+            }
+
+            if (firstImg.Width != imgWidth)
+            {
+                throw new InvalidOperationException("Image width does not match provided width.");
+            }
+
+            imgHeight = firstImg.Height;
+        }
+
+        // Make horizontal strips using every provided image.
+        using var tileGrid = new SKBitmap(imgWidth * tileWidth, imgHeight.Value * tileHeight);
+        using var canvas = new SKCanvas(tileGrid);
+
+        var imgIndex = 0;
+        for (var y = 0; y < tileHeight; y++)
+        {
+            for (var x = 0; x < tileWidth; x++)
+            {
+                if (imgIndex >= paths.Count)
+                {
+                    break;
+                }
+
+                using var img = Decode(paths[imgIndex++], false, null, out _);
+
+                if (img is null)
+                {
+                    throw new InvalidDataException("Could not decode image data.");
+                }
+
+                if (img.Width != imgWidth)
+                {
+                    throw new InvalidOperationException("Image width does not match provided width.");
+                }
+
+                if (img.Height != imgHeight)
+                {
+                    throw new InvalidOperationException("Image height does not match first image height.");
+                }
+
+                canvas.DrawBitmap(img, x * imgWidth, y * imgHeight.Value);
+            }
+        }
+
+        using var outputStream = new SKFileWStream(options.OutputPath);
+        tileGrid.Encode(outputStream, SKEncodedImageFormat.Jpeg, quality);
+
+        return imgHeight.Value;
+    }
+
     private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
     private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
     {
     {
         try
         try

+ 6 - 0
src/Jellyfin.Drawing/NullImageEncoder.cs

@@ -49,6 +49,12 @@ public class NullImageEncoder : IImageEncoder
         throw new NotImplementedException();
         throw new NotImplementedException();
     }
     }
 
 
+    /// <inheritdoc />
+    public int CreateTrickplayTile(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight)
+    {
+        throw new NotImplementedException();
+    }
+
     /// <inheritdoc />
     /// <inheritdoc />
     public string GetImageBlurHash(int xComp, int yComp, string path)
     public string GetImageBlurHash(int xComp, int yComp, string path)
     {
     {