Explorar o código

Continute work

David %!s(int64=5) %!d(string=hai) anos
pai
achega
3514813eb4

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

@@ -46,6 +46,7 @@ using Emby.Server.Implementations.Session;
 using Emby.Server.Implementations.TV;
 using Emby.Server.Implementations.Updates;
 using Emby.Server.Implementations.SyncPlay;
+using Jellyfin.Api.Helpers;
 using MediaBrowser.Api;
 using MediaBrowser.Common;
 using MediaBrowser.Common.Configuration;
@@ -637,6 +638,8 @@ namespace Emby.Server.Implementations
             serviceCollection.AddSingleton<EncodingHelper>();
 
             serviceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>();
+
+            serviceCollection.AddSingleton<TranscodingJobHelper>();
         }
 
         /// <summary>

+ 266 - 84
Jellyfin.Api/Controllers/AudioController.cs

@@ -3,97 +3,277 @@ using System.Collections.Generic;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Api.Helpers;
+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.Logging;
-using Microsoft.Net.Http.Headers;
+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 ILogger _logger;
+        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 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="logger">Instance of the <see cref="ILogger{AuidoController}"/> interface.</param>
-        public AudioController(IDlnaManager dlnaManager, ILogger<AudioController> logger)
+        /// <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>
+        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)
         {
             _dlnaManager = dlnaManager;
-            _logger = logger;
+            _authContext = authorizationContext;
+            _userManager = userManger;
+            _libraryManager = libraryManager;
+            _mediaSourceManager = mediaSourceManager;
+            _serverConfigurationManager = serverConfigurationManager;
+            _mediaEncoder = mediaEncoder;
+            _streamHelper = streamHelper;
+            _fileSystem = fileSystem;
+            _subtitleEncoder = subtitleEncoder;
+            _configuration = configuration;
+            _deviceManager = deviceManager;
+            _transcodingJobHelper = transcodingJobHelper;
         }
 
-        [HttpGet("{id}/stream.{container}")]
-        [HttpGet("{id}/stream")]
-        [HttpHead("{id}/stream.{container}")]
-        [HttpGet("{id}/stream")]
+        /// <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.{container}")]
+        [HttpGet("{itemId}/stream")]
+        [HttpHead("{itemId}/stream.{container}")]
+        [HttpGet("{itemId}/stream")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
         public async Task<ActionResult> GetAudioStream(
-            [FromRoute] string id,
-            [FromRoute] string container,
-            [FromQuery] bool Static,
-            [FromQuery] string tag)
+            [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();
 
-            var state = await GetState(request, cancellationTokenSource.Token).ConfigureAwait(false);
+            var state = await StreamingHelpers.GetStreamingState(
+                    itemId,
+                    startTimeTicks,
+                    audioCodec,
+                    subtitleCodec,
+                    videoCodec,
+                    @params,
+                    @static,
+                    container,
+                    liveStreamId,
+                    playSessionId,
+                    mediaSourceId,
+                    deviceId,
+                    deviceProfileId,
+                    audioBitRate,
+                    Request,
+                    _authContext,
+                    _mediaSourceManager,
+                    _userManager,
+                    _libraryManager,
+                    _serverConfigurationManager,
+                    _mediaEncoder,
+                    _fileSystem,
+                    _subtitleEncoder,
+                    _configuration,
+                    _dlnaManager,
+                    _deviceManager,
+                    _transcodingJobHelper,
+                    _transcodingJobType,
+                    false,
+                    cancellationTokenSource.Token)
+                .ConfigureAwait(false);
 
-            if (Static && state.DirectStreamProvider != null)
+            if (@static.HasValue && @static.Value && state.DirectStreamProvider != null)
             {
-                StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, Request, _dlnaManager);
+                StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager);
 
                 using (state)
                 {
-                    var outputHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
-
-                    // TODO: Don't hardcode this
-                    outputHeaders[HeaderNames.ContentType] = MimeTypes.GetMimeType("file.ts");
+                    // TODO AllowEndOfFile = false
+                    await new ProgressiveFileCopier(_streamHelper, state.DirectStreamProvider).WriteToAsync(Response.Body, CancellationToken.None).ConfigureAwait(false);
 
-                    return new ProgressiveFileCopier(state.DirectStreamProvider, outputHeaders, null, _logger, CancellationToken.None)
-                    {
-                        AllowEndOfFile = false
-                    };
+                    // TODO (moved from MediaBrowser.Api): Don't hardcode contentType
+                    return File(Response.Body, MimeTypes.GetMimeType("file.ts")!);
                 }
             }
 
             // Static remote stream
-            if (Static && state.InputProtocol == MediaProtocol.Http)
+            if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http)
             {
-                StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, Request, _dlnaManager);
+                StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager);
 
                 using (state)
                 {
-                    return await GetStaticRemoteStreamResult(state, responseHeaders, isHeadRequest, cancellationTokenSource).ConfigureAwait(false);
+                    return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, isHeadRequest, this, cancellationTokenSource).ConfigureAwait(false);
                 }
             }
 
-            if (Static && state.InputProtocol != MediaProtocol.File)
+            if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File)
             {
-                throw new ArgumentException(string.Format($"Input protocol {state.InputProtocol} cannot be streamed statically."));
+                return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically");
             }
 
             var outputPath = state.OutputFilePath;
-            var outputPathExists = File.Exists(outputPath);
+            var outputPathExists = System.IO.File.Exists(outputPath);
 
-            var transcodingJob = TranscodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive);
+            var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive);
             var isTranscodeCached = outputPathExists && transcodingJob != null;
 
-            StreamingHelpers.AddDlnaHeaders(state, Response.Headers, Static || isTranscodeCached, Request, _dlnaManager);
+            StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, startTimeTicks, Request, _dlnaManager);
 
             // Static stream
-            if (Static)
+            if (@static.HasValue && @static.Value)
             {
                 var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath);
 
@@ -101,16 +281,10 @@ namespace Jellyfin.Api.Controllers
                 {
                     if (state.MediaSource.IsInfiniteStream)
                     {
-                        var outputHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
-                        {
-                            [HeaderNames.ContentType] = contentType
-                        };
-
+                        // TODO AllowEndOfFile = false
+                        await new ProgressiveFileCopier(_streamHelper, state.MediaPath).WriteToAsync(Response.Body, CancellationToken.None).ConfigureAwait(false);
 
-                        return new ProgressiveFileCopier(FileSystem, state.MediaPath, outputHeaders, null, _logger, CancellationToken.None)
-                        {
-                            AllowEndOfFile = false
-                        };
+                        return File(Response.Body, contentType);
                     }
 
                     TimeSpan? cacheDuration = null;
@@ -120,57 +294,65 @@ namespace Jellyfin.Api.Controllers
                         cacheDuration = TimeSpan.FromDays(365);
                     }
 
+                    return FileStreamResponseHelpers.GetStaticFileResult(
+                        state.MediaPath,
+                        contentType,
+                        _fileSystem.GetLastWriteTimeUtc(state.MediaPath),
+                        cacheDuration,
+                        isHeadRequest,
+                        this);
+                }
+            }
+
+            /*
+            // Not static but transcode cache file exists
+            if (isTranscodeCached && state.VideoRequest == null)
+            {
+                var contentType = state.GetMimeType(outputPath)
+                try
+                {
+                    if (transcodingJob != null)
+                    {
+                        ApiEntryPoint.Instance.OnTranscodeBeginRequest(transcodingJob);
+                    }
                     return await ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions
                     {
                         ResponseHeaders = responseHeaders,
                         ContentType = contentType,
                         IsHeadRequest = isHeadRequest,
-                        Path = state.MediaPath,
-                        CacheDuration = cacheDuration
-
+                        Path = outputPath,
+                        FileShare = FileShare.ReadWrite,
+                        OnComplete = () =>
+                        {
+                            if (transcodingJob != null)
+                            {
+                                ApiEntryPoint.Instance.OnTranscodeEndRequest(transcodingJob);
+                            }
                     }).ConfigureAwait(false);
                 }
+                finally
+                {
+                    state.Dispose();
+                }
             }
+            */
 
-            //// Not static but transcode cache file exists
-            //if (isTranscodeCached && state.VideoRequest == null)
-            //{
-            //    var contentType = state.GetMimeType(outputPath);
-
-            //    try
-            //    {
-            //        if (transcodingJob != null)
-            //        {
-            //            ApiEntryPoint.Instance.OnTranscodeBeginRequest(transcodingJob);
-            //        }
-
-            //        return await ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions
-            //        {
-            //            ResponseHeaders = responseHeaders,
-            //            ContentType = contentType,
-            //            IsHeadRequest = isHeadRequest,
-            //            Path = outputPath,
-            //            FileShare = FileShare.ReadWrite,
-            //            OnComplete = () =>
-            //            {
-            //                if (transcodingJob != null)
-            //                {
-            //                    ApiEntryPoint.Instance.OnTranscodeEndRequest(transcodingJob);
-            //                }
-            //            }
-
-            //        }).ConfigureAwait(false);
-            //    }
-            //    finally
-            //    {
-            //        state.Dispose();
-            //    }
-            //}
-
-            // Need to start ffmpeg
+            // Need to start ffmpeg (because media can't be returned directly)
             try
             {
-                return await GetStreamResult(request, state, responseHeaders, isHeadRequest, cancellationTokenSource).ConfigureAwait(false);
+                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);
             }
             catch
             {

+ 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>

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

@@ -0,0 +1,236 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+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.Extensions.Primitives;
+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="cancellationTokenSource">The <see cref="CancellationTokenSource"/>.</param>
+        /// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns>
+        public static async Task<ActionResult> GetStaticRemoteStreamResult(
+            StreamState state,
+            bool isHeadRequest,
+            ControllerBase controller,
+            CancellationTokenSource cancellationTokenSource)
+        {
+            HttpClient httpClient = new HttpClient();
+            var responseHeaders = controller.Response.Headers;
+
+            if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent))
+            {
+                httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent);
+            }
+
+            var response = await httpClient.GetAsync(state.MediaPath).ConfigureAwait(false);
+            var contentType = response.Content.Headers.ContentType.ToString();
+
+            responseHeaders[HeaderNames.AcceptRanges] = "none";
+
+            // Seeing cases of -1 here
+            if (response.Content.Headers.ContentLength.HasValue && response.Content.Headers.ContentLength.Value >= 0)
+            {
+                responseHeaders[HeaderNames.ContentLength] = response.Content.Headers.ContentLength.Value.ToString(CultureInfo.InvariantCulture);
+            }
+
+            if (isHeadRequest)
+            {
+                using (response)
+                {
+                    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="dateLastModified">The <see cref="DateTime"/> of the last modification of the file.</param>
+        /// <param name="cacheDuration">The cache duration 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>
+        // TODO: caching doesn't work
+        public static ActionResult GetStaticFileResult(
+            string path,
+            string contentType,
+            DateTime dateLastModified,
+            TimeSpan? cacheDuration,
+            bool isHeadRequest,
+            ControllerBase controller)
+        {
+            bool disableCaching = false;
+            if (controller.Request.Headers.TryGetValue(HeaderNames.CacheControl, out StringValues headerValue))
+            {
+                disableCaching = headerValue.FirstOrDefault().Contains("no-cache", StringComparison.InvariantCulture);
+            }
+
+            bool parsingSuccessful = DateTime.TryParseExact(controller.Request.Headers[HeaderNames.IfModifiedSince], "ddd, dd MMM yyyy HH:mm:ss \"GMT\"", new CultureInfo("en-US", false), DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime ifModifiedSinceHeader);
+
+            // if the parsing of the IfModifiedSince header was not successfull, disable caching
+            if (!parsingSuccessful)
+            {
+                disableCaching = true;
+            }
+
+            controller.Response.ContentType = contentType;
+            controller.Response.Headers.Add(HeaderNames.Age, Convert.ToInt64((DateTime.UtcNow - dateLastModified).TotalSeconds).ToString(CultureInfo.InvariantCulture));
+            controller.Response.Headers.Add(HeaderNames.Vary, HeaderNames.Accept);
+
+            if (disableCaching)
+            {
+                controller.Response.Headers.Add(HeaderNames.CacheControl, "no-cache, no-store, must-revalidate");
+                controller.Response.Headers.Add(HeaderNames.Pragma, "no-cache, no-store, must-revalidate");
+            }
+            else
+            {
+                if (cacheDuration.HasValue)
+                {
+                    controller.Response.Headers.Add(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds);
+                }
+                else
+                {
+                    controller.Response.Headers.Add(HeaderNames.CacheControl, "public");
+                }
+
+                controller.Response.Headers.Add(HeaderNames.LastModified, dateLastModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", new CultureInfo("en-US", false)));
+
+                // if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified
+                if (!(dateLastModified > ifModifiedSinceHeader))
+                {
+                    if (ifModifiedSinceHeader.Add(cacheDuration!.Value) < DateTime.UtcNow)
+                    {
+                        controller.Response.StatusCode = StatusCodes.Status304NotModified;
+                        return new ContentResult();
+                    }
+                }
+            }
+
+            // 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();
+            }
+
+            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)
+        {
+            IHeaderDictionary responseHeaders = controller.Response.Headers;
+            // Use the command line args with a dummy playlist path
+            var outputPath = state.OutputFilePath;
+
+            responseHeaders[HeaderNames.AcceptRanges] = "none";
+
+            var contentType = state.GetMimeType(outputPath);
+
+            // TODO: The isHeadRequest is only here because ServiceStack will add Content-Length=0 to the response
+            // TODO (from api-migration): Investigate if this is still neccessary as we migrated away from ServiceStack
+            var contentLength = state.EstimateContentLength || isHeadRequest ? GetEstimatedContentLength(state) : null;
+
+            if (contentLength.HasValue)
+            {
+                responseHeaders[HeaderNames.ContentLength] = contentLength.Value.ToString(CultureInfo.InvariantCulture);
+            }
+            else
+            {
+                responseHeaders.Remove(HeaderNames.ContentLength);
+            }
+
+            // 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();
+                }
+
+                Stream stream = new MemoryStream();
+
+                await new ProgressiveFileCopier(streamHelper, outputPath).WriteToAsync(stream, CancellationToken.None).ConfigureAwait(false);
+                return controller.File(stream, contentType);
+            }
+            finally
+            {
+                transcodingLock.Release();
+            }
+        }
+
+        /// <summary>
+        /// Gets the length of the estimated content.
+        /// </summary>
+        /// <param name="state">The state.</param>
+        /// <returns>System.Nullable{System.Int64}.</returns>
+        private static long? GetEstimatedContentLength(StreamState state)
+        {
+            var totalBitrate = state.TotalOutputBitrate ?? 0;
+
+            if (totalBitrate > 0 && state.RunTimeTicks.HasValue)
+            {
+                return Convert.ToInt64(totalBitrate * TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalSeconds / 8);
+            }
+
+            return null;
+        }
+    }
+}

+ 633 - 48
Jellyfin.Api/Helpers/StreamingHelpers.cs

@@ -1,32 +1,255 @@
 using System;
 using System.Collections.Generic;
 using System.Globalization;
+using System.IO;
 using System.Linq;
-using Jellyfin.Api.Models;
+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
+    /// The streaming helpers.
     /// </summary>
-    public class StreamingHelpers
+    public static class StreamingHelpers
     {
+        public static async Task<StreamState> GetStreamingState(
+            Guid itemId,
+            long? startTimeTicks,
+            string? audioCodec,
+            string? subtitleCodec,
+            string? videoCodec,
+            string? @params,
+            bool? @static,
+            string? container,
+            string? liveStreamId,
+            string? playSessionId,
+            string? mediaSourceId,
+            string? deviceId,
+            string? deviceProfileId,
+            int? audioBitRate,
+            HttpRequest request,
+            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,
+            bool isVideoRequest,
+            CancellationToken cancellationToken)
+        {
+            EncodingHelper encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration);
+            // Parse the DLNA time seek header
+            if (!startTimeTicks.HasValue)
+            {
+                var timeSeek = request.Headers["TimeSeekRange.dlna.org"];
+
+                startTimeTicks = ParseTimeSeekHeader(timeSeek);
+            }
+
+            if (!string.IsNullOrWhiteSpace(@params))
+            {
+                // What is this?
+                ParseParams(request);
+            }
+
+            var streamOptions = ParseStreamOptions(request.Query);
+
+            var url = request.Path.Value.Split('.').Last();
+
+            if (string.IsNullOrEmpty(audioCodec))
+            {
+                audioCodec = encodingHelper.InferAudioCodec(url);
+            }
+
+            var enableDlnaHeaders = !string.IsNullOrWhiteSpace(@params) ||
+                                    string.Equals(request.Headers["GetContentFeatures.DLNA.ORG"], "1", StringComparison.OrdinalIgnoreCase);
+
+            var state = new StreamState(mediaSourceManager, transcodingJobType, transcodingJobHelper)
+            {
+                // TODO request was the StreamingRequest living in MediaBrowser.Api.Playback.Progressive
+                Request = request,
+                RequestedUrl = url,
+                UserAgent = request.Headers[HeaderNames.UserAgent],
+                EnableDlnaHeaders = enableDlnaHeaders
+            };
+
+            var auth = authorizationContext.GetAuthorizationInfo(request);
+            if (!auth.UserId.Equals(Guid.Empty))
+            {
+                state.User = userManager.GetUserById(auth.UserId);
+            }
+
+            /*
+            if ((Request.UserAgent ?? string.Empty).IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1 ||
+                (Request.UserAgent ?? string.Empty).IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1 ||
+                (Request.UserAgent ?? string.Empty).IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1)
+            {
+                state.SegmentLength = 6;
+            }
+            */
+
+            if (state.VideoRequest != null && !string.IsNullOrWhiteSpace(state.VideoRequest.VideoCodec))
+            {
+                state.SupportedVideoCodecs = state.VideoRequest.VideoCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToArray();
+                state.VideoRequest.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault();
+            }
+
+            if (!string.IsNullOrWhiteSpace(audioCodec))
+            {
+                state.SupportedAudioCodecs = audioCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToArray();
+                state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => mediaEncoder.CanEncodeToAudioCodec(i))
+                                           ?? state.SupportedAudioCodecs.FirstOrDefault();
+            }
+
+            if (!string.IsNullOrWhiteSpace(subtitleCodec))
+            {
+                state.SupportedSubtitleCodecs = subtitleCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToArray();
+                state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(i => mediaEncoder.CanEncodeToSubtitleCodec(i))
+                                              ?? state.SupportedSubtitleCodecs.FirstOrDefault();
+            }
+
+            var item = libraryManager.GetItemById(itemId);
+
+            state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase);
+
+            /*
+            var primaryImage = item.GetImageInfo(ImageType.Primary, 0) ??
+                         item.Parents.Select(i => i.GetImageInfo(ImageType.Primary, 0)).FirstOrDefault(i => i != null);
+            if (primaryImage != null)
+            {
+                state.AlbumCoverPath = primaryImage.Path;
+            }
+            */
+
+            MediaSourceInfo? mediaSource = null;
+            if (string.IsNullOrWhiteSpace(liveStreamId))
+            {
+                var currentJob = !string.IsNullOrWhiteSpace(playSessionId)
+                    ? transcodingJobHelper.GetTranscodingJob(playSessionId)
+                    : null;
+
+                if (currentJob != null)
+                {
+                    mediaSource = currentJob.MediaSource;
+                }
+
+                if (mediaSource == null)
+                {
+                    var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(itemId), null, false, false, cancellationToken).ConfigureAwait(false);
+
+                    mediaSource = string.IsNullOrEmpty(mediaSourceId)
+                        ? mediaSources[0]
+                        : mediaSources.Find(i => string.Equals(i.Id, mediaSourceId, StringComparison.InvariantCulture));
+
+                    if (mediaSource == null && Guid.Parse(mediaSourceId) == itemId)
+                    {
+                        mediaSource = mediaSources[0];
+                    }
+                }
+            }
+            else
+            {
+                var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(liveStreamId, cancellationToken).ConfigureAwait(false);
+                mediaSource = liveStreamInfo.Item1;
+                state.DirectStreamProvider = liveStreamInfo.Item2;
+            }
+
+            encodingHelper.AttachMediaSourceInfo(state, mediaSource, url);
+
+            var containerInternal = Path.GetExtension(state.RequestedUrl);
+
+            if (string.IsNullOrEmpty(container))
+            {
+                containerInternal = container;
+            }
+
+            if (string.IsNullOrEmpty(containerInternal))
+            {
+                containerInternal = (@static.HasValue && @static.Value) ? StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, state.MediaPath, null, DlnaProfileType.Audio) : GetOutputFileExtension(state);
+            }
+
+            state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.');
+
+            state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(audioBitRate, state.AudioStream);
+
+            state.OutputAudioCodec = audioCodec;
+
+            state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec);
+
+            if (isVideoRequest)
+            {
+                state.OutputVideoCodec = state.VideoRequest.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,
+                        videoRequest.MaxWidth,
+                        videoRequest.MaxHeight);
+
+                    videoRequest.MaxWidth = resolution.MaxWidth;
+                    videoRequest.MaxHeight = resolution.MaxHeight;
+                }
+            }
+
+            ApplyDeviceProfileSettings(state, dlnaManager, deviceManager, request, deviceProfileId, @static);
+
+            var ext = string.IsNullOrWhiteSpace(state.OutputContainer)
+                ? GetOutputFileExtension(state)
+                : ('.' + state.OutputContainer);
+
+            state.OutputFilePath = GetOutputFilePath(state, ext!, serverConfigurationManager, deviceId, 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)
         {
@@ -54,7 +277,7 @@ namespace Jellyfin.Api.Helpers
 
                 if (!isStaticallyStreamed && profile != null)
                 {
-                    AddTimeSeekResponseHeaders(state, responseHeaders);
+                    AddTimeSeekResponseHeaders(state, responseHeaders, startTimeTicks);
                 }
             }
 
@@ -82,51 +305,18 @@ namespace Jellyfin.Api.Helpers
             {
                 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 dlna headers.
-        /// </summary>
-        /// <param name="startTimeTicks">The start time ticks.</param>
-        /// <param name="request">The <see cref="HttpRequest"/>.</param>
-        public void ParseDlnaHeaders(long? startTimeTicks, HttpRequest request)
-        {
-            if (!startTimeTicks.HasValue)
-            {
-                var timeSeek = request.Headers["TimeSeekRange.dlna.org"];
-
-                startTimeTicks = ParseTimeSeekHeader(timeSeek);
+                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>
-        public long? ParseTimeSeekHeader(string value)
+        /// <param name="value">The time seek header string.</param>
+        /// <returns>A nullable <see cref="long"/> representing the seek time in ticks.</returns>
+        public static long? ParseTimeSeekHeader(string value)
         {
             if (string.IsNullOrWhiteSpace(value))
             {
@@ -138,12 +328,13 @@ namespace Jellyfin.Api.Helpers
             {
                 throw new ArgumentException("Invalid timeseek header");
             }
-            int index = value.IndexOf('-');
+
+            int index = value.IndexOf('-', StringComparison.InvariantCulture);
             value = index == -1
                 ? value.Substring(Npt.Length)
                 : value.Substring(Npt.Length, index - Npt.Length);
 
-            if (value.IndexOf(':') == -1)
+            if (value.IndexOf(':', StringComparison.InvariantCulture) == -1)
             {
                 // Parses npt times in the format of '417.33'
                 if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds))
@@ -169,15 +360,45 @@ namespace Jellyfin.Api.Helpers
                 {
                     throw new ArgumentException("Invalid timeseek header");
                 }
+
                 timeFactor /= 60;
             }
+
             return TimeSpan.FromSeconds(secondsSum).Ticks;
         }
 
-        public void AddTimeSeekResponseHeaders(StreamState state, IHeaderDictionary responseHeaders)
+        /// <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>
+        public static Dictionary<string, string> ParseStreamOptions(IQueryCollection queryString)
         {
-            var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture);
-            var startSeconds = TimeSpan.FromTicks(state.Request.StartTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture);
+            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>
+        public 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,
@@ -190,5 +411,369 @@ namespace Jellyfin.Api.Helpers
                 startSeconds,
                 runtimeSeconds));
         }
+
+        /// <summary>
+        /// Gets the output file extension.
+        /// </summary>
+        /// <param name="state">The state.</param>
+        /// <returns>System.String.</returns>
+        public static string? GetOutputFileExtension(StreamState state)
+        {
+            var ext = Path.GetExtension(state.RequestedUrl);
+
+            if (!string.IsNullOrEmpty(ext))
+            {
+                return ext;
+            }
+
+            var isVideoRequest = state.VideoRequest != null;
+
+            // Try to infer based on the desired video codec
+            if (isVideoRequest)
+            {
+                var videoCodec = state.VideoRequest.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 (!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.VideoRequest == null
+                ? 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.VideoRequest == null ? 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 void ParseParams(StreamRequest request)
+        {
+            var vals = request.Params.Split(';');
+
+            var videoRequest = request as VideoStreamRequest;
+
+            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;
+                }
+            }
+        }
     }
 }

+ 442 - 11
Jellyfin.Api/Helpers/TranscodingJobHelper.cs

@@ -1,16 +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;
 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
@@ -30,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.
@@ -40,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>
@@ -63,7 +109,13 @@ namespace Jellyfin.Api.Helpers
             }
         }
 
-        public static TranscodingJobDto GetTranscodingJob(string path, TranscodingJobType type)
+        /// <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)
             {
@@ -361,14 +413,24 @@ namespace Jellyfin.Api.Helpers
             }
         }
 
+        /// <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(
-        TranscodingJob job,
-        StreamState state,
-        TimeSpan? transcodingPosition,
-        float? framerate,
-        double? percentComplete,
-        long? bytesTranscoded,
-        int? bitRate)
+            TranscodingJobDto job,
+            StreamState state,
+            TimeSpan? transcodingPosition,
+            float? framerate,
+            double? percentComplete,
+            long? bytesTranscoded,
+            int? bitRate)
         {
             var ticks = transcodingPosition?.Ticks;
 
@@ -405,5 +467,374 @@ namespace Jellyfin.Api.Helpers
                 });
             }
         }
+
+        /// <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);
+            }
+        }
     }
 }

+ 92 - 30
Jellyfin.Api/Models/StreamState.cs → Jellyfin.Api/Models/StreamingDtos/StreamState.cs

@@ -5,36 +5,77 @@ using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Model.Dlna;
 
-namespace Jellyfin.Api.Models
+namespace Jellyfin.Api.Models.StreamingDtos
 {
+    /// <summary>
+    /// The stream state dto.
+    /// </summary>
     public class StreamState : EncodingJobInfo, IDisposable
     {
         private readonly IMediaSourceManager _mediaSourceManager;
-        private bool _disposed = false;
-
-        public string RequestedUrl { get; set; }
-
-        public StreamRequest Request
+        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="mediaSourceManager" /> 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)
         {
-            get => (StreamRequest)BaseRequest;
-            set
-            {
-                BaseRequest = value;
-
-                IsVideoRequest = VideoRequest != null;
-            }
+            _mediaSourceManager = mediaSourceManager;
+            _transcodingJobHelper = transcodingJobHelper;
         }
 
-        public TranscodingThrottler TranscodingThrottler { get; set; }
-
+        /// <summary>
+        /// Gets or sets the requested url.
+        /// </summary>
+        public string? RequestedUrl { get; set; }
+
+        // /// <summary>
+        // /// Gets or sets the request.
+        // /// </summary>
+        // public StreamRequest Request
+        // {
+        //     get => (StreamRequest)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 VideoStreamRequest VideoRequest => Request as VideoStreamRequest;
 
-        public IDirectStreamProvider DirectStreamProvider { get; set; }
+        /// <summary>
+        /// Gets or sets the direct stream provicer.
+        /// </summary>
+        public IDirectStreamProvider? DirectStreamProvider { get; set; }
 
-        public string WaitForPath { 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 VideoStreamRequest;
 
+        /// <summary>
+        /// Gets the segment length.
+        /// </summary>
         public int SegmentLength
         {
             get
@@ -74,6 +115,9 @@ namespace Jellyfin.Api.Models
             }
         }
 
+        /// <summary>
+        /// Gets the minimum number of segments.
+        /// </summary>
         public int MinSegments
         {
             get
@@ -87,35 +131,53 @@ namespace Jellyfin.Api.Models
             }
         }
 
-        public string UserAgent { get; set; }
+        /// <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; }
 
-        public DeviceProfile DeviceProfile { get; set; }
+        /// <summary>
+        /// Gets or sets the device profile.
+        /// </summary>
+        public DeviceProfile? DeviceProfile { get; set; }
 
-        public TranscodingJobDto TranscodingJob { get; set; }
+        /// <summary>
+        /// Gets or sets the transcoding job.
+        /// </summary>
+        public TranscodingJobDto? TranscodingJob { get; set; }
 
-        public StreamState(IMediaSourceManager mediaSourceManager, TranscodingJobType transcodingType)
-            : base(transcodingType)
+        /// <inheritdoc />
+        public void Dispose()
         {
-            _mediaSourceManager = mediaSourceManager;
+            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);
-        }
-
-        public void Dispose()
-        {
-            Dispose(true);
-            GC.SuppressFinalize(this);
+            _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)

+ 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

@@ -1263,6 +1263,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;