Kaynağa Gözat

Merge pull request #3592 from Ullmie02/api-audio

Migrate AudioService to Jellyfin.Api
Patrick Barron 4 yıl önce
ebeveyn
işleme
44acb9f7d9

+ 3 - 1
Emby.Server.Implementations/ApplicationHost.cs

@@ -4,7 +4,6 @@ using System;
 using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Diagnostics;
-using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Net;
@@ -46,6 +45,7 @@ using Emby.Server.Implementations.Session;
 using Emby.Server.Implementations.SyncPlay;
 using Emby.Server.Implementations.TV;
 using Emby.Server.Implementations.Updates;
+using Jellyfin.Api.Helpers;
 using MediaBrowser.Api;
 using MediaBrowser.Common;
 using MediaBrowser.Common.Configuration;
@@ -633,6 +633,8 @@ namespace Emby.Server.Implementations
             serviceCollection.AddSingleton<EncodingHelper>();
 
             serviceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>();
+
+            serviceCollection.AddSingleton<TranscodingJobHelper>();
         }
 
         /// <summary>

+ 350 - 0
Jellyfin.Api/Controllers/AudioController.cs

@@ -0,0 +1,350 @@
+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
+{
+    /// <summary>
+    /// The audio controller.
+    /// </summary>
+    // TODO: In order to autheneticate 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 IStreamHelper _streamHelper;
+        private readonly IFileSystem _fileSystem;
+        private readonly ISubtitleEncoder _subtitleEncoder;
+        private readonly IConfiguration _configuration;
+        private readonly IDeviceManager _deviceManager;
+        private readonly TranscodingJobHelper _transcodingJobHelper;
+        private readonly HttpClient _httpClient;
+
+        private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="AudioController"/> class.
+        /// </summary>
+        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+        /// <param name="userManger">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+        /// <param name="streamHelper">Instance of the <see cref="IStreamHelper"/> interface.</param>
+        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param>
+        /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param>
+        /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+        /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param>
+        /// <param name="httpClient">Instance of the <see cref="HttpClient"/>.</param>
+        public AudioController(
+            IDlnaManager dlnaManager,
+            IUserManager userManger,
+            IAuthorizationContext authorizationContext,
+            ILibraryManager libraryManager,
+            IMediaSourceManager mediaSourceManager,
+            IServerConfigurationManager serverConfigurationManager,
+            IMediaEncoder mediaEncoder,
+            IStreamHelper streamHelper,
+            IFileSystem fileSystem,
+            ISubtitleEncoder subtitleEncoder,
+            IConfiguration configuration,
+            IDeviceManager deviceManager,
+            TranscodingJobHelper transcodingJobHelper,
+            HttpClient httpClient)
+        {
+            _dlnaManager = dlnaManager;
+            _authContext = authorizationContext;
+            _userManager = userManger;
+            _libraryManager = libraryManager;
+            _mediaSourceManager = mediaSourceManager;
+            _serverConfigurationManager = serverConfigurationManager;
+            _mediaEncoder = mediaEncoder;
+            _streamHelper = streamHelper;
+            _fileSystem = fileSystem;
+            _subtitleEncoder = subtitleEncoder;
+            _configuration = configuration;
+            _deviceManager = deviceManager;
+            _transcodingJobHelper = transcodingJobHelper;
+            _httpClient = httpClient;
+        }
+
+        /// <summary>
+        /// Gets an audio stream.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="container">The audio container.</param>
+        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+        /// <param name="params">The streaming parameters.</param>
+        /// <param name="tag">The tag.</param>
+        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+        /// <param name="playSessionId">The play session id.</param>
+        /// <param name="segmentContainer">The segment container.</param>
+        /// <param name="segmentLength">The segment lenght.</param>
+        /// <param name="minSegments">The minimum number of segments.</param>
+        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+        /// <param name="maxRefFrames">Optional.</param>
+        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+        /// <param name="requireAvc">Optional. Whether to require avc.</param>
+        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+        /// <param name="liveStreamId">The live stream id.</param>
+        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
+        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+        /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+        /// <param name="streamOptions">Optional. The streaming options.</param>
+        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+        [HttpGet("{itemId}/{stream=stream}.{container?}")]
+        [HttpGet("{itemId}/stream")]
+        [HttpHead("{itemId}/{stream=stream}.{container?}")]
+        [HttpHead("{itemId}/stream")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult> GetAudioStream(
+            [FromRoute] Guid itemId,
+            [FromRoute] string? container,
+            [FromQuery] bool? @static,
+            [FromQuery] string? @params,
+            [FromQuery] string? tag,
+            [FromQuery] string? deviceProfileId,
+            [FromQuery] string? playSessionId,
+            [FromQuery] string? segmentContainer,
+            [FromQuery] int? segmentLength,
+            [FromQuery] int? minSegments,
+            [FromQuery] string? mediaSourceId,
+            [FromQuery] string? deviceId,
+            [FromQuery] string? audioCodec,
+            [FromQuery] bool? enableAutoStreamCopy,
+            [FromQuery] bool? allowVideoStreamCopy,
+            [FromQuery] bool? allowAudioStreamCopy,
+            [FromQuery] bool? breakOnNonKeyFrames,
+            [FromQuery] int? audioSampleRate,
+            [FromQuery] int? maxAudioBitDepth,
+            [FromQuery] int? audioBitRate,
+            [FromQuery] int? audioChannels,
+            [FromQuery] int? maxAudioChannels,
+            [FromQuery] string? profile,
+            [FromQuery] string? level,
+            [FromQuery] float? framerate,
+            [FromQuery] float? maxFramerate,
+            [FromQuery] bool? copyTimestamps,
+            [FromQuery] long? startTimeTicks,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? videoBitRate,
+            [FromQuery] int? subtitleStreamIndex,
+            [FromQuery] SubtitleDeliveryMethod subtitleMethod,
+            [FromQuery] int? maxRefFrames,
+            [FromQuery] int? maxVideoBitDepth,
+            [FromQuery] bool? requireAvc,
+            [FromQuery] bool? deInterlace,
+            [FromQuery] bool? requireNonAnamorphic,
+            [FromQuery] int? transcodingMaxAudioChannels,
+            [FromQuery] int? cpuCoreLimit,
+            [FromQuery] string? liveStreamId,
+            [FromQuery] bool? enableMpegtsM2TsMode,
+            [FromQuery] string? videoCodec,
+            [FromQuery] string? subtitleCodec,
+            [FromQuery] string? transcodingReasons,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? videoStreamIndex,
+            [FromQuery] EncodingContext context,
+            [FromQuery] Dictionary<string, string> streamOptions)
+        {
+            bool isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head;
+
+            var cancellationTokenSource = new CancellationTokenSource();
+
+            StreamingRequestDto streamingRequest = new StreamingRequestDto
+            {
+                Id = itemId,
+                Container = container,
+                Static = @static.HasValue ? @static.Value : true,
+                Params = @params,
+                Tag = tag,
+                DeviceProfileId = deviceProfileId,
+                PlaySessionId = playSessionId,
+                SegmentContainer = segmentContainer,
+                SegmentLength = segmentLength,
+                MinSegments = minSegments,
+                MediaSourceId = mediaSourceId,
+                DeviceId = deviceId,
+                AudioCodec = audioCodec,
+                EnableAutoStreamCopy = enableAutoStreamCopy.HasValue ? enableAutoStreamCopy.Value : true,
+                AllowAudioStreamCopy = allowAudioStreamCopy.HasValue ? allowAudioStreamCopy.Value : true,
+                AllowVideoStreamCopy = allowVideoStreamCopy.HasValue ? allowVideoStreamCopy.Value : true,
+                BreakOnNonKeyFrames = breakOnNonKeyFrames.HasValue ? breakOnNonKeyFrames.Value : false,
+                AudioSampleRate = audioSampleRate,
+                MaxAudioChannels = maxAudioChannels,
+                AudioBitRate = audioBitRate,
+                MaxAudioBitDepth = maxAudioBitDepth,
+                AudioChannels = audioChannels,
+                Profile = profile,
+                Level = level,
+                Framerate = framerate,
+                MaxFramerate = maxFramerate,
+                CopyTimestamps = copyTimestamps.HasValue ? copyTimestamps.Value : true,
+                StartTimeTicks = startTimeTicks,
+                Width = width,
+                Height = height,
+                VideoBitRate = videoBitRate,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                SubtitleMethod = subtitleMethod,
+                MaxRefFrames = maxRefFrames,
+                MaxVideoBitDepth = maxVideoBitDepth,
+                RequireAvc = requireAvc.HasValue ? requireAvc.Value : true,
+                DeInterlace = deInterlace.HasValue ? deInterlace.Value : true,
+                RequireNonAnamorphic = requireNonAnamorphic.HasValue ? requireNonAnamorphic.Value : true,
+                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+                CpuCoreLimit = cpuCoreLimit,
+                LiveStreamId = liveStreamId,
+                EnableMpegtsM2TsMode = enableMpegtsM2TsMode.HasValue ? enableMpegtsM2TsMode.Value : true,
+                VideoCodec = videoCodec,
+                SubtitleCodec = subtitleCodec,
+                TranscodeReasons = transcodingReasons,
+                AudioStreamIndex = audioStreamIndex,
+                VideoStreamIndex = videoStreamIndex,
+                Context = context,
+                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);
+
+                // TODO AllowEndOfFile = false
+                await new ProgressiveFileCopier(_streamHelper, state.DirectStreamProvider).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);
+
+                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)
+                {
+                    // TODO AllowEndOfFile = false
+                    await new ProgressiveFileCopier(_streamHelper, state.MediaPath).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,
+                _streamHelper,
+                this,
+                _transcodingJobHelper,
+                ffmpegCommandLineArguments,
+                Request,
+                _transcodingJobType,
+                cancellationTokenSource).ConfigureAwait(false);
+        }
+    }
+}

+ 3 - 9
Jellyfin.Api/Controllers/PlaystateController.cs

@@ -8,7 +8,6 @@ using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Session;
 using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Session;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -40,8 +39,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
         /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
         /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
-        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        /// <param name="transcodingJobHelper">Th <see cref="TranscodingJobHelper"/> singleton.</param>
         public PlaystateController(
             IUserManager userManager,
             IUserDataManager userDataRepository,
@@ -49,8 +47,7 @@ namespace Jellyfin.Api.Controllers
             ISessionManager sessionManager,
             IAuthorizationContext authContext,
             ILoggerFactory loggerFactory,
-            IMediaSourceManager mediaSourceManager,
-            IFileSystem fileSystem)
+            TranscodingJobHelper transcodingJobHelper)
         {
             _userManager = userManager;
             _userDataRepository = userDataRepository;
@@ -59,10 +56,7 @@ namespace Jellyfin.Api.Controllers
             _authContext = authContext;
             _logger = loggerFactory.CreateLogger<PlaystateController>();
 
-            _transcodingJobHelper = new TranscodingJobHelper(
-                loggerFactory.CreateLogger<TranscodingJobHelper>(),
-                mediaSourceManager,
-                fileSystem);
+            _transcodingJobHelper = transcodingJobHelper;
         }
 
         /// <summary>

+ 139 - 0
Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs

@@ -0,0 +1,139 @@
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.StreamingDtos;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.IO;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Net.Http.Headers;
+
+namespace Jellyfin.Api.Helpers
+{
+    /// <summary>
+    /// The stream response helpers.
+    /// </summary>
+    public static class FileStreamResponseHelpers
+    {
+        /// <summary>
+        /// Returns a static file from a remote source.
+        /// </summary>
+        /// <param name="state">The current <see cref="StreamState"/>.</param>
+        /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param>
+        /// <param name="controller">The <see cref="ControllerBase"/> managing the response.</param>
+        /// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param>
+        /// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns>
+        public static async Task<ActionResult> GetStaticRemoteStreamResult(
+            StreamState state,
+            bool isHeadRequest,
+            ControllerBase controller,
+            HttpClient httpClient)
+        {
+            if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent))
+            {
+                httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent);
+            }
+
+            using var response = await httpClient.GetAsync(state.MediaPath).ConfigureAwait(false);
+            var contentType = response.Content.Headers.ContentType.ToString();
+
+            controller.Response.Headers[HeaderNames.AcceptRanges] = "none";
+
+            if (isHeadRequest)
+            {
+                return controller.File(Array.Empty<byte>(), contentType);
+            }
+
+            return controller.File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), contentType);
+        }
+
+        /// <summary>
+        /// Returns a static file from the server.
+        /// </summary>
+        /// <param name="path">The path to the file.</param>
+        /// <param name="contentType">The content type of the file.</param>
+        /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param>
+        /// <param name="controller">The <see cref="ControllerBase"/> managing the response.</param>
+        /// <returns>An <see cref="ActionResult"/> the file.</returns>
+        public static ActionResult GetStaticFileResult(
+            string path,
+            string contentType,
+            bool isHeadRequest,
+            ControllerBase controller)
+        {
+            controller.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();
+            }
+
+            using var stream = new FileStream(path, FileMode.Open, FileAccess.Read);
+            return controller.File(stream, contentType);
+        }
+
+        /// <summary>
+        /// Returns a transcoded file from the server.
+        /// </summary>
+        /// <param name="state">The current <see cref="StreamState"/>.</param>
+        /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param>
+        /// <param name="streamHelper">Instance of the <see cref="IStreamHelper"/> interface.</param>
+        /// <param name="controller">The <see cref="ControllerBase"/> managing the response.</param>
+        /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param>
+        /// <param name="ffmpegCommandLineArguments">The command line arguments to start ffmpeg.</param>
+        /// <param name="request">The <see cref="HttpRequest"/> starting the transcoding.</param>
+        /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
+        /// <param name="cancellationTokenSource">The <see cref="CancellationTokenSource"/>.</param>
+        /// <returns>A <see cref="Task{ActionResult}"/> containing the transcoded file.</returns>
+        public static async Task<ActionResult> GetTranscodedFile(
+            StreamState state,
+            bool isHeadRequest,
+            IStreamHelper streamHelper,
+            ControllerBase controller,
+            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";
+
+            var contentType = state.GetMimeType(outputPath);
+
+            // Headers only
+            if (isHeadRequest)
+            {
+                return controller.File(Array.Empty<byte>(), contentType);
+            }
+
+            var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath);
+            await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
+            try
+            {
+                if (!File.Exists(outputPath))
+                {
+                    await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false);
+                }
+                else
+                {
+                    transcodingJobHelper.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive);
+                    state.Dispose();
+                }
+
+                await using var memoryStream = new MemoryStream();
+                await new ProgressiveFileCopier(streamHelper, outputPath).WriteToAsync(memoryStream, CancellationToken.None).ConfigureAwait(false);
+                return controller.File(memoryStream, contentType);
+            }
+            finally
+            {
+                transcodingLock.Release();
+            }
+        }
+    }
+}

+ 758 - 0
Jellyfin.Api/Helpers/StreamingHelpers.cs

@@ -0,0 +1,758 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.StreamingDtos;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+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.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Primitives;
+using Microsoft.Net.Http.Headers;
+
+namespace Jellyfin.Api.Helpers
+{
+    /// <summary>
+    /// The streaming helpers.
+    /// </summary>
+    public static class StreamingHelpers
+    {
+        /// <summary>
+        /// Gets the current streaming state.
+        /// </summary>
+        /// <param name="streamingRequest">The <see cref="StreamingRequestDto"/>.</param>
+        /// <param name="httpRequest">The <see cref="HttpRequest"/>.</param>
+        /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param>
+        /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param>
+        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+        /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+        /// <param name="transcodingJobHelper">Initialized <see cref="TranscodingJobHelper"/>.</param>
+        /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
+        /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
+        /// <returns>A <see cref="Task"/> containing the current <see cref="StreamState"/>.</returns>
+        public static async Task<StreamState> GetStreamingState(
+            StreamingRequestDto streamingRequest,
+            HttpRequest httpRequest,
+            IAuthorizationContext authorizationContext,
+            IMediaSourceManager mediaSourceManager,
+            IUserManager userManager,
+            ILibraryManager libraryManager,
+            IServerConfigurationManager serverConfigurationManager,
+            IMediaEncoder mediaEncoder,
+            IFileSystem fileSystem,
+            ISubtitleEncoder subtitleEncoder,
+            IConfiguration configuration,
+            IDlnaManager dlnaManager,
+            IDeviceManager deviceManager,
+            TranscodingJobHelper transcodingJobHelper,
+            TranscodingJobType transcodingJobType,
+            CancellationToken cancellationToken)
+        {
+            EncodingHelper encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration);
+            // Parse the DLNA time seek header
+            if (!streamingRequest.StartTimeTicks.HasValue)
+            {
+                var timeSeek = httpRequest.Headers["TimeSeekRange.dlna.org"];
+
+                streamingRequest.StartTimeTicks = ParseTimeSeekHeader(timeSeek.ToString());
+            }
+
+            if (!string.IsNullOrWhiteSpace(streamingRequest.Params))
+            {
+                ParseParams(streamingRequest);
+            }
+
+            streamingRequest.StreamOptions = ParseStreamOptions(httpRequest.Query);
+
+            var url = httpRequest.Path.Value.Split('.').Last();
+
+            if (string.IsNullOrEmpty(streamingRequest.AudioCodec))
+            {
+                streamingRequest.AudioCodec = encodingHelper.InferAudioCodec(url);
+            }
+
+            var enableDlnaHeaders = !string.IsNullOrWhiteSpace(streamingRequest.Params) ||
+                                    string.Equals(httpRequest.Headers["GetContentFeatures.DLNA.ORG"], "1", StringComparison.OrdinalIgnoreCase);
+
+            var state = new StreamState(mediaSourceManager, transcodingJobType, transcodingJobHelper)
+            {
+                Request = streamingRequest,
+                RequestedUrl = url,
+                UserAgent = httpRequest.Headers[HeaderNames.UserAgent],
+                EnableDlnaHeaders = enableDlnaHeaders
+            };
+
+            var auth = authorizationContext.GetAuthorizationInfo(httpRequest);
+            if (!auth.UserId.Equals(Guid.Empty))
+            {
+                state.User = userManager.GetUserById(auth.UserId);
+            }
+
+            if (state.IsVideoRequest && !string.IsNullOrWhiteSpace(state.Request.VideoCodec))
+            {
+                state.SupportedVideoCodecs = state.Request.VideoCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
+                state.Request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault();
+            }
+
+            if (!string.IsNullOrWhiteSpace(streamingRequest.AudioCodec))
+            {
+                state.SupportedAudioCodecs = streamingRequest.AudioCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
+                state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => mediaEncoder.CanEncodeToAudioCodec(i))
+                                           ?? state.SupportedAudioCodecs.FirstOrDefault();
+            }
+
+            if (!string.IsNullOrWhiteSpace(streamingRequest.SubtitleCodec))
+            {
+                state.SupportedSubtitleCodecs = streamingRequest.SubtitleCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
+                state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(i => mediaEncoder.CanEncodeToSubtitleCodec(i))
+                                              ?? state.SupportedSubtitleCodecs.FirstOrDefault();
+            }
+
+            var item = libraryManager.GetItemById(streamingRequest.Id);
+
+            state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase);
+
+            MediaSourceInfo? mediaSource = null;
+            if (string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId))
+            {
+                var currentJob = !string.IsNullOrWhiteSpace(streamingRequest.PlaySessionId)
+                    ? transcodingJobHelper.GetTranscodingJob(streamingRequest.PlaySessionId)
+                    : null;
+
+                if (currentJob != null)
+                {
+                    mediaSource = currentJob.MediaSource;
+                }
+
+                if (mediaSource == null)
+                {
+                    var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false);
+
+                    mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId)
+                        ? mediaSources[0]
+                        : mediaSources.Find(i => string.Equals(i.Id, streamingRequest.MediaSourceId, StringComparison.InvariantCulture));
+
+                    if (mediaSource == null && Guid.Parse(streamingRequest.MediaSourceId) == streamingRequest.Id)
+                    {
+                        mediaSource = mediaSources[0];
+                    }
+                }
+            }
+            else
+            {
+                var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false);
+                mediaSource = liveStreamInfo.Item1;
+                state.DirectStreamProvider = liveStreamInfo.Item2;
+            }
+
+            encodingHelper.AttachMediaSourceInfo(state, mediaSource, url);
+
+            string? containerInternal = Path.GetExtension(state.RequestedUrl);
+
+            if (string.IsNullOrEmpty(streamingRequest.Container))
+            {
+                containerInternal = streamingRequest.Container;
+            }
+
+            if (string.IsNullOrEmpty(containerInternal))
+            {
+                containerInternal = streamingRequest.Static ?
+                    StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, state.MediaPath, null, DlnaProfileType.Audio)
+                    : GetOutputFileExtension(state);
+            }
+
+            state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.');
+
+            state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, state.AudioStream);
+
+            state.OutputAudioCodec = streamingRequest.AudioCodec;
+
+            state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec);
+
+            if (state.VideoRequest != null)
+            {
+                state.OutputVideoCodec = state.Request.VideoCodec;
+                state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec);
+
+                encodingHelper.TryStreamCopy(state);
+
+                if (state.OutputVideoBitrate.HasValue && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+                {
+                    var resolution = ResolutionNormalizer.Normalize(
+                        state.VideoStream?.BitRate,
+                        state.VideoStream?.Width,
+                        state.VideoStream?.Height,
+                        state.OutputVideoBitrate.Value,
+                        state.VideoStream?.Codec,
+                        state.OutputVideoCodec,
+                        state.VideoRequest.MaxWidth,
+                        state.VideoRequest.MaxHeight);
+
+                    state.VideoRequest.MaxWidth = resolution.MaxWidth;
+                    state.VideoRequest.MaxHeight = resolution.MaxHeight;
+                }
+            }
+
+            ApplyDeviceProfileSettings(state, dlnaManager, deviceManager, httpRequest, streamingRequest.DeviceProfileId, streamingRequest.Static);
+
+            var ext = string.IsNullOrWhiteSpace(state.OutputContainer)
+                ? GetOutputFileExtension(state)
+                : ('.' + state.OutputContainer);
+
+            state.OutputFilePath = GetOutputFilePath(state, ext!, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId);
+
+            return state;
+        }
+
+        /// <summary>
+        /// Adds the dlna headers.
+        /// </summary>
+        /// <param name="state">The state.</param>
+        /// <param name="responseHeaders">The response headers.</param>
+        /// <param name="isStaticallyStreamed">if set to <c>true</c> [is statically streamed].</param>
+        /// <param name="startTimeTicks">The start time in ticks.</param>
+        /// <param name="request">The <see cref="HttpRequest"/>.</param>
+        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+        public static void AddDlnaHeaders(
+            StreamState state,
+            IHeaderDictionary responseHeaders,
+            bool isStaticallyStreamed,
+            long? startTimeTicks,
+            HttpRequest request,
+            IDlnaManager dlnaManager)
+        {
+            if (!state.EnableDlnaHeaders)
+            {
+                return;
+            }
+
+            var profile = state.DeviceProfile;
+
+            StringValues transferMode = request.Headers["transferMode.dlna.org"];
+            responseHeaders.Add("transferMode.dlna.org", string.IsNullOrEmpty(transferMode) ? "Streaming" : transferMode.ToString());
+            responseHeaders.Add("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*");
+
+            if (state.RunTimeTicks.HasValue)
+            {
+                if (string.Equals(request.Headers["getMediaInfo.sec"], "1", StringComparison.OrdinalIgnoreCase))
+                {
+                    var ms = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalMilliseconds;
+                    responseHeaders.Add("MediaInfo.sec", string.Format(
+                        CultureInfo.InvariantCulture,
+                        "SEC_Duration={0};",
+                        Convert.ToInt32(ms)));
+                }
+
+                if (!isStaticallyStreamed && profile != null)
+                {
+                    AddTimeSeekResponseHeaders(state, responseHeaders, startTimeTicks);
+                }
+            }
+
+            if (profile == null)
+            {
+                profile = dlnaManager.GetDefaultProfile();
+            }
+
+            var audioCodec = state.ActualOutputAudioCodec;
+
+            if (!state.IsVideoRequest)
+            {
+                responseHeaders.Add("contentFeatures.dlna.org", new ContentFeatureBuilder(profile).BuildAudioHeader(
+                    state.OutputContainer,
+                    audioCodec,
+                    state.OutputAudioBitrate,
+                    state.OutputAudioSampleRate,
+                    state.OutputAudioChannels,
+                    state.OutputAudioBitDepth,
+                    isStaticallyStreamed,
+                    state.RunTimeTicks,
+                    state.TranscodeSeekInfo));
+            }
+            else
+            {
+                var videoCodec = state.ActualOutputVideoCodec;
+
+                responseHeaders.Add(
+                    "contentFeatures.dlna.org",
+                    new ContentFeatureBuilder(profile).BuildVideoHeader(state.OutputContainer, videoCodec, audioCodec, state.OutputWidth, state.OutputHeight, state.TargetVideoBitDepth, state.OutputVideoBitrate, state.TargetTimestamp, isStaticallyStreamed, state.RunTimeTicks, state.TargetVideoProfile, state.TargetVideoLevel, state.TargetFramerate, state.TargetPacketLength, state.TranscodeSeekInfo, state.IsTargetAnamorphic, state.IsTargetInterlaced, state.TargetRefFrames, state.TargetVideoStreamCount, state.TargetAudioStreamCount, state.TargetVideoCodecTag, state.IsTargetAVC).FirstOrDefault() ?? string.Empty);
+            }
+        }
+
+        /// <summary>
+        /// Parses the time seek header.
+        /// </summary>
+        /// <param name="value">The time seek header string.</param>
+        /// <returns>A nullable <see cref="long"/> representing the seek time in ticks.</returns>
+        private static long? ParseTimeSeekHeader(ReadOnlySpan<char> value)
+        {
+            if (value.IsEmpty)
+            {
+                return null;
+            }
+
+            const string npt = "npt=";
+            if (!value.StartsWith(npt, StringComparison.OrdinalIgnoreCase))
+            {
+                throw new ArgumentException("Invalid timeseek header");
+            }
+
+            var index = value.IndexOf('-');
+            value = index == -1
+                ? value.Slice(npt.Length)
+                : value.Slice(npt.Length, index - npt.Length);
+            if (value.IndexOf(':') == -1)
+            {
+                // Parses npt times in the format of '417.33'
+                if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds))
+                {
+                    return TimeSpan.FromSeconds(seconds).Ticks;
+                }
+
+                throw new ArgumentException("Invalid timeseek header");
+            }
+
+            try
+            {
+                // Parses npt times in the format of '10:19:25.7'
+                return TimeSpan.Parse(value).Ticks;
+            }
+            catch
+            {
+                throw new ArgumentException("Invalid timeseek header");
+            }
+        }
+
+        /// <summary>
+        /// Parses query parameters as StreamOptions.
+        /// </summary>
+        /// <param name="queryString">The query string.</param>
+        /// <returns>A <see cref="Dictionary{String,String}"/> containing the stream options.</returns>
+        private static Dictionary<string, string> ParseStreamOptions(IQueryCollection queryString)
+        {
+            Dictionary<string, string> streamOptions = new Dictionary<string, string>();
+            foreach (var param in queryString)
+            {
+                if (char.IsLower(param.Key[0]))
+                {
+                    // This was probably not parsed initially and should be a StreamOptions
+                    // or the generated URL should correctly serialize it
+                    // TODO: This should be incorporated either in the lower framework for parsing requests
+                    streamOptions[param.Key] = param.Value;
+                }
+            }
+
+            return streamOptions;
+        }
+
+        /// <summary>
+        /// Adds the dlna time seek headers to the response.
+        /// </summary>
+        /// <param name="state">The current <see cref="StreamState"/>.</param>
+        /// <param name="responseHeaders">The <see cref="IHeaderDictionary"/> of the response.</param>
+        /// <param name="startTimeTicks">The start time in ticks.</param>
+        private static void AddTimeSeekResponseHeaders(StreamState state, IHeaderDictionary responseHeaders, long? startTimeTicks)
+        {
+            var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks!.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture);
+            var startSeconds = TimeSpan.FromTicks(startTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture);
+
+            responseHeaders.Add("TimeSeekRange.dlna.org", string.Format(
+                CultureInfo.InvariantCulture,
+                "npt={0}-{1}/{1}",
+                startSeconds,
+                runtimeSeconds));
+            responseHeaders.Add("X-AvailableSeekRange", string.Format(
+                CultureInfo.InvariantCulture,
+                "1 npt={0}-{1}",
+                startSeconds,
+                runtimeSeconds));
+        }
+
+        /// <summary>
+        /// Gets the output file extension.
+        /// </summary>
+        /// <param name="state">The state.</param>
+        /// <returns>System.String.</returns>
+        private static string? GetOutputFileExtension(StreamState state)
+        {
+            var ext = Path.GetExtension(state.RequestedUrl);
+
+            if (!string.IsNullOrEmpty(ext))
+            {
+                return ext;
+            }
+
+            // Try to infer based on the desired video codec
+            if (state.IsVideoRequest)
+            {
+                var videoCodec = state.Request.VideoCodec;
+
+                if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase) ||
+                    string.Equals(videoCodec, "h265", StringComparison.OrdinalIgnoreCase))
+                {
+                    return ".ts";
+                }
+
+                if (string.Equals(videoCodec, "theora", StringComparison.OrdinalIgnoreCase))
+                {
+                    return ".ogv";
+                }
+
+                if (string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase))
+                {
+                    return ".webm";
+                }
+
+                if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase))
+                {
+                    return ".asf";
+                }
+            }
+
+            // Try to infer based on the desired audio codec
+            if (!state.IsVideoRequest)
+            {
+                var audioCodec = state.Request.AudioCodec;
+
+                if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase))
+                {
+                    return ".aac";
+                }
+
+                if (string.Equals("mp3", audioCodec, StringComparison.OrdinalIgnoreCase))
+                {
+                    return ".mp3";
+                }
+
+                if (string.Equals("vorbis", audioCodec, StringComparison.OrdinalIgnoreCase))
+                {
+                    return ".ogg";
+                }
+
+                if (string.Equals("wma", audioCodec, StringComparison.OrdinalIgnoreCase))
+                {
+                    return ".wma";
+                }
+            }
+
+            return null;
+        }
+
+        /// <summary>
+        /// Gets the output file path for transcoding.
+        /// </summary>
+        /// <param name="state">The current <see cref="StreamState"/>.</param>
+        /// <param name="outputFileExtension">The file extension of the output file.</param>
+        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        /// <param name="deviceId">The device id.</param>
+        /// <param name="playSessionId">The play session id.</param>
+        /// <returns>The complete file path, including the folder, for the transcoding file.</returns>
+        private static string GetOutputFilePath(StreamState state, string outputFileExtension, IServerConfigurationManager serverConfigurationManager, string? deviceId, string? playSessionId)
+        {
+            var data = $"{state.MediaPath}-{state.UserAgent}-{deviceId!}-{playSessionId!}";
+
+            var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+            var ext = outputFileExtension?.ToLowerInvariant();
+            var folder = serverConfigurationManager.GetTranscodePath();
+
+            return Path.Combine(folder, filename + ext);
+        }
+
+        private static void ApplyDeviceProfileSettings(StreamState state, IDlnaManager dlnaManager, IDeviceManager deviceManager, HttpRequest request, string? deviceProfileId, bool? @static)
+        {
+            var headers = request.Headers;
+
+            if (!string.IsNullOrWhiteSpace(deviceProfileId))
+            {
+                state.DeviceProfile = dlnaManager.GetProfile(deviceProfileId);
+            }
+            else if (!string.IsNullOrWhiteSpace(deviceProfileId))
+            {
+                var caps = deviceManager.GetCapabilities(deviceProfileId);
+
+                state.DeviceProfile = caps == null ? dlnaManager.GetProfile(headers) : caps.DeviceProfile;
+            }
+
+            var profile = state.DeviceProfile;
+
+            if (profile == null)
+            {
+                // Don't use settings from the default profile.
+                // Only use a specific profile if it was requested.
+                return;
+            }
+
+            var audioCodec = state.ActualOutputAudioCodec;
+            var videoCodec = state.ActualOutputVideoCodec;
+
+            var mediaProfile = !state.IsVideoRequest
+                ? profile.GetAudioMediaProfile(state.OutputContainer, audioCodec, state.OutputAudioChannels, state.OutputAudioBitrate, state.OutputAudioSampleRate, state.OutputAudioBitDepth)
+                : profile.GetVideoMediaProfile(
+                    state.OutputContainer,
+                    audioCodec,
+                    videoCodec,
+                    state.OutputWidth,
+                    state.OutputHeight,
+                    state.TargetVideoBitDepth,
+                    state.OutputVideoBitrate,
+                    state.TargetVideoProfile,
+                    state.TargetVideoLevel,
+                    state.TargetFramerate,
+                    state.TargetPacketLength,
+                    state.TargetTimestamp,
+                    state.IsTargetAnamorphic,
+                    state.IsTargetInterlaced,
+                    state.TargetRefFrames,
+                    state.TargetVideoStreamCount,
+                    state.TargetAudioStreamCount,
+                    state.TargetVideoCodecTag,
+                    state.IsTargetAVC);
+
+            if (mediaProfile != null)
+            {
+                state.MimeType = mediaProfile.MimeType;
+            }
+
+            if (!(@static.HasValue && @static.Value))
+            {
+                var transcodingProfile = !state.IsVideoRequest ? profile.GetAudioTranscodingProfile(state.OutputContainer, audioCodec) : profile.GetVideoTranscodingProfile(state.OutputContainer, audioCodec, videoCodec);
+
+                if (transcodingProfile != null)
+                {
+                    state.EstimateContentLength = transcodingProfile.EstimateContentLength;
+                    // state.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode;
+                    state.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo;
+
+                    if (state.VideoRequest != null)
+                    {
+                        state.VideoRequest.CopyTimestamps = transcodingProfile.CopyTimestamps;
+                        state.VideoRequest.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest;
+                    }
+                }
+            }
+        }
+
+        /// <summary>
+        /// Parses the parameters.
+        /// </summary>
+        /// <param name="request">The request.</param>
+        private static void ParseParams(StreamingRequestDto request)
+        {
+            if (string.IsNullOrEmpty(request.Params))
+            {
+                return;
+            }
+
+            var vals = request.Params.Split(';');
+
+            var videoRequest = request as VideoRequestDto;
+
+            for (var i = 0; i < vals.Length; i++)
+            {
+                var val = vals[i];
+
+                if (string.IsNullOrWhiteSpace(val))
+                {
+                    continue;
+                }
+
+                switch (i)
+                {
+                    case 0:
+                        request.DeviceProfileId = val;
+                        break;
+                    case 1:
+                        request.DeviceId = val;
+                        break;
+                    case 2:
+                        request.MediaSourceId = val;
+                        break;
+                    case 3:
+                        request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+                        break;
+                    case 4:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.VideoCodec = val;
+                        }
+
+                        break;
+                    case 5:
+                        request.AudioCodec = val;
+                        break;
+                    case 6:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.AudioStreamIndex = int.Parse(val, CultureInfo.InvariantCulture);
+                        }
+
+                        break;
+                    case 7:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.SubtitleStreamIndex = int.Parse(val, CultureInfo.InvariantCulture);
+                        }
+
+                        break;
+                    case 8:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.VideoBitRate = int.Parse(val, CultureInfo.InvariantCulture);
+                        }
+
+                        break;
+                    case 9:
+                        request.AudioBitRate = int.Parse(val, CultureInfo.InvariantCulture);
+                        break;
+                    case 10:
+                        request.MaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture);
+                        break;
+                    case 11:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.MaxFramerate = float.Parse(val, CultureInfo.InvariantCulture);
+                        }
+
+                        break;
+                    case 12:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.MaxWidth = int.Parse(val, CultureInfo.InvariantCulture);
+                        }
+
+                        break;
+                    case 13:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.MaxHeight = int.Parse(val, CultureInfo.InvariantCulture);
+                        }
+
+                        break;
+                    case 14:
+                        request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture);
+                        break;
+                    case 15:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.Level = val;
+                        }
+
+                        break;
+                    case 16:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.MaxRefFrames = int.Parse(val, CultureInfo.InvariantCulture);
+                        }
+
+                        break;
+                    case 17:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.MaxVideoBitDepth = int.Parse(val, CultureInfo.InvariantCulture);
+                        }
+
+                        break;
+                    case 18:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.Profile = val;
+                        }
+
+                        break;
+                    case 19:
+                        // cabac no longer used
+                        break;
+                    case 20:
+                        request.PlaySessionId = val;
+                        break;
+                    case 21:
+                        // api_key
+                        break;
+                    case 22:
+                        request.LiveStreamId = val;
+                        break;
+                    case 23:
+                        // Duplicating ItemId because of MediaMonkey
+                        break;
+                    case 24:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.CopyTimestamps = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+                        }
+
+                        break;
+                    case 25:
+                        if (!string.IsNullOrWhiteSpace(val) && videoRequest != null)
+                        {
+                            if (Enum.TryParse(val, out SubtitleDeliveryMethod method))
+                            {
+                                videoRequest.SubtitleMethod = method;
+                            }
+                        }
+
+                        break;
+                    case 26:
+                        request.TranscodingMaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture);
+                        break;
+                    case 27:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.EnableSubtitlesInManifest = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+                        }
+
+                        break;
+                    case 28:
+                        request.Tag = val;
+                        break;
+                    case 29:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.RequireAvc = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+                        }
+
+                        break;
+                    case 30:
+                        request.SubtitleCodec = val;
+                        break;
+                    case 31:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.RequireNonAnamorphic = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+                        }
+
+                        break;
+                    case 32:
+                        if (videoRequest != null)
+                        {
+                            videoRequest.DeInterlace = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+                        }
+
+                        break;
+                    case 33:
+                        request.TranscodeReasons = val;
+                        break;
+                }
+            }
+        }
+    }
+}

+ 489 - 2
Jellyfin.Api/Helpers/TranscodingJobHelper.cs

@@ -1,13 +1,28 @@
 using System;
 using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
 using System.IO;
 using System.Linq;
+using System.Text;
+using System.Text.Json;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Api.Models.PlaybackDtos;
+using Jellyfin.Api.Models.StreamingDtos;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Session;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Logging;
 
 namespace Jellyfin.Api.Helpers
@@ -27,9 +42,17 @@ namespace Jellyfin.Api.Helpers
         /// </summary>
         private static readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = new Dictionary<string, SemaphoreSlim>();
 
+        private readonly IAuthorizationContext _authorizationContext;
+        private readonly EncodingHelper _encodingHelper;
+        private readonly IFileSystem _fileSystem;
+        private readonly IIsoManager _isoManager;
+
         private readonly ILogger<TranscodingJobHelper> _logger;
+        private readonly IMediaEncoder _mediaEncoder;
         private readonly IMediaSourceManager _mediaSourceManager;
-        private readonly IFileSystem _fileSystem;
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+        private readonly ISessionManager _sessionManager;
+        private readonly ILoggerFactory _loggerFactory;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="TranscodingJobHelper"/> class.
@@ -37,14 +60,40 @@ namespace Jellyfin.Api.Helpers
         /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobHelpers}"/> interface.</param>
         /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
         /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
+        /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="isoManager">Instance of the <see cref="IIsoManager"/> interface.</param>
+        /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param>
+        /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param>
+        /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
         public TranscodingJobHelper(
             ILogger<TranscodingJobHelper> logger,
             IMediaSourceManager mediaSourceManager,
-            IFileSystem fileSystem)
+            IFileSystem fileSystem,
+            IMediaEncoder mediaEncoder,
+            IServerConfigurationManager serverConfigurationManager,
+            ISessionManager sessionManager,
+            IAuthorizationContext authorizationContext,
+            IIsoManager isoManager,
+            ISubtitleEncoder subtitleEncoder,
+            IConfiguration configuration,
+            ILoggerFactory loggerFactory)
         {
             _logger = logger;
             _mediaSourceManager = mediaSourceManager;
             _fileSystem = fileSystem;
+            _mediaEncoder = mediaEncoder;
+            _serverConfigurationManager = serverConfigurationManager;
+            _sessionManager = sessionManager;
+            _authorizationContext = authorizationContext;
+            _isoManager = isoManager;
+            _loggerFactory = loggerFactory;
+
+            _encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration);
+
+            DeleteEncodedMediaCache();
         }
 
         /// <summary>
@@ -60,6 +109,20 @@ namespace Jellyfin.Api.Helpers
             }
         }
 
+        /// <summary>
+        /// Get transcoding job.
+        /// </summary>
+        /// <param name="path">Path to the transcoding file.</param>
+        /// <param name="type">The <see cref="TranscodingJobType"/>.</param>
+        /// <returns>The transcoding job.</returns>
+        public TranscodingJobDto GetTranscodingJob(string path, TranscodingJobType type)
+        {
+            lock (_activeTranscodingJobs)
+            {
+                return _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase));
+            }
+        }
+
         /// <summary>
         /// Ping transcoding job.
         /// </summary>
@@ -349,5 +412,429 @@ namespace Jellyfin.Api.Helpers
                 throw new AggregateException("Error deleting HLS files", exs);
             }
         }
+
+        /// <summary>
+        /// Report the transcoding progress to the session manager.
+        /// </summary>
+        /// <param name="job">The <see cref="TranscodingJobDto"/> of which the progress will be reported.</param>
+        /// <param name="state">The <see cref="StreamState"/> of the current transcoding job.</param>
+        /// <param name="transcodingPosition">The current transcoding position.</param>
+        /// <param name="framerate">The framerate of the transcoding job.</param>
+        /// <param name="percentComplete">The completion percentage of the transcode.</param>
+        /// <param name="bytesTranscoded">The number of bytes transcoded.</param>
+        /// <param name="bitRate">The bitrate of the transcoding job.</param>
+        public void ReportTranscodingProgress(
+            TranscodingJobDto job,
+            StreamState state,
+            TimeSpan? transcodingPosition,
+            float? framerate,
+            double? percentComplete,
+            long? bytesTranscoded,
+            int? bitRate)
+        {
+            var ticks = transcodingPosition?.Ticks;
+
+            if (job != null)
+            {
+                job.Framerate = framerate;
+                job.CompletionPercentage = percentComplete;
+                job.TranscodingPositionTicks = ticks;
+                job.BytesTranscoded = bytesTranscoded;
+                job.BitRate = bitRate;
+            }
+
+            var deviceId = state.Request.DeviceId;
+
+            if (!string.IsNullOrWhiteSpace(deviceId))
+            {
+                var audioCodec = state.ActualOutputAudioCodec;
+                var videoCodec = state.ActualOutputVideoCodec;
+
+                _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo
+                {
+                    Bitrate = bitRate ?? state.TotalOutputBitrate,
+                    AudioCodec = audioCodec,
+                    VideoCodec = videoCodec,
+                    Container = state.OutputContainer,
+                    Framerate = framerate,
+                    CompletionPercentage = percentComplete,
+                    Width = state.OutputWidth,
+                    Height = state.OutputHeight,
+                    AudioChannels = state.OutputAudioChannels,
+                    IsAudioDirect = EncodingHelper.IsCopyCodec(state.OutputAudioCodec),
+                    IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec),
+                    TranscodeReasons = state.TranscodeReasons
+                });
+            }
+        }
+
+        /// <summary>
+        /// Starts the FFMPEG.
+        /// </summary>
+        /// <param name="state">The state.</param>
+        /// <param name="outputPath">The output path.</param>
+        /// <param name="commandLineArguments">The command line arguments for ffmpeg.</param>
+        /// <param name="request">The <see cref="HttpRequest"/>.</param>
+        /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
+        /// <param name="cancellationTokenSource">The cancellation token source.</param>
+        /// <param name="workingDirectory">The working directory.</param>
+        /// <returns>Task.</returns>
+        public async Task<TranscodingJobDto> StartFfMpeg(
+            StreamState state,
+            string outputPath,
+            string commandLineArguments,
+            HttpRequest request,
+            TranscodingJobType transcodingJobType,
+            CancellationTokenSource cancellationTokenSource,
+            string? workingDirectory = null)
+        {
+            Directory.CreateDirectory(Path.GetDirectoryName(outputPath));
+
+            await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false);
+
+            if (state.VideoRequest != null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+            {
+                var auth = _authorizationContext.GetAuthorizationInfo(request);
+                if (auth.User != null && !auth.User.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding))
+                {
+                    this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
+
+                    throw new ArgumentException("User does not have access to video transcoding");
+                }
+            }
+
+            var process = new Process()
+            {
+                StartInfo = new ProcessStartInfo()
+                {
+                    WindowStyle = ProcessWindowStyle.Hidden,
+                    CreateNoWindow = true,
+                    UseShellExecute = false,
+
+                    // Must consume both stdout and stderr or deadlocks may occur
+                    // RedirectStandardOutput = true,
+                    RedirectStandardError = true,
+                    RedirectStandardInput = true,
+                    FileName = _mediaEncoder.EncoderPath,
+                    Arguments = commandLineArguments,
+                    WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory,
+                    ErrorDialog = false
+                },
+                EnableRaisingEvents = true
+            };
+
+            var transcodingJob = this.OnTranscodeBeginning(
+                outputPath,
+                state.Request.PlaySessionId,
+                state.MediaSource.LiveStreamId,
+                Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
+                transcodingJobType,
+                process,
+                state.Request.DeviceId,
+                state,
+                cancellationTokenSource);
+
+            var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments;
+            _logger.LogInformation(commandLineLogMessage);
+
+            var logFilePrefix = "ffmpeg-transcode";
+            if (state.VideoRequest != null
+                && EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+            {
+                logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec)
+                    ? "ffmpeg-remux"
+                    : "ffmpeg-directstream";
+            }
+
+            var logFilePath = Path.Combine(_serverConfigurationManager.ApplicationPaths.LogDirectoryPath, logFilePrefix + "-" + Guid.NewGuid() + ".txt");
+
+            // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory.
+            Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true);
+
+            var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine);
+            await logStream.WriteAsync(commandLineLogMessageBytes, 0, commandLineLogMessageBytes.Length, cancellationTokenSource.Token).ConfigureAwait(false);
+
+            process.Exited += (sender, args) => OnFfMpegProcessExited(process, transcodingJob, state);
+
+            try
+            {
+                process.Start();
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error starting ffmpeg");
+
+                this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
+
+                throw;
+            }
+
+            _logger.LogDebug("Launched ffmpeg process");
+            state.TranscodingJob = transcodingJob;
+
+            // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback
+            _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream);
+
+            // Wait for the file to exist before proceeeding
+            var ffmpegTargetFile = state.WaitForPath ?? outputPath;
+            _logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile);
+            while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited)
+            {
+                await Task.Delay(100, cancellationTokenSource.Token).ConfigureAwait(false);
+            }
+
+            _logger.LogDebug("File {0} created or transcoding has finished", ffmpegTargetFile);
+
+            if (state.IsInputVideo && transcodingJob.Type == TranscodingJobType.Progressive && !transcodingJob.HasExited)
+            {
+                await Task.Delay(1000, cancellationTokenSource.Token).ConfigureAwait(false);
+
+                if (state.ReadInputAtNativeFramerate && !transcodingJob.HasExited)
+                {
+                    await Task.Delay(1500, cancellationTokenSource.Token).ConfigureAwait(false);
+                }
+            }
+
+            if (!transcodingJob.HasExited)
+            {
+                StartThrottler(state, transcodingJob);
+            }
+
+            _logger.LogDebug("StartFfMpeg() finished successfully");
+
+            return transcodingJob;
+        }
+
+        private void StartThrottler(StreamState state, TranscodingJobDto transcodingJob)
+        {
+            if (EnableThrottling(state))
+            {
+                transcodingJob.TranscodingThrottler = state.TranscodingThrottler = new TranscodingThrottler(transcodingJob, new Logger<TranscodingThrottler>(new LoggerFactory()), _serverConfigurationManager, _fileSystem);
+                state.TranscodingThrottler.Start();
+            }
+        }
+
+        private bool EnableThrottling(StreamState state)
+        {
+            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+
+            // enable throttling when NOT using hardware acceleration
+            if (string.IsNullOrEmpty(encodingOptions.HardwareAccelerationType))
+            {
+                return state.InputProtocol == MediaProtocol.File &&
+                       state.RunTimeTicks.HasValue &&
+                       state.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks &&
+                       state.IsInputVideo &&
+                       state.VideoType == VideoType.VideoFile &&
+                       !EncodingHelper.IsCopyCodec(state.OutputVideoCodec);
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Called when [transcode beginning].
+        /// </summary>
+        /// <param name="path">The path.</param>
+        /// <param name="playSessionId">The play session identifier.</param>
+        /// <param name="liveStreamId">The live stream identifier.</param>
+        /// <param name="transcodingJobId">The transcoding job identifier.</param>
+        /// <param name="type">The type.</param>
+        /// <param name="process">The process.</param>
+        /// <param name="deviceId">The device id.</param>
+        /// <param name="state">The state.</param>
+        /// <param name="cancellationTokenSource">The cancellation token source.</param>
+        /// <returns>TranscodingJob.</returns>
+        public TranscodingJobDto OnTranscodeBeginning(
+            string path,
+            string? playSessionId,
+            string? liveStreamId,
+            string transcodingJobId,
+            TranscodingJobType type,
+            Process process,
+            string? deviceId,
+            StreamState state,
+            CancellationTokenSource cancellationTokenSource)
+        {
+            lock (_activeTranscodingJobs)
+            {
+                var job = new TranscodingJobDto(_loggerFactory.CreateLogger<TranscodingJobDto>())
+                {
+                    Type = type,
+                    Path = path,
+                    Process = process,
+                    ActiveRequestCount = 1,
+                    DeviceId = deviceId,
+                    CancellationTokenSource = cancellationTokenSource,
+                    Id = transcodingJobId,
+                    PlaySessionId = playSessionId,
+                    LiveStreamId = liveStreamId,
+                    MediaSource = state.MediaSource
+                };
+
+                _activeTranscodingJobs.Add(job);
+
+                ReportTranscodingProgress(job, state, null, null, null, null, null);
+
+                return job;
+            }
+        }
+
+        /// <summary>
+        /// <summary>
+        /// The progressive
+        /// </summary>
+        /// Called when [transcode failed to start].
+        /// </summary>
+        /// <param name="path">The path.</param>
+        /// <param name="type">The type.</param>
+        /// <param name="state">The state.</param>
+        public void OnTranscodeFailedToStart(string path, TranscodingJobType type, StreamState state)
+        {
+            lock (_activeTranscodingJobs)
+            {
+                var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase));
+
+                if (job != null)
+                {
+                    _activeTranscodingJobs.Remove(job);
+                }
+            }
+
+            lock (_transcodingLocks)
+            {
+                _transcodingLocks.Remove(path);
+            }
+
+            if (!string.IsNullOrWhiteSpace(state.Request.DeviceId))
+            {
+                _sessionManager.ClearTranscodingInfo(state.Request.DeviceId);
+            }
+        }
+
+        /// <summary>
+        /// Processes the exited.
+        /// </summary>
+        /// <param name="process">The process.</param>
+        /// <param name="job">The job.</param>
+        /// <param name="state">The state.</param>
+        private void OnFfMpegProcessExited(Process process, TranscodingJobDto job, StreamState state)
+        {
+            if (job != null)
+            {
+                job.HasExited = true;
+            }
+
+            _logger.LogDebug("Disposing stream resources");
+            state.Dispose();
+
+            if (process.ExitCode == 0)
+            {
+                _logger.LogInformation("FFMpeg exited with code 0");
+            }
+            else
+            {
+                _logger.LogError("FFMpeg exited with code {0}", process.ExitCode);
+            }
+
+            process.Dispose();
+        }
+
+        private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource)
+        {
+            if (state.VideoType == VideoType.Iso && state.IsoType.HasValue && _isoManager.CanMount(state.MediaPath))
+            {
+                state.IsoMount = await _isoManager.Mount(state.MediaPath, cancellationTokenSource.Token).ConfigureAwait(false);
+            }
+
+            if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId))
+            {
+                var liveStreamResponse = await _mediaSourceManager.OpenLiveStream(
+                    new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken },
+                    cancellationTokenSource.Token)
+                    .ConfigureAwait(false);
+
+                _encodingHelper.AttachMediaSourceInfo(state, liveStreamResponse.MediaSource, state.RequestedUrl);
+
+                if (state.VideoRequest != null)
+                {
+                    _encodingHelper.TryStreamCopy(state);
+                }
+            }
+
+            if (state.MediaSource.BufferMs.HasValue)
+            {
+                await Task.Delay(state.MediaSource.BufferMs.Value, cancellationTokenSource.Token).ConfigureAwait(false);
+            }
+        }
+
+        /// <summary>
+        /// Called when [transcode begin request].
+        /// </summary>
+        /// <param name="path">The path.</param>
+        /// <param name="type">The type.</param>
+        /// <returns>The <see cref="TranscodingJobDto"/>.</returns>
+        public TranscodingJobDto? OnTranscodeBeginRequest(string path, TranscodingJobType type)
+        {
+            lock (_activeTranscodingJobs)
+            {
+                var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase));
+
+                if (job == null)
+                {
+                    return null;
+                }
+
+                OnTranscodeBeginRequest(job);
+
+                return job;
+            }
+        }
+
+        private void OnTranscodeBeginRequest(TranscodingJobDto job)
+        {
+            job.ActiveRequestCount++;
+
+            if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive)
+            {
+                job.StopKillTimer();
+            }
+        }
+
+        /// <summary>
+        /// Gets the transcoding lock.
+        /// </summary>
+        /// <param name="outputPath">The output path of the transcoded file.</param>
+        /// <returns>A <see cref="SemaphoreSlim"/>.</returns>
+        public SemaphoreSlim GetTranscodingLock(string outputPath)
+        {
+            lock (_transcodingLocks)
+            {
+                if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim result))
+                {
+                    result = new SemaphoreSlim(1, 1);
+                    _transcodingLocks[outputPath] = result;
+                }
+
+                return result;
+            }
+        }
+
+        /// <summary>
+        /// Deletes the encoded media cache.
+        /// </summary>
+        private void DeleteEncodedMediaCache()
+        {
+            var path = _serverConfigurationManager.GetTranscodePath();
+            if (!Directory.Exists(path))
+            {
+                return;
+            }
+
+            foreach (var file in _fileSystem.GetFilePaths(path, true))
+            {
+                _fileSystem.DeleteFile(file);
+            }
+        }
     }
 }

+ 201 - 0
Jellyfin.Api/Models/StreamingDtos/StreamState.cs

@@ -0,0 +1,201 @@
+using System;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.PlaybackDtos;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Dlna;
+
+namespace Jellyfin.Api.Models.StreamingDtos
+{
+    /// <summary>
+    /// The stream state dto.
+    /// </summary>
+    public class StreamState : EncodingJobInfo, IDisposable
+    {
+        private readonly IMediaSourceManager _mediaSourceManager;
+        private readonly TranscodingJobHelper _transcodingJobHelper;
+        private bool _disposed;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="StreamState" /> class.
+        /// </summary>
+        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager" /> interface.</param>
+        /// <param name="transcodingType">The <see cref="TranscodingJobType" />.</param>
+        /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper" /> singleton.</param>
+        public StreamState(IMediaSourceManager mediaSourceManager, TranscodingJobType transcodingType, TranscodingJobHelper transcodingJobHelper)
+            : base(transcodingType)
+        {
+            _mediaSourceManager = mediaSourceManager;
+            _transcodingJobHelper = transcodingJobHelper;
+        }
+
+        /// <summary>
+        /// Gets or sets the requested url.
+        /// </summary>
+        public string? RequestedUrl { get; set; }
+
+        /// <summary>
+        /// Gets or sets the request.
+        /// </summary>
+        public StreamingRequestDto Request
+        {
+            get => (StreamingRequestDto)BaseRequest;
+            set
+            {
+                BaseRequest = value;
+                IsVideoRequest = VideoRequest != null;
+            }
+        }
+
+        /// <summary>
+        /// Gets or sets the transcoding throttler.
+        /// </summary>
+        public TranscodingThrottler? TranscodingThrottler { get; set; }
+
+        /// <summary>
+        /// Gets the video request.
+        /// </summary>
+        public VideoRequestDto? VideoRequest => Request! as VideoRequestDto;
+
+        /// <summary>
+        /// Gets or sets the direct stream provicer.
+        /// </summary>
+        public IDirectStreamProvider? DirectStreamProvider { get; set; }
+
+        /// <summary>
+        /// Gets or sets the path to wait for.
+        /// </summary>
+        public string? WaitForPath { get; set; }
+
+        /// <summary>
+        /// Gets a value indicating whether the request outputs video.
+        /// </summary>
+        public bool IsOutputVideo => Request is VideoRequestDto;
+
+        /// <summary>
+        /// Gets the segment length.
+        /// </summary>
+        public int SegmentLength
+        {
+            get
+            {
+                if (Request.SegmentLength.HasValue)
+                {
+                    return Request.SegmentLength.Value;
+                }
+
+                if (EncodingHelper.IsCopyCodec(OutputVideoCodec))
+                {
+                    var userAgent = UserAgent ?? string.Empty;
+
+                    if (userAgent.IndexOf("AppleTV", StringComparison.OrdinalIgnoreCase) != -1
+                        || userAgent.IndexOf("cfnetwork", StringComparison.OrdinalIgnoreCase) != -1
+                        || userAgent.IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1
+                        || userAgent.IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1
+                        || userAgent.IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1)
+                    {
+                        return 6;
+                    }
+
+                    if (IsSegmentedLiveStream)
+                    {
+                        return 3;
+                    }
+
+                    return 6;
+                }
+
+                return 3;
+            }
+        }
+
+        /// <summary>
+        /// Gets the minimum number of segments.
+        /// </summary>
+        public int MinSegments
+        {
+            get
+            {
+                if (Request.MinSegments.HasValue)
+                {
+                    return Request.MinSegments.Value;
+                }
+
+                return SegmentLength >= 10 ? 2 : 3;
+            }
+        }
+
+        /// <summary>
+        /// Gets or sets the user agent.
+        /// </summary>
+        public string? UserAgent { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether to estimate the content length.
+        /// </summary>
+        public bool EstimateContentLength { get; set; }
+
+        /// <summary>
+        /// Gets or sets the transcode seek info.
+        /// </summary>
+        public TranscodeSeekInfo TranscodeSeekInfo { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether to enable dlna headers.
+        /// </summary>
+        public bool EnableDlnaHeaders { get; set; }
+
+        /// <summary>
+        /// Gets or sets the device profile.
+        /// </summary>
+        public DeviceProfile? DeviceProfile { get; set; }
+
+        /// <summary>
+        /// Gets or sets the transcoding job.
+        /// </summary>
+        public TranscodingJobDto? TranscodingJob { get; set; }
+
+        /// <inheritdoc />
+        public void Dispose()
+        {
+            Dispose(true);
+            GC.SuppressFinalize(this);
+        }
+
+        /// <inheritdoc />
+        public override void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate)
+        {
+            _transcodingJobHelper.ReportTranscodingProgress(TranscodingJob!, this, transcodingPosition, framerate, percentComplete, bytesTranscoded, bitRate);
+        }
+
+        /// <summary>
+        /// Disposes the stream state.
+        /// </summary>
+        /// <param name="disposing">Whether the object is currently beeing disposed.</param>
+        protected virtual void Dispose(bool disposing)
+        {
+            if (_disposed)
+            {
+                return;
+            }
+
+            if (disposing)
+            {
+                // REVIEW: Is this the right place for this?
+                if (MediaSource.RequiresClosing
+                    && string.IsNullOrWhiteSpace(Request.LiveStreamId)
+                    && !string.IsNullOrWhiteSpace(MediaSource.LiveStreamId))
+                {
+                    _mediaSourceManager.CloseLiveStream(MediaSource.LiveStreamId).GetAwaiter().GetResult();
+                }
+
+                TranscodingThrottler?.Dispose();
+            }
+
+            TranscodingThrottler = null;
+            TranscodingJob = null;
+
+            _disposed = true;
+        }
+    }
+}

+ 45 - 0
Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs

@@ -0,0 +1,45 @@
+using MediaBrowser.Controller.MediaEncoding;
+
+namespace Jellyfin.Api.Models.StreamingDtos
+{
+    /// <summary>
+    /// The audio streaming request dto.
+    /// </summary>
+    public class StreamingRequestDto : BaseEncodingJobOptions
+    {
+        /// <summary>
+        /// Gets or sets the device profile.
+        /// </summary>
+        public string? DeviceProfileId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the params.
+        /// </summary>
+        public string? Params { get; set; }
+
+        /// <summary>
+        /// Gets or sets the play session id.
+        /// </summary>
+        public string? PlaySessionId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the tag.
+        /// </summary>
+        public string? Tag { get; set; }
+
+        /// <summary>
+        /// Gets or sets the segment container.
+        /// </summary>
+        public string? SegmentContainer { get; set; }
+
+        /// <summary>
+        /// Gets or sets the segment length.
+        /// </summary>
+        public int? SegmentLength { get; set; }
+
+        /// <summary>
+        /// Gets or sets the min segments.
+        /// </summary>
+        public int? MinSegments { get; set; }
+    }
+}

+ 19 - 0
Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs

@@ -0,0 +1,19 @@
+namespace Jellyfin.Api.Models.StreamingDtos
+{
+    /// <summary>
+    /// The video request dto.
+    /// </summary>
+    public class VideoRequestDto : StreamingRequestDto
+    {
+        /// <summary>
+        /// Gets a value indicating whether this instance has fixed resolution.
+        /// </summary>
+        /// <value><c>true</c> if this instance has fixed resolution; otherwise, <c>false</c>.</value>
+        public bool HasFixedResolution => Width.HasValue || Height.HasValue;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether to enable subtitles in the manifest.
+        /// </summary>
+        public bool EnableSubtitlesInManifest { get; set; }
+    }
+}

+ 0 - 4
MediaBrowser.Api/Playback/Progressive/AudioService.cs

@@ -17,10 +17,6 @@ namespace MediaBrowser.Api.Playback.Progressive
     /// <summary>
     /// Class GetAudioStream.
     /// </summary>
-    [Route("/Audio/{Id}/stream.{Container}", "GET", Summary = "Gets an audio stream")]
-    [Route("/Audio/{Id}/stream", "GET", Summary = "Gets an audio stream")]
-    [Route("/Audio/{Id}/stream.{Container}", "HEAD", Summary = "Gets an audio stream")]
-    [Route("/Audio/{Id}/stream", "HEAD", Summary = "Gets an audio stream")]
     public class GetAudioStream : StreamRequest
     {
     }

+ 11 - 0
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs

@@ -1323,6 +1323,17 @@ namespace MediaBrowser.Controller.MediaEncoding
             return null;
         }
 
+        public int? GetAudioBitrateParam(int? audioBitRate, MediaStream audioStream)
+        {
+            if (audioBitRate.HasValue)
+            {
+                // Don't encode any higher than this
+                return Math.Min(384000, audioBitRate.Value);
+            }
+
+            return null;
+        }
+
         public string GetAudioFilterParam(EncodingJobInfo state, EncodingOptions encodingOptions, bool isHls)
         {
             var channels = state.OutputAudioChannels;