From b18d6bd3562a5e5a69f806b461049fbf9f61b70e Mon Sep 17 00:00:00 2001 From: nicknsy <20588554+nicknsy@users.noreply.github.com> Date: Wed, 22 Feb 2023 23:13:55 -0800 Subject: [PATCH] Trickplay playlist and image controller --- .../Controllers/DynamicHlsController.cs | 19 -- .../Controllers/TrickplayController.cs | 177 ++++++++++++++++++ Jellyfin.Api/Helpers/DynamicHlsHelper.cs | 77 +------- 3 files changed, 178 insertions(+), 95 deletions(-) create mode 100644 Jellyfin.Api/Controllers/TrickplayController.cs diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 2dbc343e96..acd8111435 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -1028,25 +1028,6 @@ public class DynamicHlsController : BaseJellyfinApiController .ConfigureAwait(false); } - /// - /// Gets an image tiles playlist for trickplay. - /// - /// The item id. - /// The width of a single tile. - /// The media version id. - /// Tiles stream returned. - /// A containing the trickplay tiles file. - [HttpGet("Videos/{itemId}/tiles.m3u8")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesPlaylistFile] - public ActionResult GetTrickplayTilesHlsPlaylist( - [FromRoute, Required] Guid itemId, - [FromQuery, Required] int width, - [FromQuery, Required] string mediaSourceId) - { - return _dynamicHlsHelper.GetTilesHlsPlaylist(width, mediaSourceId); - } - /// /// Gets a video stream using HTTP live streaming. /// diff --git a/Jellyfin.Api/Controllers/TrickplayController.cs b/Jellyfin.Api/Controllers/TrickplayController.cs new file mode 100644 index 0000000000..389eb43ffc --- /dev/null +++ b/Jellyfin.Api/Controllers/TrickplayController.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Jellyfin.Api.Attributes; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Trickplay; +using MediaBrowser.Model; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Controllers; + +/// +/// Trickplay controller. +/// +[Route("")] +[Authorize] +public class TrickplayController : BaseJellyfinApiController +{ + private readonly ILogger _logger; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILibraryManager _libraryManager; + private readonly ITrickplayManager _trickplayManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of . + /// Instance of . + public TrickplayController( + ILogger logger, + IHttpContextAccessor httpContextAccessor, + ILibraryManager libraryManager, + ITrickplayManager trickplayManager) + { + _logger = logger; + _httpContextAccessor = httpContextAccessor; + _libraryManager = libraryManager; + _trickplayManager = trickplayManager; + } + + /// + /// Gets an image tiles playlist for trickplay. + /// + /// The item id. + /// The width of a single tile. + /// The media version id, if using an alternate version. + /// Tiles stream returned. + /// A containing the trickplay tiles file. + [HttpGet("Videos/{itemId}/Trickplay/{width}/tiles.m3u8")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] + public ActionResult GetTrickplayHlsPlaylist( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] int width, + [FromQuery] string? mediaSourceId) + { + return GetTrickplayPlaylistInternal(width, mediaSourceId ?? itemId.ToString("N")); + } + + /// + /// Gets a trickplay tile grid image. + /// + /// The item id. + /// The width of a single tile. + /// The index of the desired tile grid. + /// The media version id, if using an alternate version. + /// Tiles image returned. + /// Tiles image not found at specified index. + /// A containing the trickplay tiles image. + [HttpGet("Videos/{itemId}/Trickplay/{width}/{index}.jpg")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public ActionResult GetTrickplayHlsPlaylist( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] int width, + [FromRoute, Required] int index, + [FromQuery] string? mediaSourceId) + { + var item = _libraryManager.GetItemById(mediaSourceId ?? itemId.ToString("N")); + 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(); + } + + private ActionResult GetTrickplayPlaylistInternal(int width, string mediaSourceId) + { + if (_httpContextAccessor.HttpContext is null) + { + throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); + } + + var tilesResolutions = _trickplayManager.GetTilesResolutions(Guid.Parse(mediaSourceId)); + if (tilesResolutions is not null && tilesResolutions.ContainsKey(width)) + { + var builder = new StringBuilder(128); + var tilesInfo = tilesResolutions[width]; + + if (tilesInfo.TileCount > 0) + { + const string urlFormat = "Trickplay/{0}/{1}.jpg?MediaSourceId={2}&api_key={3}"; + const string decimalFormat = "{0:0.###}"; + + var resolution = tilesInfo.Width.ToString(CultureInfo.InvariantCulture) + "x" + tilesInfo.Height.ToString(CultureInfo.InvariantCulture); + var layout = tilesInfo.TileWidth.ToString(CultureInfo.InvariantCulture) + "x" + tilesInfo.TileHeight.ToString(CultureInfo.InvariantCulture); + var tilesPerGrid = tilesInfo.TileWidth * tilesInfo.TileHeight; + var tileDuration = (decimal)tilesInfo.Interval / 1000; + var tileGridCount = (int)Math.Ceiling((decimal)tilesInfo.TileCount / tilesPerGrid); + + builder.AppendLine("#EXTM3U"); + builder.Append("#EXT-X-TARGETDURATION:").AppendLine(tileGridCount.ToString(CultureInfo.InvariantCulture)); + builder.AppendLine("#EXT-X-VERSION:7"); + builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:1"); + builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); + builder.AppendLine("#EXT-X-IMAGES-ONLY"); + + for (int i = 0; i < tileGridCount; i++) + { + // All tile grids before the last one must contain full amount of tiles. + // The final grid will be 0 < count <= maxTiles + if (i == tileGridCount - 1) + { + tilesPerGrid = tilesInfo.TileCount - (i * tilesPerGrid); + } + + var infDuration = tileDuration * tilesPerGrid; + var url = string.Format( + CultureInfo.InvariantCulture, + urlFormat, + width.ToString(CultureInfo.InvariantCulture), + i.ToString(CultureInfo.InvariantCulture), + mediaSourceId, + _httpContextAccessor.HttpContext.User.GetToken()); + + // EXTINF + builder.Append("#EXTINF:").Append(string.Format(CultureInfo.InvariantCulture, decimalFormat, infDuration)) + .AppendLine(","); + + // EXT-X-TILES + builder.Append("#EXT-X-TILES:RESOLUTION=").Append(resolution).Append(",LAYOUT=").Append(layout).Append(",DURATION=") + .AppendLine(string.Format(CultureInfo.InvariantCulture, decimalFormat, tileDuration)); + + // URL + builder.AppendLine(url); + } + + builder.AppendLine("#EXT-X-ENDLIST"); + return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); + } + } + + return new FileContentResult(Array.Empty(), MimeTypes.GetMimeType("playlist.m3u8")); + } +} diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index d8a16876df..6508dd8cf9 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -118,81 +118,6 @@ public class DynamicHlsHelper cancellationTokenSource).ConfigureAwait(false); } - /// - /// Get trickplay tiles hls playlist. - /// - /// The width of a single tile. - /// The media version id. - /// The resulting . - public ActionResult GetTilesHlsPlaylist(int width, string mediaSourceId) - { - if (_httpContextAccessor.HttpContext is null) - { - throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); - } - - var tilesResolutions = _trickplayManager.GetTilesResolutions(Guid.Parse(mediaSourceId)); - if (tilesResolutions is not null && tilesResolutions.ContainsKey(width)) - { - var builder = new StringBuilder(128); - var tilesInfo = tilesResolutions[width]; - - if (tilesInfo.TileCount > 0) - { - const string urlFormat = "{0}/Trickplay/{1}/{2}.jpg?&api_key={3}"; - const string decimalFormat = "{0:0.###}"; - - var resolution = tilesInfo.Width.ToString(CultureInfo.InvariantCulture) + "x" + tilesInfo.Height.ToString(CultureInfo.InvariantCulture); - var layout = tilesInfo.TileWidth.ToString(CultureInfo.InvariantCulture) + "x" + tilesInfo.TileHeight.ToString(CultureInfo.InvariantCulture); - var tilesPerGrid = tilesInfo.TileWidth * tilesInfo.TileHeight; - var tileDuration = (decimal)tilesInfo.Interval / 1000; - var tileGridCount = (int)Math.Ceiling((decimal)tilesInfo.TileCount / tilesPerGrid); - - builder.AppendLine("#EXTM3U"); - builder.Append("#EXT-X-TARGETDURATION:").AppendLine(tileGridCount.ToString(CultureInfo.InvariantCulture)); - builder.AppendLine("#EXT-X-VERSION:7"); - builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:1"); - builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); - builder.AppendLine("#EXT-X-IMAGES-ONLY"); - - for (int i = 0; i < tileGridCount; i++) - { - // All tile grids before the last one must contain full amount of tiles. - // The final grid will be 0 < count <= maxTiles - if (i == tileGridCount - 1) - { - tilesPerGrid = tilesInfo.TileCount - (i * tilesPerGrid); - } - - var infDuration = tileDuration * tilesPerGrid; - var url = string.Format( - CultureInfo.InvariantCulture, - urlFormat, - mediaSourceId, - width.ToString(CultureInfo.InvariantCulture), - i.ToString(CultureInfo.InvariantCulture), - _httpContextAccessor.HttpContext.User.GetToken()); - - // EXTINF - builder.Append("#EXTINF:").Append(string.Format(CultureInfo.InvariantCulture, decimalFormat, infDuration)) - .AppendLine(","); - - // EXT-X-TILES - builder.Append("#EXT-X-TILES:RESOLUTION=").Append(resolution).Append(",LAYOUT=").Append(layout).Append(",DURATION=") - .AppendLine(string.Format(CultureInfo.InvariantCulture, decimalFormat, tileDuration)); - - // URL - builder.AppendLine(url); - } - - builder.AppendLine("#EXT-X-ENDLIST"); - return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); - } - } - - return new FileContentResult(Array.Empty(), MimeTypes.GetMimeType("playlist.m3u8")); - } - private async Task GetMasterPlaylistInternal( StreamingRequestDto streamingRequest, bool isHeadRequest, @@ -633,7 +558,7 @@ public class DynamicHlsHelper var url = string.Format( CultureInfo.InvariantCulture, - "tiles.m3u8?Width={0}&MediaSourceId={1}&api_key={2}", + "Trickplay/{0}/tiles.m3u8?MediaSourceId={1}&api_key={2}", width.ToString(CultureInfo.InvariantCulture), state.Request.MediaSourceId, user.GetToken());