#nullable enable #pragma warning disable CA1801 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; using System.Threading.Tasks; using Jellyfin.Api.Constants; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; 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 Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Controllers { /// /// Subtitle controller. /// public class SubtitleController : BaseJellyfinApiController { 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 IAuthorizationContext _authContext; 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( ILibraryManager libraryManager, ISubtitleManager subtitleManager, ISubtitleEncoder subtitleEncoder, IMediaSourceManager mediaSourceManager, IProviderManager providerManager, IFileSystem fileSystem, IAuthorizationContext authContext, ILogger logger) { _libraryManager = libraryManager; _subtitleManager = subtitleManager; _subtitleEncoder = subtitleEncoder; _mediaSourceManager = mediaSourceManager; _providerManager = providerManager; _fileSystem = fileSystem; _authContext = authContext; _logger = logger; } /// /// Deletes an external subtitle file. /// /// The item id. /// The index of the subtitle file. /// Subtitle deleted. /// Item not found. /// A . [HttpDelete("/Videos/{id}/Subtitles/{index}")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult DeleteSubtitle( [FromRoute] Guid id, [FromRoute] int index) { var item = _libraryManager.GetItemById(id); if (item == 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/{id}/RemoteSearch/Subtitles/{language}")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task>> SearchRemoteSubtitles( [FromRoute] Guid id, [FromRoute] string language, [FromQuery] bool? isPerfectMatch) { var video = (Video)_libraryManager.GetItemById(id); return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, CancellationToken.None).ConfigureAwait(false); } /// /// Downloads a remote subtitle. /// /// The item id. /// The subtitle id. /// Subtitle downloaded. /// A . [HttpPost("/Items/{id}/RemoteSearch/Subtitles/{subtitleId}")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task DownloadRemoteSubtitles( [FromRoute] Guid id, [FromRoute] string subtitleId) { var video = (Video)_libraryManager.GetItemById(id); 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)] public async Task GetRemoteSubtitles([FromRoute] 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 item id. /// The media source id. /// The subtitle stream index. /// The format of the returned subtitle. /// Optional. The start position of the subtitle in ticks. /// 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/{id}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")] [HttpGet("/Videos/{id}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks}/Stream.{format}")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task GetSubtitle( [FromRoute, Required] Guid id, [FromRoute, Required] string mediaSourceId, [FromRoute, Required] int index, [FromRoute, Required] string format, [FromRoute] long startPositionTicks, [FromQuery] long? endPositionTicks, [FromQuery] bool copyTimestamps, [FromQuery] bool addVttTimeMap) { if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase)) { format = "json"; } if (string.IsNullOrEmpty(format)) { var item = (Video)_libraryManager.GetItemById(id); var idString = id.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); FileStream stream = new FileStream(subtitleStream.Path, FileMode.Open, FileAccess.Read); return File(stream, MimeTypes.GetMimeType(subtitleStream.Path)); } if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap) { await using Stream stream = await EncodeSubtitles(id, mediaSourceId, index, 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( id, mediaSourceId, index, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false), MimeTypes.GetMimeType("file." + format)); } /// /// 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/{id}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task GetSubtitlePlaylist( [FromRoute] Guid id, // TODO: 'int index' is never used: CA1801 is disabled [FromRoute] int index, [FromRoute] string mediaSourceId, [FromQuery, Required] int segmentLength) { var item = (Video)_libraryManager.GetItemById(id); var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false); var builder = new StringBuilder(); 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)"); } builder.AppendLine("#EXTM3U"); builder.AppendLine("#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture)); builder.AppendLine("#EXT-X-VERSION:3"); builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0"); builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); long positionTicks = 0; var accessToken = _authContext.GetAuthorizationInfo(Request).Token; while (positionTicks < runtime) { var remaining = runtime - positionTicks; var lengthTicks = Math.Min(remaining, segmentLengthTicks); builder.AppendLine("#EXTINF:" + TimeSpan.FromTicks(lengthTicks).TotalSeconds.ToString(CultureInfo.InvariantCulture) + ","); var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks); var url = string.Format( CultureInfo.CurrentCulture, "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")); } /// /// 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); } } }