diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 0201ed7a34..99530bfbd2 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -632,6 +632,9 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); } /// diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index fb3f09ba65..d300b4891e 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -1,93 +1,32 @@ using System; using System.Collections.Generic; -using System.Net.Http; -using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.StreamingDtos; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Dlna; -using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; using MediaBrowser.Model.Dlna; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Net; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; namespace Jellyfin.Api.Controllers { /// /// The audio controller. /// - // TODO: In order to autheneticate this in the future, Dlna playback will require updating + // TODO: In order to authenticate this in the future, Dlna playback will require updating public class AudioController : BaseJellyfinApiController { - private readonly IDlnaManager _dlnaManager; - private readonly IAuthorizationContext _authContext; - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IFileSystem _fileSystem; - private readonly ISubtitleEncoder _subtitleEncoder; - private readonly IConfiguration _configuration; - private readonly IDeviceManager _deviceManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - private readonly IHttpClientFactory _httpClientFactory; + private readonly AudioHelper _audioHelper; private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive; /// /// Initializes a new instance of the class. /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// The singleton. - /// Instance of the interface. - public AudioController( - IDlnaManager dlnaManager, - IUserManager userManger, - IAuthorizationContext authorizationContext, - ILibraryManager libraryManager, - IMediaSourceManager mediaSourceManager, - IServerConfigurationManager serverConfigurationManager, - IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - ISubtitleEncoder subtitleEncoder, - IConfiguration configuration, - IDeviceManager deviceManager, - TranscodingJobHelper transcodingJobHelper, - IHttpClientFactory httpClientFactory) + /// Instance of . + public AudioController(AudioHelper audioHelper) { - _dlnaManager = dlnaManager; - _authContext = authorizationContext; - _userManager = userManger; - _libraryManager = libraryManager; - _mediaSourceManager = mediaSourceManager; - _serverConfigurationManager = serverConfigurationManager; - _mediaEncoder = mediaEncoder; - _fileSystem = fileSystem; - _subtitleEncoder = subtitleEncoder; - _configuration = configuration; - _deviceManager = deviceManager; - _transcodingJobHelper = transcodingJobHelper; - _httpClientFactory = httpClientFactory; + _audioHelper = audioHelper; } /// @@ -200,10 +139,6 @@ namespace Jellyfin.Api.Controllers [FromQuery] EncodingContext? context, [FromQuery] Dictionary? streamOptions) { - bool isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; - - var cancellationTokenSource = new CancellationTokenSource(); - StreamingRequestDto streamingRequest = new StreamingRequestDto { Id = itemId, @@ -257,97 +192,7 @@ namespace Jellyfin.Api.Controllers StreamOptions = streamOptions }; - using var state = await StreamingHelpers.GetStreamingState( - streamingRequest, - Request, - _authContext, - _mediaSourceManager, - _userManager, - _libraryManager, - _serverConfigurationManager, - _mediaEncoder, - _fileSystem, - _subtitleEncoder, - _configuration, - _dlnaManager, - _deviceManager, - _transcodingJobHelper, - _transcodingJobType, - cancellationTokenSource.Token) - .ConfigureAwait(false); - - if (@static.HasValue && @static.Value && state.DirectStreamProvider != null) - { - StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager); - - await new ProgressiveFileCopier(state.DirectStreamProvider, null, _transcodingJobHelper, CancellationToken.None) - { - AllowEndOfFile = false - }.WriteToAsync(Response.Body, CancellationToken.None) - .ConfigureAwait(false); - - // TODO (moved from MediaBrowser.Api): Don't hardcode contentType - return File(Response.Body, MimeTypes.GetMimeType("file.ts")!); - } - - // Static remote stream - if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http) - { - StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager); - - var httpClient = _httpClientFactory.CreateClient(); - return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, isHeadRequest, this, httpClient).ConfigureAwait(false); - } - - if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File) - { - return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically"); - } - - var outputPath = state.OutputFilePath; - var outputPathExists = System.IO.File.Exists(outputPath); - - var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); - var isTranscodeCached = outputPathExists && transcodingJob != null; - - StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, startTimeTicks, Request, _dlnaManager); - - // Static stream - if (@static.HasValue && @static.Value) - { - var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); - - if (state.MediaSource.IsInfiniteStream) - { - await new ProgressiveFileCopier(state.MediaPath, null, _transcodingJobHelper, CancellationToken.None) - { - AllowEndOfFile = false - }.WriteToAsync(Response.Body, CancellationToken.None) - .ConfigureAwait(false); - - return File(Response.Body, contentType); - } - - return FileStreamResponseHelpers.GetStaticFileResult( - state.MediaPath, - contentType, - isHeadRequest, - this); - } - - // Need to start ffmpeg (because media can't be returned directly) - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration); - var ffmpegCommandLineArguments = encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath); - return await FileStreamResponseHelpers.GetTranscodedFile( - state, - isHeadRequest, - this, - _transcodingJobHelper, - ffmpegCommandLineArguments, - Request, - _transcodingJobType, - cancellationTokenSource).ConfigureAwait(false); + return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false); } } } diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs index 23d10e2156..1aed20ade6 100644 --- a/Jellyfin.Api/Controllers/DevicesController.cs +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -48,7 +48,7 @@ namespace Jellyfin.Api.Controllers [HttpGet] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetDevices([FromQuery] bool? supportsSync, [FromQuery, Required] Guid? userId) + public ActionResult> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId) { var deviceQuery = new DeviceQuery { SupportsSync = supportsSync, UserId = userId ?? Guid.Empty }; return _deviceManager.GetDevices(deviceQuery); diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index d581ab8cd2..b4fe3bc8f8 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -13,7 +13,6 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.PlaybackDtos; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Dlna; @@ -22,7 +21,6 @@ using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Net; using Microsoft.AspNetCore.Authorization; @@ -53,9 +51,9 @@ namespace Jellyfin.Api.Controllers private readonly IConfiguration _configuration; private readonly IDeviceManager _deviceManager; private readonly TranscodingJobHelper _transcodingJobHelper; - private readonly INetworkManager _networkManager; private readonly ILogger _logger; private readonly EncodingHelper _encodingHelper; + private readonly DynamicHlsHelper _dynamicHlsHelper; private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls; @@ -74,8 +72,8 @@ namespace Jellyfin.Api.Controllers /// Instance of the interface. /// Instance of the interface. /// Instance of the class. - /// Instance of the interface. /// Instance of the interface. + /// Instance of . public DynamicHlsController( ILibraryManager libraryManager, IUserManager userManager, @@ -89,8 +87,8 @@ namespace Jellyfin.Api.Controllers IConfiguration configuration, IDeviceManager deviceManager, TranscodingJobHelper transcodingJobHelper, - INetworkManager networkManager, - ILogger logger) + ILogger logger, + DynamicHlsHelper dynamicHlsHelper) { _libraryManager = libraryManager; _userManager = userManager; @@ -104,8 +102,8 @@ namespace Jellyfin.Api.Controllers _configuration = configuration; _deviceManager = deviceManager; _transcodingJobHelper = transcodingJobHelper; - _networkManager = networkManager; _logger = logger; + _dynamicHlsHelper = dynamicHlsHelper; _encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration); } @@ -220,8 +218,6 @@ namespace Jellyfin.Api.Controllers [FromQuery] Dictionary streamOptions, [FromQuery] bool enableAdaptiveBitrateStreaming = true) { - var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; - var cancellationTokenSource = new CancellationTokenSource(); var streamingRequest = new HlsVideoRequestDto { Id = itemId, @@ -276,8 +272,7 @@ namespace Jellyfin.Api.Controllers EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming }; - return await GetMasterPlaylistInternal(streamingRequest, isHeadRequest, enableAdaptiveBitrateStreaming, cancellationTokenSource) - .ConfigureAwait(false); + return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); } /// @@ -390,8 +385,6 @@ namespace Jellyfin.Api.Controllers [FromQuery] Dictionary streamOptions, [FromQuery] bool enableAdaptiveBitrateStreaming = true) { - var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; - var cancellationTokenSource = new CancellationTokenSource(); var streamingRequest = new HlsAudioRequestDto { Id = itemId, @@ -446,8 +439,7 @@ namespace Jellyfin.Api.Controllers EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming }; - return await GetMasterPlaylistInternal(streamingRequest, isHeadRequest, enableAdaptiveBitrateStreaming, cancellationTokenSource) - .ConfigureAwait(false); + return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); } /// @@ -1118,106 +1110,6 @@ namespace Jellyfin.Api.Controllers .ConfigureAwait(false); } - private async Task GetMasterPlaylistInternal( - StreamingRequestDto streamingRequest, - bool isHeadRequest, - bool enableAdaptiveBitrateStreaming, - CancellationTokenSource cancellationTokenSource) - { - using var state = await StreamingHelpers.GetStreamingState( - streamingRequest, - Request, - _authContext, - _mediaSourceManager, - _userManager, - _libraryManager, - _serverConfigurationManager, - _mediaEncoder, - _fileSystem, - _subtitleEncoder, - _configuration, - _dlnaManager, - _deviceManager, - _transcodingJobHelper, - _transcodingJobType, - cancellationTokenSource.Token) - .ConfigureAwait(false); - - Response.Headers.Add(HeaderNames.Expires, "0"); - if (isHeadRequest) - { - return new FileContentResult(Array.Empty(), MimeTypes.GetMimeType("playlist.m3u8")); - } - - var totalBitrate = state.OutputAudioBitrate ?? 0 + state.OutputVideoBitrate ?? 0; - - var builder = new StringBuilder(); - - builder.AppendLine("#EXTM3U"); - - var isLiveStream = state.IsSegmentedLiveStream; - - var queryString = Request.QueryString.ToString(); - - // from universal audio service - if (queryString.IndexOf("SegmentContainer", StringComparison.OrdinalIgnoreCase) == -1 && !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)) - { - queryString += "&SegmentContainer=" + state.Request.SegmentContainer; - } - - // from universal audio service - if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons) && queryString.IndexOf("TranscodeReasons=", StringComparison.OrdinalIgnoreCase) == -1) - { - queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons; - } - - // Main stream - var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8"; - - playlistUrl += queryString; - - var subtitleStreams = state.MediaSource - .MediaStreams - .Where(i => i.IsTextSubtitleStream) - .ToList(); - - var subtitleGroup = subtitleStreams.Count > 0 && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Hls || state.VideoRequest!.EnableSubtitlesInManifest) - ? "subs" - : null; - - // If we're burning in subtitles then don't add additional subs to the manifest - if (state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) - { - subtitleGroup = null; - } - - if (!string.IsNullOrWhiteSpace(subtitleGroup)) - { - AddSubtitles(state, subtitleStreams, builder); - } - - AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); - - if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming)) - { - var requestedVideoBitrate = state.VideoRequest == null ? 0 : state.VideoRequest.VideoBitRate ?? 0; - - // By default, vary by just 200k - var variation = GetBitrateVariation(totalBitrate); - - var newBitrate = totalBitrate - variation; - var variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); - AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); - - variation *= 2; - newBitrate = totalBitrate - variation; - variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); - AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); - } - - return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); - } - private async Task GetVariantPlaylistInternal(StreamingRequestDto streamingRequest, string name, CancellationTokenSource cancellationTokenSource) { using var state = await StreamingHelpers.GetStreamingState( @@ -1411,330 +1303,6 @@ namespace Jellyfin.Api.Controllers return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } - private void AddSubtitles(StreamState state, IEnumerable subtitles, StringBuilder builder) - { - var selectedIndex = state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index; - const string Format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\""; - - foreach (var stream in subtitles) - { - var name = stream.DisplayTitle; - - var isDefault = selectedIndex.HasValue && selectedIndex.Value == stream.Index; - var isForced = stream.IsForced; - - var url = string.Format( - CultureInfo.InvariantCulture, - "{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&api_key={3}", - state.Request.MediaSourceId, - stream.Index.ToString(CultureInfo.InvariantCulture), - 30.ToString(CultureInfo.InvariantCulture), - ClaimHelpers.GetToken(Request.HttpContext.User)); - - var line = string.Format( - CultureInfo.InvariantCulture, - Format, - name, - isDefault ? "YES" : "NO", - isForced ? "YES" : "NO", - url, - stream.Language ?? "Unknown"); - - builder.AppendLine(line); - } - } - - private void AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) - { - builder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") - .Append(bitrate.ToString(CultureInfo.InvariantCulture)) - .Append(",AVERAGE-BANDWIDTH=") - .Append(bitrate.ToString(CultureInfo.InvariantCulture)); - - AppendPlaylistCodecsField(builder, state); - - AppendPlaylistResolutionField(builder, state); - - AppendPlaylistFramerateField(builder, state); - - if (!string.IsNullOrWhiteSpace(subtitleGroup)) - { - builder.Append(",SUBTITLES=\"") - .Append(subtitleGroup) - .Append('"'); - } - - builder.Append(Environment.NewLine); - builder.AppendLine(url); - } - - /// - /// Appends a CODECS field containing formatted strings of - /// the active streams output video and audio codecs. - /// - /// - /// - /// - /// StringBuilder to append the field to. - /// StreamState of the current stream. - private void AppendPlaylistCodecsField(StringBuilder builder, StreamState state) - { - // Video - string videoCodecs = string.Empty; - int? videoCodecLevel = GetOutputVideoCodecLevel(state); - if (!string.IsNullOrEmpty(state.ActualOutputVideoCodec) && videoCodecLevel.HasValue) - { - videoCodecs = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value); - } - - // Audio - string audioCodecs = string.Empty; - if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec)) - { - audioCodecs = GetPlaylistAudioCodecs(state); - } - - StringBuilder codecs = new StringBuilder(); - - codecs.Append(videoCodecs); - - if (!string.IsNullOrEmpty(videoCodecs) && !string.IsNullOrEmpty(audioCodecs)) - { - codecs.Append(','); - } - - codecs.Append(audioCodecs); - - if (codecs.Length > 1) - { - builder.Append(",CODECS=\"") - .Append(codecs) - .Append('"'); - } - } - - /// - /// Appends a RESOLUTION field containing the resolution of the output stream. - /// - /// - /// StringBuilder to append the field to. - /// StreamState of the current stream. - private void AppendPlaylistResolutionField(StringBuilder builder, StreamState state) - { - if (state.OutputWidth.HasValue && state.OutputHeight.HasValue) - { - builder.Append(",RESOLUTION=") - .Append(state.OutputWidth.GetValueOrDefault()) - .Append('x') - .Append(state.OutputHeight.GetValueOrDefault()); - } - } - - /// - /// Appends a FRAME-RATE field containing the framerate of the output stream. - /// - /// - /// StringBuilder to append the field to. - /// StreamState of the current stream. - private void AppendPlaylistFramerateField(StringBuilder builder, StreamState state) - { - double? framerate = null; - if (state.TargetFramerate.HasValue) - { - framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3); - } - else if (state.VideoStream?.RealFrameRate != null) - { - framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3); - } - - if (framerate.HasValue) - { - builder.Append(",FRAME-RATE=") - .Append(framerate.Value); - } - } - - private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming) - { - // Within the local network this will likely do more harm than good. - var ip = RequestHelpers.NormalizeIp(Request.HttpContext.Connection.RemoteIpAddress).ToString(); - if (_networkManager.IsInLocalNetwork(ip)) - { - return false; - } - - if (!enableAdaptiveBitrateStreaming) - { - return false; - } - - if (isLiveStream || string.IsNullOrWhiteSpace(state.MediaPath)) - { - // Opening live streams is so slow it's not even worth it - return false; - } - - if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) - { - return false; - } - - if (EncodingHelper.IsCopyCodec(state.OutputAudioCodec)) - { - return false; - } - - if (!state.IsOutputVideo) - { - return false; - } - - // Having problems in android - return false; - // return state.VideoRequest.VideoBitRate.HasValue; - } - - /// - /// Get the H.26X level of the output video stream. - /// - /// StreamState of the current stream. - /// H.26X level of the output video stream. - private int? GetOutputVideoCodecLevel(StreamState state) - { - string? levelString; - if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) - && state.VideoStream.Level.HasValue) - { - levelString = state.VideoStream?.Level.ToString(); - } - else - { - levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec); - } - - if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel)) - { - return parsedLevel; - } - - return null; - } - - /// - /// Gets a formatted string of the output audio codec, for use in the CODECS field. - /// - /// - /// - /// StreamState of the current stream. - /// Formatted audio codec string. - private string GetPlaylistAudioCodecs(StreamState state) - { - if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase)) - { - string? profile = state.GetRequestedProfiles("aac").FirstOrDefault(); - return HlsCodecStringHelpers.GetAACString(profile); - } - - if (string.Equals(state.ActualOutputAudioCodec, "mp3", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringHelpers.GetMP3String(); - } - - if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringHelpers.GetAC3String(); - } - - if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringHelpers.GetEAC3String(); - } - - return string.Empty; - } - - /// - /// Gets a formatted string of the output video codec, for use in the CODECS field. - /// - /// - /// - /// StreamState of the current stream. - /// Video codec. - /// Video level. - /// Formatted video codec string. - private string GetPlaylistVideoCodecs(StreamState state, string codec, int level) - { - if (level == 0) - { - // This is 0 when there's no requested H.26X level in the device profile - // and the source is not encoded in H.26X - _logger.LogError("Got invalid H.26X level when building CODECS field for HLS master playlist"); - return string.Empty; - } - - if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)) - { - string profile = state.GetRequestedProfiles("h264").FirstOrDefault(); - return HlsCodecStringHelpers.GetH264String(profile, level); - } - - if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) - { - string profile = state.GetRequestedProfiles("h265").FirstOrDefault(); - - return HlsCodecStringHelpers.GetH265String(profile, level); - } - - return string.Empty; - } - - private int GetBitrateVariation(int bitrate) - { - // By default, vary by just 50k - var variation = 50000; - - if (bitrate >= 10000000) - { - variation = 2000000; - } - else if (bitrate >= 5000000) - { - variation = 1500000; - } - else if (bitrate >= 3000000) - { - variation = 1000000; - } - else if (bitrate >= 2000000) - { - variation = 500000; - } - else if (bitrate >= 1000000) - { - variation = 300000; - } - else if (bitrate >= 600000) - { - variation = 200000; - } - else if (bitrate >= 400000) - { - variation = 100000; - } - - return variation; - } - - private string ReplaceBitrate(string url, int oldValue, int newValue) - { - return url.Replace( - "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture), - "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture), - StringComparison.OrdinalIgnoreCase); - } - private double[] GetSegmentLengths(StreamState state) { var result = new List(); @@ -2089,7 +1657,7 @@ namespace Jellyfin.Api.Controllers return Task.CompletedTask; }); - return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath)!, false, this); + return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath)!, false, HttpContext); } private long GetEndPositionTicks(StreamState state, int requestedIndex) diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index e4a6842bce..816252f80d 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -61,7 +61,7 @@ namespace Jellyfin.Api.Controllers var file = segmentId + Path.GetExtension(Request.Path); file = Path.Combine(_serverConfigurationManager.GetTranscodePath(), file); - return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file)!, false, this); + return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file)!, false, HttpContext); } /// @@ -148,7 +148,7 @@ namespace Jellyfin.Api.Controllers return Task.CompletedTask; }); - return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path)!, false, this); + return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path)!, false, HttpContext); } } } diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 97a88bc149..3ccfc826db 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -592,11 +592,11 @@ namespace Jellyfin.Api.Controllers GenreIds = RequestHelpers.GetGuids(genreIds) }; - if (!librarySeriesId.Equals(Guid.Empty)) + if (librarySeriesId != null && !librarySeriesId.Equals(Guid.Empty)) { query.IsSeries = true; - if (_libraryManager.GetItemById(librarySeriesId ?? Guid.Empty) is Series series) + if (_libraryManager.GetItemById(librarySeriesId.Value) is Series series) { query.Name = series.Name; } @@ -1004,7 +1004,7 @@ namespace Jellyfin.Api.Controllers /// Validate login. /// Created listings provider returned. /// A containing the created listings provider. - [HttpGet("ListingProviders")] + [HttpPost("ListingProviders")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] [SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")] diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index 517113074c..7faf479a5c 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -1,30 +1,18 @@ using System; using System.Buffers; using System.ComponentModel.DataAnnotations; -using System.Globalization; using System.Linq; using System.Net.Mime; -using System.Text.Json; -using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.MediaInfoDtos; using Jellyfin.Api.Models.VideoDtos; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Dlna; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -42,12 +30,9 @@ namespace Jellyfin.Api.Controllers private readonly IMediaSourceManager _mediaSourceManager; private readonly IDeviceManager _deviceManager; private readonly ILibraryManager _libraryManager; - private readonly INetworkManager _networkManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IUserManager _userManager; private readonly IAuthorizationContext _authContext; private readonly ILogger _logger; - private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly MediaInfoHelper _mediaInfoHelper; /// /// Initializes a new instance of the class. @@ -55,32 +40,23 @@ namespace Jellyfin.Api.Controllers /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. - /// Instance of the interface. + /// Instance of the . public MediaInfoController( IMediaSourceManager mediaSourceManager, IDeviceManager deviceManager, ILibraryManager libraryManager, - INetworkManager networkManager, - IMediaEncoder mediaEncoder, - IUserManager userManager, IAuthorizationContext authContext, ILogger logger, - IServerConfigurationManager serverConfigurationManager) + MediaInfoHelper mediaInfoHelper) { _mediaSourceManager = mediaSourceManager; _deviceManager = deviceManager; _libraryManager = libraryManager; - _networkManager = networkManager; - _mediaEncoder = mediaEncoder; - _userManager = userManager; _authContext = authContext; _logger = logger; - _serverConfigurationManager = serverConfigurationManager; + _mediaInfoHelper = mediaInfoHelper; } /// @@ -94,7 +70,10 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetPlaybackInfo([FromRoute] Guid itemId, [FromQuery, Required] Guid? userId) { - return await GetPlaybackInfoInternal(itemId, userId).ConfigureAwait(false); + return await _mediaInfoHelper.GetPlaybackInfo( + itemId, + userId) + .ConfigureAwait(false); } /// @@ -153,7 +132,12 @@ namespace Jellyfin.Api.Controllers } } - var info = await GetPlaybackInfoInternal(itemId, userId, mediaSourceId, liveStreamId).ConfigureAwait(false); + var info = await _mediaInfoHelper.GetPlaybackInfo( + itemId, + userId, + mediaSourceId, + liveStreamId) + .ConfigureAwait(false); if (profile != null) { @@ -162,7 +146,7 @@ namespace Jellyfin.Api.Controllers foreach (var mediaSource in info.MediaSources) { - SetDeviceSpecificData( + _mediaInfoHelper.SetDeviceSpecificData( item, mediaSource, profile, @@ -179,10 +163,11 @@ namespace Jellyfin.Api.Controllers enableDirectStream, enableTranscoding, allowVideoStreamCopy, - allowAudioStreamCopy); + allowAudioStreamCopy, + Request.HttpContext.Connection.RemoteIpAddress.ToString()); } - SortMediaSources(info, maxStreamingBitrate); + _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); } if (autoOpenLiveStream) @@ -191,21 +176,23 @@ namespace Jellyfin.Api.Controllers if (mediaSource != null && mediaSource.RequiresOpening && string.IsNullOrWhiteSpace(mediaSource.LiveStreamId)) { - var openStreamResult = await OpenMediaSource(new LiveStreamRequest - { - AudioStreamIndex = audioStreamIndex, - DeviceProfile = deviceProfile?.DeviceProfile, - EnableDirectPlay = enableDirectPlay, - EnableDirectStream = enableDirectStream, - ItemId = itemId, - MaxAudioChannels = maxAudioChannels, - MaxStreamingBitrate = maxStreamingBitrate, - PlaySessionId = info.PlaySessionId, - StartTimeTicks = startTimeTicks, - SubtitleStreamIndex = subtitleStreamIndex, - UserId = userId ?? Guid.Empty, - OpenToken = mediaSource.OpenToken - }).ConfigureAwait(false); + var openStreamResult = await _mediaInfoHelper.OpenMediaSource( + Request, + new LiveStreamRequest + { + AudioStreamIndex = audioStreamIndex, + DeviceProfile = deviceProfile?.DeviceProfile, + EnableDirectPlay = enableDirectPlay, + EnableDirectStream = enableDirectStream, + ItemId = itemId, + MaxAudioChannels = maxAudioChannels, + MaxStreamingBitrate = maxStreamingBitrate, + PlaySessionId = info.PlaySessionId, + StartTimeTicks = startTimeTicks, + SubtitleStreamIndex = subtitleStreamIndex, + UserId = userId ?? Guid.Empty, + OpenToken = mediaSource.OpenToken + }).ConfigureAwait(false); info.MediaSources = new[] { openStreamResult.MediaSource }; } @@ -215,7 +202,7 @@ namespace Jellyfin.Api.Controllers { foreach (var mediaSource in info.MediaSources) { - NormalizeMediaSourceContainer(mediaSource, profile!, DlnaProfileType.Video); + _mediaInfoHelper.NormalizeMediaSourceContainer(mediaSource, profile!, DlnaProfileType.Video); } } @@ -271,7 +258,7 @@ namespace Jellyfin.Api.Controllers EnableDirectStream = enableDirectStream, DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http } }; - return await OpenMediaSource(request).ConfigureAwait(false); + return await _mediaInfoHelper.OpenMediaSource(Request, request).ConfigureAwait(false); } /// @@ -324,454 +311,5 @@ namespace Jellyfin.Api.Controllers ArrayPool.Shared.Return(buffer); } } - - private async Task GetPlaybackInfoInternal( - Guid id, - Guid? userId, - string? mediaSourceId = null, - string? liveStreamId = null) - { - var user = userId.HasValue && !userId.Equals(Guid.Empty) - ? _userManager.GetUserById(userId.Value) - : null; - var item = _libraryManager.GetItemById(id); - var result = new PlaybackInfoResponse(); - - MediaSourceInfo[] mediaSources; - if (string.IsNullOrWhiteSpace(liveStreamId)) - { - // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? - var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(mediaSourceId)) - { - mediaSources = mediaSourcesList.ToArray(); - } - else - { - mediaSources = mediaSourcesList - .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } - } - else - { - var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); - - mediaSources = new[] { mediaSource }; - } - - if (mediaSources.Length == 0) - { - result.MediaSources = Array.Empty(); - - result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream; - } - else - { - // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it - // Should we move this directly into MediaSourceManager? - result.MediaSources = JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(mediaSources)); - - result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - } - - return result; - } - - private void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type) - { - mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, mediaSource.Path, profile, type); - } - - private void SetDeviceSpecificData( - BaseItem item, - MediaSourceInfo mediaSource, - DeviceProfile profile, - AuthorizationInfo auth, - long? maxBitrate, - long startTimeTicks, - string mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - int? maxAudioChannels, - string playSessionId, - Guid userId, - bool enableDirectPlay, - bool enableDirectStream, - bool enableTranscoding, - bool allowVideoStreamCopy, - bool allowAudioStreamCopy) - { - var streamBuilder = new StreamBuilder(_mediaEncoder, _logger); - - var options = new VideoOptions - { - MediaSources = new[] { mediaSource }, - Context = EncodingContext.Streaming, - DeviceId = auth.DeviceId, - ItemId = item.Id, - Profile = profile, - MaxAudioChannels = maxAudioChannels - }; - - if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase)) - { - options.MediaSourceId = mediaSourceId; - options.AudioStreamIndex = audioStreamIndex; - options.SubtitleStreamIndex = subtitleStreamIndex; - } - - var user = _userManager.GetUserById(userId); - - if (!enableDirectPlay) - { - mediaSource.SupportsDirectPlay = false; - } - - if (!enableDirectStream) - { - mediaSource.SupportsDirectStream = false; - } - - if (!enableTranscoding) - { - mediaSource.SupportsTranscoding = false; - } - - if (item is Audio) - { - _logger.LogInformation( - "User policy for {0}. EnableAudioPlaybackTranscoding: {1}", - user.Username, - user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); - } - else - { - _logger.LogInformation( - "User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}", - user.Username, - user.HasPermission(PermissionKind.EnablePlaybackRemuxing), - user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding), - user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); - } - - // Beginning of Playback Determination: Attempt DirectPlay first - if (mediaSource.SupportsDirectPlay) - { - if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding)) - { - mediaSource.SupportsDirectPlay = false; - } - else - { - var supportsDirectStream = mediaSource.SupportsDirectStream; - - // Dummy this up to fool StreamBuilder - mediaSource.SupportsDirectStream = true; - options.MaxBitrate = maxBitrate; - - if (item is Audio) - { - if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)) - { - options.ForceDirectPlay = true; - } - } - else if (item is Video) - { - if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding) - && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding) - && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing)) - { - options.ForceDirectPlay = true; - } - } - - // The MediaSource supports direct stream, now test to see if the client supports it - var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase) - ? streamBuilder.BuildAudioItem(options) - : streamBuilder.BuildVideoItem(options); - - if (streamInfo == null || !streamInfo.IsDirectStream) - { - mediaSource.SupportsDirectPlay = false; - } - - // Set this back to what it was - mediaSource.SupportsDirectStream = supportsDirectStream; - - if (streamInfo != null) - { - SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token); - } - } - } - - if (mediaSource.SupportsDirectStream) - { - if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding)) - { - mediaSource.SupportsDirectStream = false; - } - else - { - options.MaxBitrate = GetMaxBitrate(maxBitrate, user); - - if (item is Audio) - { - if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)) - { - options.ForceDirectStream = true; - } - } - else if (item is Video) - { - if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding) - && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding) - && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing)) - { - options.ForceDirectStream = true; - } - } - - // The MediaSource supports direct stream, now test to see if the client supports it - var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase) - ? streamBuilder.BuildAudioItem(options) - : streamBuilder.BuildVideoItem(options); - - if (streamInfo == null || !streamInfo.IsDirectStream) - { - mediaSource.SupportsDirectStream = false; - } - - if (streamInfo != null) - { - SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token); - } - } - } - - if (mediaSource.SupportsTranscoding) - { - options.MaxBitrate = GetMaxBitrate(maxBitrate, user); - - // The MediaSource supports direct stream, now test to see if the client supports it - var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase) - ? streamBuilder.BuildAudioItem(options) - : streamBuilder.BuildVideoItem(options); - - if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding)) - { - if (streamInfo != null) - { - streamInfo.PlaySessionId = playSessionId; - streamInfo.StartPositionTicks = startTimeTicks; - mediaSource.TranscodingUrl = streamInfo.ToUrl("-", auth.Token).TrimStart('-'); - mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; - mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; - mediaSource.TranscodingContainer = streamInfo.Container; - mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; - - // Do this after the above so that StartPositionTicks is set - SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token); - } - } - else - { - if (streamInfo != null) - { - streamInfo.PlaySessionId = playSessionId; - - if (streamInfo.PlayMethod == PlayMethod.Transcode) - { - streamInfo.StartPositionTicks = startTimeTicks; - mediaSource.TranscodingUrl = streamInfo.ToUrl("-", auth.Token).TrimStart('-'); - - if (!allowVideoStreamCopy) - { - mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; - } - - if (!allowAudioStreamCopy) - { - mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; - } - - mediaSource.TranscodingContainer = streamInfo.Container; - mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; - } - - if (!allowAudioStreamCopy) - { - mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; - } - - mediaSource.TranscodingContainer = streamInfo.Container; - mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; - - // Do this after the above so that StartPositionTicks is set - SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token); - } - } - } - - foreach (var attachment in mediaSource.MediaAttachments) - { - attachment.DeliveryUrl = string.Format( - CultureInfo.InvariantCulture, - "/Videos/{0}/{1}/Attachments/{2}", - item.Id, - mediaSource.Id, - attachment.Index); - } - } - - private async Task OpenMediaSource(LiveStreamRequest request) - { - var authInfo = _authContext.GetAuthorizationInfo(Request); - - var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); - - var profile = request.DeviceProfile; - if (profile == null) - { - var caps = _deviceManager.GetCapabilities(authInfo.DeviceId); - if (caps != null) - { - profile = caps.DeviceProfile; - } - } - - if (profile != null) - { - var item = _libraryManager.GetItemById(request.ItemId); - - SetDeviceSpecificData( - item, - result.MediaSource, - profile, - authInfo, - request.MaxStreamingBitrate, - request.StartTimeTicks ?? 0, - result.MediaSource.Id, - request.AudioStreamIndex, - request.SubtitleStreamIndex, - request.MaxAudioChannels, - request.PlaySessionId, - request.UserId, - request.EnableDirectPlay, - request.EnableDirectStream, - true, - true, - true); - } - else - { - if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl)) - { - result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId; - } - } - - // here was a check if (result.MediaSource != null) but Rider said it will never be null - NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video); - - return result; - } - - private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken) - { - var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken); - mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex; - - mediaSource.TranscodeReasons = info.TranscodeReasons; - - foreach (var profile in profiles) - { - foreach (var stream in mediaSource.MediaStreams) - { - if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index) - { - stream.DeliveryMethod = profile.DeliveryMethod; - - if (profile.DeliveryMethod == SubtitleDeliveryMethod.External) - { - stream.DeliveryUrl = profile.Url.TrimStart('-'); - stream.IsExternalUrl = profile.IsExternalUrl; - } - } - } - } - } - - private long? GetMaxBitrate(long? clientMaxBitrate, User user) - { - var maxBitrate = clientMaxBitrate; - var remoteClientMaxBitrate = user?.RemoteClientBitrateLimit ?? 0; - - if (remoteClientMaxBitrate <= 0) - { - remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit; - } - - if (remoteClientMaxBitrate > 0) - { - var isInLocalNetwork = _networkManager.IsInLocalNetwork(Request.HttpContext.Connection.RemoteIpAddress.ToString()); - - _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, Request.HttpContext.Connection.RemoteIpAddress.ToString(), isInLocalNetwork); - if (!isInLocalNetwork) - { - maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate); - } - } - - return maxBitrate; - } - - private void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate) - { - var originalList = result.MediaSources.ToList(); - - result.MediaSources = result.MediaSources.OrderBy(i => - { - // Nothing beats direct playing a file - if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) - { - return 0; - } - - return 1; - }) - .ThenBy(i => - { - // Let's assume direct streaming a file is just as desirable as direct playing a remote url - if (i.SupportsDirectPlay || i.SupportsDirectStream) - { - return 0; - } - - return 1; - }) - .ThenBy(i => - { - return i.Protocol switch - { - MediaProtocol.File => 0, - _ => 1, - }; - }) - .ThenBy(i => - { - if (maxBitrate.HasValue && i.Bitrate.HasValue) - { - return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2; - } - - return 1; - }) - .ThenBy(originalList.IndexOf) - .ToArray(); - } } } diff --git a/Jellyfin.Api/Controllers/NotificationsController.cs b/Jellyfin.Api/Controllers/NotificationsController.cs index cf3e780b44..0ceda6815c 100644 --- a/Jellyfin.Api/Controllers/NotificationsController.cs +++ b/Jellyfin.Api/Controllers/NotificationsController.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading; using Jellyfin.Api.Constants; diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index 0422bfe727..22f2ca5c32 100644 --- a/Jellyfin.Api/Controllers/PlaystateController.cs +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -94,7 +94,7 @@ namespace Jellyfin.Api.Controllers /// Item id. /// Item marked as unplayed. /// A containing the . - [HttpDelete("Users/{userId}/PlayedItem/{itemId}")] + [HttpDelete("Users/{userId}/PlayedItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult MarkUnplayedItem([FromRoute] Guid userId, [FromRoute] Guid itemId) { diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 48b57bdb7f..4795fb0cf7 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -413,7 +413,7 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult PostFullCapabilities( - [FromQuery, Required] string? id, + [FromQuery] string? id, [FromBody, Required] ClientCapabilities capabilities) { if (string.IsNullOrWhiteSpace(id)) @@ -480,7 +480,7 @@ namespace Jellyfin.Api.Controllers /// /// Password reset providers retrieved. /// An with the password reset providers. - [HttpGet("Auto/PasswordResetProviders")] + [HttpGet("Auth/PasswordResetProviders")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = Policies.RequiresElevation)] public ActionResult> GetPasswordResetProviders() diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index 75df16aa7b..3994fb9958 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -2,17 +2,20 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Net.Http; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; -using Jellyfin.Api.Models.VideoDtos; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.MediaInfo; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Controllers { @@ -23,27 +26,39 @@ namespace Jellyfin.Api.Controllers public class UniversalAudioController : BaseJellyfinApiController { private readonly IAuthorizationContext _authorizationContext; - private readonly MediaInfoController _mediaInfoController; - private readonly DynamicHlsController _dynamicHlsController; - private readonly AudioController _audioController; + private readonly IDeviceManager _deviceManager; + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + private readonly MediaInfoHelper _mediaInfoHelper; + private readonly AudioHelper _audioHelper; + private readonly DynamicHlsHelper _dynamicHlsHelper; /// /// Initializes a new instance of the class. /// /// Instance of the interface. - /// Instance of the . - /// Instance of the . - /// Instance of the . + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of . + /// Instance of . + /// Instance of . public UniversalAudioController( IAuthorizationContext authorizationContext, - MediaInfoController mediaInfoController, - DynamicHlsController dynamicHlsController, - AudioController audioController) + IDeviceManager deviceManager, + ILibraryManager libraryManager, + ILogger logger, + MediaInfoHelper mediaInfoHelper, + AudioHelper audioHelper, + DynamicHlsHelper dynamicHlsHelper) { _authorizationContext = authorizationContext; - _mediaInfoController = mediaInfoController; - _dynamicHlsController = dynamicHlsController; - _audioController = audioController; + _deviceManager = deviceManager; + _libraryManager = libraryManager; + _logger = logger; + _mediaInfoHelper = mediaInfoHelper; + _audioHelper = audioHelper; + _dynamicHlsHelper = dynamicHlsHelper; } /// @@ -95,24 +110,68 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool breakOnNonKeyFrames, [FromQuery] bool enableRedirection = true) { - bool isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); _authorizationContext.GetAuthorizationInfo(Request).DeviceId = deviceId; - var playbackInfoResult = await _mediaInfoController.GetPostedPlaybackInfo( - itemId, - userId, - maxStreamingBitrate, - startTimeTicks, - null, - null, - maxAudioChannels, - mediaSourceId, - null, - new DeviceProfileDto { DeviceProfile = deviceProfile }) + var authInfo = _authorizationContext.GetAuthorizationInfo(Request); + + _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile); + + if (deviceProfile == null) + { + var clientCapabilities = _deviceManager.GetCapabilities(authInfo.DeviceId); + if (clientCapabilities != null) + { + deviceProfile = clientCapabilities.DeviceProfile; + } + } + + var info = await _mediaInfoHelper.GetPlaybackInfo( + itemId, + userId, + mediaSourceId) .ConfigureAwait(false); - var mediaSource = playbackInfoResult.Value.MediaSources[0]; + if (deviceProfile != null) + { + // set device specific data + var item = _libraryManager.GetItemById(itemId); + + foreach (var sourceInfo in info.MediaSources) + { + _mediaInfoHelper.SetDeviceSpecificData( + item, + sourceInfo, + deviceProfile, + authInfo, + maxStreamingBitrate ?? deviceProfile.MaxStreamingBitrate, + startTimeTicks ?? 0, + mediaSourceId ?? string.Empty, + null, + null, + maxAudioChannels, + info!.PlaySessionId!, + userId ?? Guid.Empty, + true, + true, + true, + true, + true, + Request.HttpContext.Connection.RemoteIpAddress.ToString()); + } + + _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); + } + + if (info.MediaSources != null) + { + foreach (var source in info.MediaSources) + { + _mediaInfoHelper.NormalizeMediaSourceContainer(source, deviceProfile!, DlnaProfileType.Video); + } + } + + var mediaSource = info.MediaSources![0]; if (mediaSource.SupportsDirectPlay && mediaSource.Protocol == MediaProtocol.Http) { if (enableRedirection) @@ -127,129 +186,71 @@ namespace Jellyfin.Api.Controllers var isStatic = mediaSource.SupportsDirectStream; if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) { - var transcodingProfile = deviceProfile.TranscodingProfiles[0]; - // hls segment container can only be mpegts or fmp4 per ffmpeg documentation // TODO: remove this when we switch back to the segment muxer var supportedHlsContainers = new[] { "mpegts", "fmp4" }; - if (isHeadRequest) + var dynamicHlsRequestDto = new HlsAudioRequestDto { - _dynamicHlsController.Request.Method = HttpMethod.Head.Method; - } - - return await _dynamicHlsController.GetMasterHlsAudioPlaylist( - itemId, - ".m3u8", - isStatic, - null, - null, - null, - playbackInfoResult.Value.PlaySessionId, + Id = itemId, + Container = ".m3u8", + Static = isStatic, + PlaySessionId = info.PlaySessionId, // fallback to mpegts if device reports some weird value unsupported by hls - Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "mpegts", - null, - null, - mediaSource.Id, - deviceId, - transcodingProfile.AudioCodec, - null, - null, - null, - transcodingProfile.BreakOnNonKeyFrames, - maxAudioSampleRate, - maxAudioBitDepth, - null, - isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)), - maxAudioChannels, - null, - null, - null, - null, - null, - startTimeTicks, - null, - null, - null, - null, - SubtitleDeliveryMethod.Hls, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()), - null, - null, - EncodingContext.Static, - new Dictionary()) + SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "mpegts", + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = true, + AllowAudioStreamCopy = true, + AllowVideoStreamCopy = true, + BreakOnNonKeyFrames = breakOnNonKeyFrames, + AudioSampleRate = maxAudioSampleRate, + MaxAudioChannels = maxAudioChannels, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)), + StartTimeTicks = startTimeTicks, + SubtitleMethod = SubtitleDeliveryMethod.Hls, + RequireAvc = true, + DeInterlace = true, + RequireNonAnamorphic = true, + EnableMpegtsM2TsMode = true, + TranscodeReasons = mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()), + Context = EncodingContext.Static, + StreamOptions = new Dictionary(), + EnableAdaptiveBitrateStreaming = true + }; + + return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType.Hls, dynamicHlsRequestDto, true) .ConfigureAwait(false); } - else + + var audioStreamingDto = new StreamingRequestDto { - if (isHeadRequest) - { - _audioController.Request.Method = HttpMethod.Head.Method; - } + Id = itemId, + Container = isStatic ? null : ("." + mediaSource.TranscodingContainer), + Static = isStatic, + PlaySessionId = info.PlaySessionId, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = true, + AllowAudioStreamCopy = true, + AllowVideoStreamCopy = true, + BreakOnNonKeyFrames = breakOnNonKeyFrames, + AudioSampleRate = maxAudioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)), + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = maxAudioChannels, + CopyTimestamps = true, + StartTimeTicks = startTimeTicks, + SubtitleMethod = SubtitleDeliveryMethod.Embed, + TranscodeReasons = mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()), + Context = EncodingContext.Static + }; - return await _audioController.GetAudioStream( - itemId, - isStatic ? null : ("." + mediaSource.TranscodingContainer), - isStatic, - null, - null, - null, - playbackInfoResult.Value.PlaySessionId, - null, - null, - null, - mediaSource.Id, - deviceId, - audioCodec, - null, - null, - null, - breakOnNonKeyFrames, - maxAudioSampleRate, - maxAudioBitDepth, - isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)), - null, - maxAudioChannels, - null, - null, - null, - null, - null, - startTimeTicks, - null, - null, - null, - null, - SubtitleDeliveryMethod.Embed, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()), - null, - null, - null, - null) - .ConfigureAwait(false); - } + return await _audioHelper.GetAudioStream(TranscodingJobType.Progressive, audioStreamingDto).ConfigureAwait(false); } private DeviceProfile GetDeviceProfile( diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index ae32dbf035..14d3f24607 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -471,7 +471,7 @@ namespace Jellyfin.Api.Controllers StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager); var httpClient = _httpClientFactory.CreateClient(); - return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, isHeadRequest, this, httpClient).ConfigureAwait(false); + return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, isHeadRequest, httpClient, HttpContext).ConfigureAwait(false); } if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File) @@ -507,7 +507,7 @@ namespace Jellyfin.Api.Controllers state.MediaPath, contentType, isHeadRequest, - this); + HttpContext); } // Need to start ffmpeg (because media can't be returned directly) @@ -517,10 +517,9 @@ namespace Jellyfin.Api.Controllers return await FileStreamResponseHelpers.GetTranscodedFile( state, isHeadRequest, - this, + HttpContext, _transcodingJobHelper, ffmpegCommandLineArguments, - Request, _transcodingJobType, cancellationTokenSource).ConfigureAwait(false); } diff --git a/Jellyfin.Api/Helpers/AudioHelper.cs b/Jellyfin.Api/Helpers/AudioHelper.cs new file mode 100644 index 0000000000..eae2be05ba --- /dev/null +++ b/Jellyfin.Api/Helpers/AudioHelper.cs @@ -0,0 +1,195 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; + +namespace Jellyfin.Api.Helpers +{ + /// + /// Audio helper. + /// + public class AudioHelper + { + private readonly IDlnaManager _dlnaManager; + private readonly IAuthorizationContext _authContext; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IFileSystem _fileSystem; + private readonly ISubtitleEncoder _subtitleEncoder; + private readonly IConfiguration _configuration; + private readonly IDeviceManager _deviceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IHttpContextAccessor _httpContextAccessor; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of . + /// Instance of the interface. + /// Instance of the interface. + public AudioHelper( + IDlnaManager dlnaManager, + IAuthorizationContext authContext, + IUserManager userManager, + ILibraryManager libraryManager, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IFileSystem fileSystem, + ISubtitleEncoder subtitleEncoder, + IConfiguration configuration, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + IHttpClientFactory httpClientFactory, + IHttpContextAccessor httpContextAccessor) + { + _dlnaManager = dlnaManager; + _authContext = authContext; + _userManager = userManager; + _libraryManager = libraryManager; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _fileSystem = fileSystem; + _subtitleEncoder = subtitleEncoder; + _configuration = configuration; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; + _httpClientFactory = httpClientFactory; + _httpContextAccessor = httpContextAccessor; + } + + /// + /// Get audio stream. + /// + /// Transcoding job type. + /// Streaming controller.Request dto. + /// A containing the resulting . + public async Task GetAudioStream( + TranscodingJobType transcodingJobType, + StreamingRequestDto streamingRequest) + { + bool isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == System.Net.WebRequestMethods.Http.Head; + var cancellationTokenSource = new CancellationTokenSource(); + + using var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + _httpContextAccessor.HttpContext.Request, + _authContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _fileSystem, + _subtitleEncoder, + _configuration, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + transcodingJobType, + cancellationTokenSource.Token) + .ConfigureAwait(false); + + if (streamingRequest.Static && state.DirectStreamProvider != null) + { + StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); + + await new ProgressiveFileCopier(state.DirectStreamProvider, null, _transcodingJobHelper, CancellationToken.None) + { + AllowEndOfFile = false + }.WriteToAsync(_httpContextAccessor.HttpContext.Response.Body, CancellationToken.None) + .ConfigureAwait(false); + + // TODO (moved from MediaBrowser.Api): Don't hardcode contentType + return new FileStreamResult(_httpContextAccessor.HttpContext.Response.Body, MimeTypes.GetMimeType("file.ts")!); + } + + // Static remote stream + if (streamingRequest.Static && state.InputProtocol == MediaProtocol.Http) + { + StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); + + var httpClient = _httpClientFactory.CreateClient(); + return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, isHeadRequest, httpClient, _httpContextAccessor.HttpContext).ConfigureAwait(false); + } + + if (streamingRequest.Static && state.InputProtocol != MediaProtocol.File) + { + return new BadRequestObjectResult($"Input protocol {state.InputProtocol} cannot be streamed statically"); + } + + var outputPath = state.OutputFilePath; + var outputPathExists = System.IO.File.Exists(outputPath); + + var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); + var isTranscodeCached = outputPathExists && transcodingJob != null; + + StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, streamingRequest.Static || isTranscodeCached, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); + + // Static stream + if (streamingRequest.Static) + { + var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); + + if (state.MediaSource.IsInfiniteStream) + { + await new ProgressiveFileCopier(state.MediaPath, null, _transcodingJobHelper, CancellationToken.None) + { + AllowEndOfFile = false + }.WriteToAsync(_httpContextAccessor.HttpContext.Response.Body, CancellationToken.None) + .ConfigureAwait(false); + + return new FileStreamResult(_httpContextAccessor.HttpContext.Response.Body, contentType); + } + + return FileStreamResponseHelpers.GetStaticFileResult( + state.MediaPath, + contentType, + isHeadRequest, + _httpContextAccessor.HttpContext); + } + + // Need to start ffmpeg (because media can't be returned directly) + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration); + var ffmpegCommandLineArguments = encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath); + return await FileStreamResponseHelpers.GetTranscodedFile( + state, + isHeadRequest, + _httpContextAccessor.HttpContext, + _transcodingJobHelper, + ffmpegCommandLineArguments, + transcodingJobType, + cancellationTokenSource).ConfigureAwait(false); + } + } +} diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs new file mode 100644 index 0000000000..6a8829d462 --- /dev/null +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -0,0 +1,550 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Security.Claims; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; + +namespace Jellyfin.Api.Helpers +{ + /// + /// Dynamic hls helper. + /// + public class DynamicHlsHelper + { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDlnaManager _dlnaManager; + private readonly IAuthorizationContext _authContext; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IFileSystem _fileSystem; + private readonly ISubtitleEncoder _subtitleEncoder; + private readonly IConfiguration _configuration; + private readonly IDeviceManager _deviceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly INetworkManager _networkManager; + private readonly ILogger _logger; + private readonly IHttpContextAccessor _httpContextAccessor; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of . + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public DynamicHlsHelper( + ILibraryManager libraryManager, + IUserManager userManager, + IDlnaManager dlnaManager, + IAuthorizationContext authContext, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IFileSystem fileSystem, + ISubtitleEncoder subtitleEncoder, + IConfiguration configuration, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + INetworkManager networkManager, + ILogger logger, + IHttpContextAccessor httpContextAccessor) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dlnaManager = dlnaManager; + _authContext = authContext; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _fileSystem = fileSystem; + _subtitleEncoder = subtitleEncoder; + _configuration = configuration; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; + _networkManager = networkManager; + _logger = logger; + _httpContextAccessor = httpContextAccessor; + } + + /// + /// Get master hls playlist. + /// + /// Transcoding job type. + /// Streaming request dto. + /// Enable adaptive bitrate streaming. + /// A containing the resulting . + public async Task GetMasterHlsPlaylist( + TranscodingJobType transcodingJobType, + StreamingRequestDto streamingRequest, + bool enableAdaptiveBitrateStreaming) + { + var isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == WebRequestMethods.Http.Head; + var cancellationTokenSource = new CancellationTokenSource(); + return await GetMasterPlaylistInternal( + streamingRequest, + isHeadRequest, + enableAdaptiveBitrateStreaming, + transcodingJobType, + cancellationTokenSource).ConfigureAwait(false); + } + + private async Task GetMasterPlaylistInternal( + StreamingRequestDto streamingRequest, + bool isHeadRequest, + bool enableAdaptiveBitrateStreaming, + TranscodingJobType transcodingJobType, + CancellationTokenSource cancellationTokenSource) + { + using var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + _httpContextAccessor.HttpContext.Request, + _authContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _fileSystem, + _subtitleEncoder, + _configuration, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + transcodingJobType, + cancellationTokenSource.Token) + .ConfigureAwait(false); + + _httpContextAccessor.HttpContext.Response.Headers.Add(HeaderNames.Expires, "0"); + if (isHeadRequest) + { + return new FileContentResult(Array.Empty(), MimeTypes.GetMimeType("playlist.m3u8")); + } + + var totalBitrate = state.OutputAudioBitrate ?? 0 + state.OutputVideoBitrate ?? 0; + + var builder = new StringBuilder(); + + builder.AppendLine("#EXTM3U"); + + var isLiveStream = state.IsSegmentedLiveStream; + + var queryString = _httpContextAccessor.HttpContext.Request.QueryString.ToString(); + + // from universal audio service + if (queryString.IndexOf("SegmentContainer", StringComparison.OrdinalIgnoreCase) == -1 && !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)) + { + queryString += "&SegmentContainer=" + state.Request.SegmentContainer; + } + + // from universal audio service + if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons) && queryString.IndexOf("TranscodeReasons=", StringComparison.OrdinalIgnoreCase) == -1) + { + queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons; + } + + // Main stream + var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8"; + + playlistUrl += queryString; + + var subtitleStreams = state.MediaSource + .MediaStreams + .Where(i => i.IsTextSubtitleStream) + .ToList(); + + var subtitleGroup = subtitleStreams.Count > 0 && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Hls || state.VideoRequest!.EnableSubtitlesInManifest) + ? "subs" + : null; + + // If we're burning in subtitles then don't add additional subs to the manifest + if (state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) + { + subtitleGroup = null; + } + + if (!string.IsNullOrWhiteSpace(subtitleGroup)) + { + AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.Request.HttpContext.User); + } + + AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); + + if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.Request.HttpContext.Connection.RemoteIpAddress)) + { + var requestedVideoBitrate = state.VideoRequest == null ? 0 : state.VideoRequest.VideoBitRate ?? 0; + + // By default, vary by just 200k + var variation = GetBitrateVariation(totalBitrate); + + var newBitrate = totalBitrate - variation; + var variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); + + variation *= 2; + newBitrate = totalBitrate - variation; + variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); + } + + return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); + } + + private void AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) + { + builder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") + .Append(bitrate.ToString(CultureInfo.InvariantCulture)) + .Append(",AVERAGE-BANDWIDTH=") + .Append(bitrate.ToString(CultureInfo.InvariantCulture)); + + AppendPlaylistCodecsField(builder, state); + + AppendPlaylistResolutionField(builder, state); + + AppendPlaylistFramerateField(builder, state); + + if (!string.IsNullOrWhiteSpace(subtitleGroup)) + { + builder.Append(",SUBTITLES=\"") + .Append(subtitleGroup) + .Append('"'); + } + + builder.Append(Environment.NewLine); + builder.AppendLine(url); + } + + /// + /// Appends a CODECS field containing formatted strings of + /// the active streams output video and audio codecs. + /// + /// + /// + /// + /// StringBuilder to append the field to. + /// StreamState of the current stream. + private void AppendPlaylistCodecsField(StringBuilder builder, StreamState state) + { + // Video + string videoCodecs = string.Empty; + int? videoCodecLevel = GetOutputVideoCodecLevel(state); + if (!string.IsNullOrEmpty(state.ActualOutputVideoCodec) && videoCodecLevel.HasValue) + { + videoCodecs = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value); + } + + // Audio + string audioCodecs = string.Empty; + if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec)) + { + audioCodecs = GetPlaylistAudioCodecs(state); + } + + StringBuilder codecs = new StringBuilder(); + + codecs.Append(videoCodecs); + + if (!string.IsNullOrEmpty(videoCodecs) && !string.IsNullOrEmpty(audioCodecs)) + { + codecs.Append(','); + } + + codecs.Append(audioCodecs); + + if (codecs.Length > 1) + { + builder.Append(",CODECS=\"") + .Append(codecs) + .Append('"'); + } + } + + /// + /// Appends a RESOLUTION field containing the resolution of the output stream. + /// + /// + /// StringBuilder to append the field to. + /// StreamState of the current stream. + private void AppendPlaylistResolutionField(StringBuilder builder, StreamState state) + { + if (state.OutputWidth.HasValue && state.OutputHeight.HasValue) + { + builder.Append(",RESOLUTION=") + .Append(state.OutputWidth.GetValueOrDefault()) + .Append('x') + .Append(state.OutputHeight.GetValueOrDefault()); + } + } + + /// + /// Appends a FRAME-RATE field containing the framerate of the output stream. + /// + /// + /// StringBuilder to append the field to. + /// StreamState of the current stream. + private void AppendPlaylistFramerateField(StringBuilder builder, StreamState state) + { + double? framerate = null; + if (state.TargetFramerate.HasValue) + { + framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3); + } + else if (state.VideoStream?.RealFrameRate != null) + { + framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3); + } + + if (framerate.HasValue) + { + builder.Append(",FRAME-RATE=") + .Append(framerate.Value); + } + } + + private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming, IPAddress ipAddress) + { + // Within the local network this will likely do more harm than good. + var ip = RequestHelpers.NormalizeIp(ipAddress).ToString(); + if (_networkManager.IsInLocalNetwork(ip)) + { + return false; + } + + if (!enableAdaptiveBitrateStreaming) + { + return false; + } + + if (isLiveStream || string.IsNullOrWhiteSpace(state.MediaPath)) + { + // Opening live streams is so slow it's not even worth it + return false; + } + + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + return false; + } + + if (EncodingHelper.IsCopyCodec(state.OutputAudioCodec)) + { + return false; + } + + if (!state.IsOutputVideo) + { + return false; + } + + // Having problems in android + return false; + // return state.VideoRequest.VideoBitRate.HasValue; + } + + private void AddSubtitles(StreamState state, IEnumerable subtitles, StringBuilder builder, ClaimsPrincipal user) + { + var selectedIndex = state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index; + const string Format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\""; + + foreach (var stream in subtitles) + { + var name = stream.DisplayTitle; + + var isDefault = selectedIndex.HasValue && selectedIndex.Value == stream.Index; + var isForced = stream.IsForced; + + var url = string.Format( + CultureInfo.InvariantCulture, + "{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&api_key={3}", + state.Request.MediaSourceId, + stream.Index.ToString(CultureInfo.InvariantCulture), + 30.ToString(CultureInfo.InvariantCulture), + ClaimHelpers.GetToken(user)); + + var line = string.Format( + CultureInfo.InvariantCulture, + Format, + name, + isDefault ? "YES" : "NO", + isForced ? "YES" : "NO", + url, + stream.Language ?? "Unknown"); + + builder.AppendLine(line); + } + } + + /// + /// Get the H.26X level of the output video stream. + /// + /// StreamState of the current stream. + /// H.26X level of the output video stream. + private int? GetOutputVideoCodecLevel(StreamState state) + { + string? levelString; + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream.Level.HasValue) + { + levelString = state.VideoStream?.Level.ToString(); + } + else + { + levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec); + } + + if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel)) + { + return parsedLevel; + } + + return null; + } + + /// + /// Gets a formatted string of the output audio codec, for use in the CODECS field. + /// + /// + /// + /// StreamState of the current stream. + /// Formatted audio codec string. + private string GetPlaylistAudioCodecs(StreamState state) + { + if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase)) + { + string? profile = state.GetRequestedProfiles("aac").FirstOrDefault(); + return HlsCodecStringHelpers.GetAACString(profile); + } + + if (string.Equals(state.ActualOutputAudioCodec, "mp3", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetMP3String(); + } + + if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetAC3String(); + } + + if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetEAC3String(); + } + + return string.Empty; + } + + /// + /// Gets a formatted string of the output video codec, for use in the CODECS field. + /// + /// + /// + /// StreamState of the current stream. + /// Video codec. + /// Video level. + /// Formatted video codec string. + private string GetPlaylistVideoCodecs(StreamState state, string codec, int level) + { + if (level == 0) + { + // This is 0 when there's no requested H.26X level in the device profile + // and the source is not encoded in H.26X + _logger.LogError("Got invalid H.26X level when building CODECS field for HLS master playlist"); + return string.Empty; + } + + if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)) + { + string profile = state.GetRequestedProfiles("h264").FirstOrDefault(); + return HlsCodecStringHelpers.GetH264String(profile, level); + } + + if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + string profile = state.GetRequestedProfiles("h265").FirstOrDefault(); + + return HlsCodecStringHelpers.GetH265String(profile, level); + } + + return string.Empty; + } + + private int GetBitrateVariation(int bitrate) + { + // By default, vary by just 50k + var variation = 50000; + + if (bitrate >= 10000000) + { + variation = 2000000; + } + else if (bitrate >= 5000000) + { + variation = 1500000; + } + else if (bitrate >= 3000000) + { + variation = 1000000; + } + else if (bitrate >= 2000000) + { + variation = 500000; + } + else if (bitrate >= 1000000) + { + variation = 300000; + } + else if (bitrate >= 600000) + { + variation = 200000; + } + else if (bitrate >= 400000) + { + variation = 100000; + } + + return variation; + } + + private string ReplaceBitrate(string url, int oldValue, int newValue) + { + return url.Replace( + "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture), + "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture), + StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 5a59313c94..deb54dbe61 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -22,14 +22,14 @@ namespace Jellyfin.Api.Helpers /// /// The current . /// Whether the current request is a HTTP HEAD request so only the headers get returned. - /// The managing the response. /// The making the remote request. + /// The current http context. /// A containing the API response. public static async Task GetStaticRemoteStreamResult( StreamState state, bool isHeadRequest, - ControllerBase controller, - HttpClient httpClient) + HttpClient httpClient, + HttpContext httpContext) { if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent)) { @@ -40,14 +40,14 @@ namespace Jellyfin.Api.Helpers var response = await httpClient.GetAsync(state.MediaPath).ConfigureAwait(false); var contentType = response.Content.Headers.ContentType.ToString(); - controller.Response.Headers[HeaderNames.AcceptRanges] = "none"; + httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none"; if (isHeadRequest) { - return controller.File(Array.Empty(), contentType); + return new FileContentResult(Array.Empty(), contentType); } - return controller.File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), contentType); + return new FileStreamResult(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), contentType); } /// @@ -56,23 +56,23 @@ namespace Jellyfin.Api.Helpers /// The path to the file. /// The content type of the file. /// Whether the current request is a HTTP HEAD request so only the headers get returned. - /// The managing the response. + /// The current http context. /// An the file. public static ActionResult GetStaticFileResult( string path, string contentType, bool isHeadRequest, - ControllerBase controller) + HttpContext httpContext) { - controller.Response.ContentType = contentType; + httpContext.Response.ContentType = contentType; // if the request is a head request, return a NoContent result with the same headers as it would with a GET request if (isHeadRequest) { - return controller.NoContent(); + return new NoContentResult(); } - return controller.PhysicalFile(path, contentType); + return new PhysicalFileResult(path, contentType); } /// @@ -80,34 +80,32 @@ namespace Jellyfin.Api.Helpers /// /// The current . /// Whether the current request is a HTTP HEAD request so only the headers get returned. - /// The managing the response. + /// The current http context. /// The singleton. /// The command line arguments to start ffmpeg. - /// The starting the transcoding. /// The . /// The . /// A containing the transcoded file. public static async Task GetTranscodedFile( StreamState state, bool isHeadRequest, - ControllerBase controller, + HttpContext httpContext, TranscodingJobHelper transcodingJobHelper, string ffmpegCommandLineArguments, - HttpRequest request, TranscodingJobType transcodingJobType, CancellationTokenSource cancellationTokenSource) { // Use the command line args with a dummy playlist path var outputPath = state.OutputFilePath; - controller.Response.Headers[HeaderNames.AcceptRanges] = "none"; + httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none"; var contentType = state.GetMimeType(outputPath); // Headers only if (isHeadRequest) { - return controller.File(Array.Empty(), contentType); + return new FileContentResult(Array.Empty(), contentType); } var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath); @@ -117,7 +115,7 @@ namespace Jellyfin.Api.Helpers TranscodingJobDto? job; if (!File.Exists(outputPath)) { - job = await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false); + job = await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, httpContext.Request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false); } else { @@ -128,7 +126,7 @@ namespace Jellyfin.Api.Helpers var memoryStream = new MemoryStream(); await new ProgressiveFileCopier(outputPath, job, transcodingJobHelper, CancellationToken.None).WriteToAsync(memoryStream, CancellationToken.None).ConfigureAwait(false); memoryStream.Position = 0; - return controller.File(memoryStream, contentType); + return new FileStreamResult(memoryStream, contentType); } finally { diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs new file mode 100644 index 0000000000..d2d1855a4c --- /dev/null +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -0,0 +1,573 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Session; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Helpers +{ + /// + /// Media info helper. + /// + public class MediaInfoHelper + { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ILogger _logger; + private readonly INetworkManager _networkManager; + private readonly IDeviceManager _deviceManager; + private readonly IAuthorizationContext _authContext; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public MediaInfoHelper( + IUserManager userManager, + ILibraryManager libraryManager, + IMediaSourceManager mediaSourceManager, + IMediaEncoder mediaEncoder, + IServerConfigurationManager serverConfigurationManager, + ILogger logger, + INetworkManager networkManager, + IDeviceManager deviceManager, + IAuthorizationContext authContext) + { + _userManager = userManager; + _libraryManager = libraryManager; + _mediaSourceManager = mediaSourceManager; + _mediaEncoder = mediaEncoder; + _serverConfigurationManager = serverConfigurationManager; + _logger = logger; + _networkManager = networkManager; + _deviceManager = deviceManager; + _authContext = authContext; + } + + /// + /// Get playback info. + /// + /// Item id. + /// User Id. + /// Media source id. + /// Live stream id. + /// A containing the . + public async Task GetPlaybackInfo( + Guid id, + Guid? userId, + string? mediaSourceId = null, + string? liveStreamId = null) + { + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + var item = _libraryManager.GetItemById(id); + var result = new PlaybackInfoResponse(); + + MediaSourceInfo[] mediaSources; + if (string.IsNullOrWhiteSpace(liveStreamId)) + { + // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? + var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(mediaSourceId)) + { + mediaSources = mediaSourcesList.ToArray(); + } + else + { + mediaSources = mediaSourcesList + .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + } + else + { + var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); + + mediaSources = new[] { mediaSource }; + } + + if (mediaSources.Length == 0) + { + result.MediaSources = Array.Empty(); + + result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream; + } + else + { + // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it + // Should we move this directly into MediaSourceManager? + result.MediaSources = JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(mediaSources)); + + result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + } + + return result; + } + + /// + /// SetDeviceSpecificData. + /// + /// Item to set data for. + /// Media source info. + /// Device profile. + /// Authorization info. + /// Max bitrate. + /// Start time ticks. + /// Media source id. + /// Audio stream index. + /// Subtitle stream index. + /// Max audio channels. + /// Play session id. + /// User id. + /// Enable direct play. + /// Enable direct stream. + /// Enable transcoding. + /// Allow video stream copy. + /// Allow audio stream copy. + /// Requesting IP address. + public void SetDeviceSpecificData( + BaseItem item, + MediaSourceInfo mediaSource, + DeviceProfile profile, + AuthorizationInfo auth, + long? maxBitrate, + long startTimeTicks, + string mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + int? maxAudioChannels, + string playSessionId, + Guid userId, + bool enableDirectPlay, + bool enableDirectStream, + bool enableTranscoding, + bool allowVideoStreamCopy, + bool allowAudioStreamCopy, + string ipAddress) + { + var streamBuilder = new StreamBuilder(_mediaEncoder, _logger); + + var options = new VideoOptions + { + MediaSources = new[] { mediaSource }, + Context = EncodingContext.Streaming, + DeviceId = auth.DeviceId, + ItemId = item.Id, + Profile = profile, + MaxAudioChannels = maxAudioChannels + }; + + if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase)) + { + options.MediaSourceId = mediaSourceId; + options.AudioStreamIndex = audioStreamIndex; + options.SubtitleStreamIndex = subtitleStreamIndex; + } + + var user = _userManager.GetUserById(userId); + + if (!enableDirectPlay) + { + mediaSource.SupportsDirectPlay = false; + } + + if (!enableDirectStream) + { + mediaSource.SupportsDirectStream = false; + } + + if (!enableTranscoding) + { + mediaSource.SupportsTranscoding = false; + } + + if (item is Audio) + { + _logger.LogInformation( + "User policy for {0}. EnableAudioPlaybackTranscoding: {1}", + user.Username, + user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); + } + else + { + _logger.LogInformation( + "User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}", + user.Username, + user.HasPermission(PermissionKind.EnablePlaybackRemuxing), + user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding), + user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); + } + + // Beginning of Playback Determination: Attempt DirectPlay first + if (mediaSource.SupportsDirectPlay) + { + if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding)) + { + mediaSource.SupportsDirectPlay = false; + } + else + { + var supportsDirectStream = mediaSource.SupportsDirectStream; + + // Dummy this up to fool StreamBuilder + mediaSource.SupportsDirectStream = true; + options.MaxBitrate = maxBitrate; + + if (item is Audio) + { + if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)) + { + options.ForceDirectPlay = true; + } + } + else if (item is Video) + { + if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding) + && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding) + && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing)) + { + options.ForceDirectPlay = true; + } + } + + // The MediaSource supports direct stream, now test to see if the client supports it + var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase) + ? streamBuilder.BuildAudioItem(options) + : streamBuilder.BuildVideoItem(options); + + if (streamInfo == null || !streamInfo.IsDirectStream) + { + mediaSource.SupportsDirectPlay = false; + } + + // Set this back to what it was + mediaSource.SupportsDirectStream = supportsDirectStream; + + if (streamInfo != null) + { + SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token); + } + } + } + + if (mediaSource.SupportsDirectStream) + { + if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding)) + { + mediaSource.SupportsDirectStream = false; + } + else + { + options.MaxBitrate = GetMaxBitrate(maxBitrate, user, ipAddress); + + if (item is Audio) + { + if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)) + { + options.ForceDirectStream = true; + } + } + else if (item is Video) + { + if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding) + && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding) + && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing)) + { + options.ForceDirectStream = true; + } + } + + // The MediaSource supports direct stream, now test to see if the client supports it + var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase) + ? streamBuilder.BuildAudioItem(options) + : streamBuilder.BuildVideoItem(options); + + if (streamInfo == null || !streamInfo.IsDirectStream) + { + mediaSource.SupportsDirectStream = false; + } + + if (streamInfo != null) + { + SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token); + } + } + } + + if (mediaSource.SupportsTranscoding) + { + options.MaxBitrate = GetMaxBitrate(maxBitrate, user, ipAddress); + + // The MediaSource supports direct stream, now test to see if the client supports it + var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase) + ? streamBuilder.BuildAudioItem(options) + : streamBuilder.BuildVideoItem(options); + + if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding)) + { + if (streamInfo != null) + { + streamInfo.PlaySessionId = playSessionId; + streamInfo.StartPositionTicks = startTimeTicks; + mediaSource.TranscodingUrl = streamInfo.ToUrl("-", auth.Token).TrimStart('-'); + mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; + mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; + mediaSource.TranscodingContainer = streamInfo.Container; + mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; + + // Do this after the above so that StartPositionTicks is set + SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token); + } + } + else + { + if (streamInfo != null) + { + streamInfo.PlaySessionId = playSessionId; + + if (streamInfo.PlayMethod == PlayMethod.Transcode) + { + streamInfo.StartPositionTicks = startTimeTicks; + mediaSource.TranscodingUrl = streamInfo.ToUrl("-", auth.Token).TrimStart('-'); + + if (!allowVideoStreamCopy) + { + mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; + } + + if (!allowAudioStreamCopy) + { + mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; + } + + mediaSource.TranscodingContainer = streamInfo.Container; + mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; + } + + if (!allowAudioStreamCopy) + { + mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; + } + + mediaSource.TranscodingContainer = streamInfo.Container; + mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; + + // Do this after the above so that StartPositionTicks is set + SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token); + } + } + } + + foreach (var attachment in mediaSource.MediaAttachments) + { + attachment.DeliveryUrl = string.Format( + CultureInfo.InvariantCulture, + "/Videos/{0}/{1}/Attachments/{2}", + item.Id, + mediaSource.Id, + attachment.Index); + } + } + + /// + /// Sort media source. + /// + /// Playback info response. + /// Max bitrate. + public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate) + { + var originalList = result.MediaSources.ToList(); + + result.MediaSources = result.MediaSources.OrderBy(i => + { + // Nothing beats direct playing a file + if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) + { + return 0; + } + + return 1; + }) + .ThenBy(i => + { + // Let's assume direct streaming a file is just as desirable as direct playing a remote url + if (i.SupportsDirectPlay || i.SupportsDirectStream) + { + return 0; + } + + return 1; + }) + .ThenBy(i => + { + return i.Protocol switch + { + MediaProtocol.File => 0, + _ => 1, + }; + }) + .ThenBy(i => + { + if (maxBitrate.HasValue && i.Bitrate.HasValue) + { + return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2; + } + + return 1; + }) + .ThenBy(originalList.IndexOf) + .ToArray(); + } + + /// + /// Open media source. + /// + /// Http Request. + /// Live stream request. + /// A containing the . + public async Task OpenMediaSource(HttpRequest httpRequest, LiveStreamRequest request) + { + var authInfo = _authContext.GetAuthorizationInfo(httpRequest); + + var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); + + var profile = request.DeviceProfile; + if (profile == null) + { + var clientCapabilities = _deviceManager.GetCapabilities(authInfo.DeviceId); + if (clientCapabilities != null) + { + profile = clientCapabilities.DeviceProfile; + } + } + + if (profile != null) + { + var item = _libraryManager.GetItemById(request.ItemId); + + SetDeviceSpecificData( + item, + result.MediaSource, + profile, + authInfo, + request.MaxStreamingBitrate, + request.StartTimeTicks ?? 0, + result.MediaSource.Id, + request.AudioStreamIndex, + request.SubtitleStreamIndex, + request.MaxAudioChannels, + request.PlaySessionId, + request.UserId, + request.EnableDirectPlay, + request.EnableDirectStream, + true, + true, + true, + httpRequest.HttpContext.Connection.RemoteIpAddress.ToString()); + } + else + { + if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl)) + { + result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId; + } + } + + // here was a check if (result.MediaSource != null) but Rider said it will never be null + NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video); + + return result; + } + + /// + /// Normalize media source container. + /// + /// Media source. + /// Device profile. + /// Dlna profile type. + public void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type) + { + mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, mediaSource.Path, profile, type); + } + + private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken) + { + var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken); + mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex; + + mediaSource.TranscodeReasons = info.TranscodeReasons; + + foreach (var profile in profiles) + { + foreach (var stream in mediaSource.MediaStreams) + { + if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index) + { + stream.DeliveryMethod = profile.DeliveryMethod; + + if (profile.DeliveryMethod == SubtitleDeliveryMethod.External) + { + stream.DeliveryUrl = profile.Url.TrimStart('-'); + stream.IsExternalUrl = profile.IsExternalUrl; + } + } + } + } + } + + private long? GetMaxBitrate(long? clientMaxBitrate, User user, string ipAddress) + { + var maxBitrate = clientMaxBitrate; + var remoteClientMaxBitrate = user?.RemoteClientBitrateLimit ?? 0; + + if (remoteClientMaxBitrate <= 0) + { + remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit; + } + + if (remoteClientMaxBitrate > 0) + { + var isInLocalNetwork = _networkManager.IsInLocalNetwork(ipAddress); + + _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, ipAddress, isInLocalNetwork); + if (!isInLocalNetwork) + { + maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate); + } + } + + return maxBitrate; + } + } +} diff --git a/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs b/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs new file mode 100644 index 0000000000..315b473293 --- /dev/null +++ b/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs @@ -0,0 +1,44 @@ +using System; +using System.ComponentModel; +using System.Globalization; + +namespace Jellyfin.Api.TypeConverters +{ + /// + /// Custom datetime parser. + /// + public class DateTimeTypeConverter : TypeConverter + { + /// + public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) + { + if (sourceType == typeof(string)) + { + return true; + } + + return base.CanConvertFrom(context, sourceType); + } + + /// + public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) + { + if (value is string dateString) + { + // Mark Played Item. + if (DateTime.TryParseExact(dateString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime)) + { + return dateTime; + } + + // Get Activity Logs. + if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out dateTime)) + { + return dateTime; + } + } + + return base.ConvertFrom(context, culture, value); + } + } +} diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 108d8f881e..d0dd183c68 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -1,4 +1,6 @@ -using System.Net.Http; +using System; +using System.ComponentModel; +using Jellyfin.Api.TypeConverters; using Jellyfin.Server.Extensions; using Jellyfin.Server.Middleware; using Jellyfin.Server.Models; @@ -94,6 +96,9 @@ namespace Jellyfin.Server }); app.Use(serverApplicationHost.ExecuteHttpHandlerAsync); + + // Add type descriptor for legacy datetime parsing. + TypeDescriptor.AddAttributes(typeof(DateTime?), new TypeConverterAttribute(typeof(DateTimeTypeConverter))); } } }