using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; using System.Net.Mime; using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Models.SubtitleDtos; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Subtitles; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Net; using MediaBrowser.Model.Providers; using MediaBrowser.Model.Subtitles; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Controllers { /// /// Subtitle controller. /// [Route("")] public class SubtitleController : BaseJellyfinApiController { private readonly IServerConfigurationManager _serverConfigurationManager; private readonly ILibraryManager _libraryManager; private readonly ISubtitleManager _subtitleManager; private readonly ISubtitleEncoder _subtitleEncoder; private readonly IMediaSourceManager _mediaSourceManager; private readonly IProviderManager _providerManager; private readonly IFileSystem _fileSystem; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// Instance of interface. /// Instance of interface. /// Instance of interface. /// Instance of interface. /// Instance of interface. /// Instance of interface. /// Instance of interface. /// Instance of interface. public SubtitleController( IServerConfigurationManager serverConfigurationManager, ILibraryManager libraryManager, ISubtitleManager subtitleManager, ISubtitleEncoder subtitleEncoder, IMediaSourceManager mediaSourceManager, IProviderManager providerManager, IFileSystem fileSystem, ILogger logger) { _serverConfigurationManager = serverConfigurationManager; _libraryManager = libraryManager; _subtitleManager = subtitleManager; _subtitleEncoder = subtitleEncoder; _mediaSourceManager = mediaSourceManager; _providerManager = providerManager; _fileSystem = fileSystem; _logger = logger; } /// /// Deletes an external subtitle file. /// /// The item id. /// The index of the subtitle file. /// Subtitle deleted. /// Item not found. /// A . [HttpDelete("Videos/{itemId}/Subtitles/{index}")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult DeleteSubtitle( [FromRoute, Required] Guid itemId, [FromRoute, Required] int index) { var item = _libraryManager.GetItemById(itemId); if (item is null) { return NotFound(); } _subtitleManager.DeleteSubtitles(item, index); return NoContent(); } /// /// Search remote subtitles. /// /// The item id. /// The language of the subtitles. /// Optional. Only show subtitles which are a perfect match. /// Subtitles retrieved. /// An array of . [HttpGet("Items/{itemId}/RemoteSearch/Subtitles/{language}")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task>> SearchRemoteSubtitles( [FromRoute, Required] Guid itemId, [FromRoute, Required] string language, [FromQuery] bool? isPerfectMatch) { var video = (Video)_libraryManager.GetItemById(itemId); return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false); } /// /// Downloads a remote subtitle. /// /// The item id. /// The subtitle id. /// Subtitle downloaded. /// A . [HttpPost("Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task DownloadRemoteSubtitles( [FromRoute, Required] Guid itemId, [FromRoute, Required] string subtitleId) { var video = (Video)_libraryManager.GetItemById(itemId); try { await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None) .ConfigureAwait(false); _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); } catch (Exception ex) { _logger.LogError(ex, "Error downloading subtitles"); } return NoContent(); } /// /// Gets the remote subtitles. /// /// The item id. /// File returned. /// A with the subtitle file. [HttpGet("Providers/Subtitles/Subtitles/{id}")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] [Produces(MediaTypeNames.Application.Octet)] [ProducesFile("text/*")] public async Task GetRemoteSubtitles([FromRoute, Required] string id) { var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false); return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format)); } /// /// Gets subtitles in a specified format. /// /// The (route) item id. /// The (route) media source id. /// The (route) subtitle stream index. /// The (route) format of the returned subtitle. /// The item id. /// The media source id. /// The subtitle stream index. /// The format of the returned subtitle. /// Optional. The end position of the subtitle in ticks. /// Optional. Whether to copy the timestamps. /// Optional. Whether to add a VTT time map. /// The start position of the subtitle in ticks. /// File returned. /// A with the subtitle file. [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/Stream.{routeFormat}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesFile("text/*")] public async Task GetSubtitle( [FromRoute, Required] Guid routeItemId, [FromRoute, Required] string routeMediaSourceId, [FromRoute, Required] int routeIndex, [FromRoute, Required] string routeFormat, [FromQuery, ParameterObsolete] Guid? itemId, [FromQuery, ParameterObsolete] string? mediaSourceId, [FromQuery, ParameterObsolete] int? index, [FromQuery, ParameterObsolete] string? format, [FromQuery] long? endPositionTicks, [FromQuery] bool copyTimestamps = false, [FromQuery] bool addVttTimeMap = false, [FromQuery] long startPositionTicks = 0) { // Set parameters to route value if not provided via query. itemId ??= routeItemId; mediaSourceId ??= routeMediaSourceId; index ??= routeIndex; format ??= routeFormat; if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase)) { format = "json"; } if (string.IsNullOrEmpty(format)) { var item = (Video)_libraryManager.GetItemById(itemId.Value); var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture); var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal)); var subtitleStream = mediaSource.MediaStreams .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index); return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path)); } if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap) { await using Stream stream = await EncodeSubtitles(itemId.Value, mediaSourceId, index.Value, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false); using var reader = new StreamReader(stream); var text = await reader.ReadToEndAsync().ConfigureAwait(false); text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal); return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format)); } return File( await EncodeSubtitles( itemId.Value, mediaSourceId, index.Value, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false), MimeTypes.GetMimeType("file." + format)); } /// /// Gets subtitles in a specified format. /// /// The (route) item id. /// The (route) media source id. /// The (route) subtitle stream index. /// The (route) start position of the subtitle in ticks. /// The (route) format of the returned subtitle. /// The item id. /// The media source id. /// The subtitle stream index. /// The start position of the subtitle in ticks. /// The format of the returned subtitle. /// Optional. The end position of the subtitle in ticks. /// Optional. Whether to copy the timestamps. /// Optional. Whether to add a VTT time map. /// File returned. /// A with the subtitle file. [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/{routeStartPositionTicks}/Stream.{routeFormat}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesFile("text/*")] public Task GetSubtitleWithTicks( [FromRoute, Required] Guid routeItemId, [FromRoute, Required] string routeMediaSourceId, [FromRoute, Required] int routeIndex, [FromRoute, Required] long routeStartPositionTicks, [FromRoute, Required] string routeFormat, [FromQuery, ParameterObsolete] Guid? itemId, [FromQuery, ParameterObsolete] string? mediaSourceId, [FromQuery, ParameterObsolete] int? index, [FromQuery, ParameterObsolete] long? startPositionTicks, [FromQuery, ParameterObsolete] string? format, [FromQuery] long? endPositionTicks, [FromQuery] bool copyTimestamps = false, [FromQuery] bool addVttTimeMap = false) { return GetSubtitle( routeItemId, routeMediaSourceId, routeIndex, routeFormat, itemId, mediaSourceId, index, format, endPositionTicks, copyTimestamps, addVttTimeMap, startPositionTicks ?? routeStartPositionTicks); } /// /// Gets an HLS subtitle playlist. /// /// The item id. /// The subtitle stream index. /// The media source id. /// The subtitle segment length. /// Subtitle playlist retrieved. /// A with the HLS subtitle playlist. [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesPlaylistFile] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] public async Task GetSubtitlePlaylist( [FromRoute, Required] Guid itemId, [FromRoute, Required] int index, [FromRoute, Required] string mediaSourceId, [FromQuery, Required] int segmentLength) { var item = (Video)_libraryManager.GetItemById(itemId); var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false); var runtime = mediaSource.RunTimeTicks ?? -1; if (runtime <= 0) { throw new ArgumentException("HLS Subtitles are not supported for this media."); } var segmentLengthTicks = TimeSpan.FromSeconds(segmentLength).Ticks; if (segmentLengthTicks <= 0) { throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger than 0)"); } var builder = new StringBuilder(); builder.AppendLine("#EXTM3U") .Append("#EXT-X-TARGETDURATION:") .Append(segmentLength) .AppendLine() .AppendLine("#EXT-X-VERSION:3") .AppendLine("#EXT-X-MEDIA-SEQUENCE:0") .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); long positionTicks = 0; var accessToken = User.GetToken(); while (positionTicks < runtime) { var remaining = runtime - positionTicks; var lengthTicks = Math.Min(remaining, segmentLengthTicks); builder.Append("#EXTINF:") .Append(TimeSpan.FromTicks(lengthTicks).TotalSeconds) .Append(',') .AppendLine(); var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks); var url = string.Format( CultureInfo.InvariantCulture, "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}", positionTicks.ToString(CultureInfo.InvariantCulture), endPositionTicks.ToString(CultureInfo.InvariantCulture), accessToken); builder.AppendLine(url); positionTicks += segmentLengthTicks; } builder.AppendLine("#EXT-X-ENDLIST"); return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); } /// /// Upload an external subtitle file. /// /// The item the subtitle belongs to. /// The request body. /// Subtitle uploaded. /// A . [HttpPost("Videos/{itemId}/Subtitles")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task UploadSubtitle( [FromRoute, Required] Guid itemId, [FromBody, Required] UploadSubtitleDto body) { var video = (Video)_libraryManager.GetItemById(itemId); var data = Convert.FromBase64String(body.Data); await using var memoryStream = new MemoryStream(data); await _subtitleManager.UploadSubtitle( video, new SubtitleResponse { Format = body.Format, Language = body.Language, IsForced = body.IsForced, Stream = memoryStream }).ConfigureAwait(false); _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); return NoContent(); } /// /// Encodes a subtitle in the specified format. /// /// The media id. /// The source media id. /// The subtitle index. /// The format to convert to. /// The start position in ticks. /// The end position in ticks. /// Whether to copy the timestamps. /// A with the new subtitle file. private Task EncodeSubtitles( Guid id, string? mediaSourceId, int index, string format, long startPositionTicks, long? endPositionTicks, bool copyTimestamps) { var item = _libraryManager.GetItemById(id); return _subtitleEncoder.GetSubtitles( item, mediaSourceId, index, format, startPositionTicks, endPositionTicks ?? 0, copyTimestamps, CancellationToken.None); } /// /// Gets a list of available fallback font files. /// /// Information retrieved. /// An array of with the available font files. [HttpGet("FallbackFont/Fonts")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] public IEnumerable GetFallbackFontList() { var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); var fallbackFontPath = encodingOptions.FallbackFontPath; if (!string.IsNullOrEmpty(fallbackFontPath)) { var files = _fileSystem.GetFiles(fallbackFontPath, new[] { ".woff", ".woff2", ".ttf", ".otf" }, false, false); var fontFiles = files .Select(i => new FontFile { Name = i.Name, Size = i.Length, DateCreated = _fileSystem.GetCreationTimeUtc(i), DateModified = _fileSystem.GetLastWriteTimeUtc(i) }) .OrderBy(i => i.Size) .ThenBy(i => i.Name) .ThenByDescending(i => i.DateModified) .ThenByDescending(i => i.DateCreated); // max total size 20M const int MaxSize = 20971520; var sizeCounter = 0L; foreach (var fontFile in fontFiles) { sizeCounter += fontFile.Size; if (sizeCounter >= MaxSize) { _logger.LogWarning("Some fonts will not be sent due to size limitations"); yield break; } yield return fontFile; } } else { _logger.LogWarning("The path of fallback font folder has not been set"); encodingOptions.EnableFallbackFont = false; } } /// /// Gets a fallback font file. /// /// The name of the fallback font file to get. /// Fallback font file retrieved. /// The fallback font file. [HttpGet("FallbackFont/Fonts/{name}")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesFile("font/*")] public ActionResult GetFallbackFont([FromRoute, Required] string name) { var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); var fallbackFontPath = encodingOptions.FallbackFontPath; if (!string.IsNullOrEmpty(fallbackFontPath)) { var fontFile = _fileSystem.GetFiles(fallbackFontPath) .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); var fileSize = fontFile?.Length; if (fontFile != null && fileSize != null && fileSize > 0) { _logger.LogDebug("Fallback font size is {FileSize} Bytes", fileSize); return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName)); } else { _logger.LogWarning("The selected font is null or empty"); } } else { _logger.LogWarning("The path of fallback font folder has not been set"); encodingOptions.EnableFallbackFont = false; } // returning HTTP 204 will break the SubtitlesOctopus return Ok(); } } }