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 infDuration = tileDuration * tilesPerGrid; 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); 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")); } }