aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Api/Helpers
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Api/Helpers')
-rw-r--r--Jellyfin.Api/Helpers/AudioHelper.cs196
-rw-r--r--Jellyfin.Api/Helpers/ClaimHelpers.cs75
-rw-r--r--Jellyfin.Api/Helpers/DynamicHlsHelper.cs550
-rw-r--r--Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs136
-rw-r--r--Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs125
-rw-r--r--Jellyfin.Api/Helpers/HlsHelpers.cs95
-rw-r--r--Jellyfin.Api/Helpers/MediaInfoHelper.cs578
-rw-r--r--Jellyfin.Api/Helpers/ProgressiveFileCopier.cs182
-rw-r--r--Jellyfin.Api/Helpers/RequestHelpers.cs165
-rw-r--r--Jellyfin.Api/Helpers/SimilarItemsHelper.cs182
-rw-r--r--Jellyfin.Api/Helpers/StreamingHelpers.cs758
-rw-r--r--Jellyfin.Api/Helpers/TranscodingJobHelper.cs888
12 files changed, 3930 insertions, 0 deletions
diff --git a/Jellyfin.Api/Helpers/AudioHelper.cs b/Jellyfin.Api/Helpers/AudioHelper.cs
new file mode 100644
index 000000000..a3f2d88ce
--- /dev/null
+++ b/Jellyfin.Api/Helpers/AudioHelper.cs
@@ -0,0 +1,196 @@
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.StreamingDtos;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Dlna;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Net;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
+
+namespace Jellyfin.Api.Helpers
+{
+ /// <summary>
+ /// Audio helper.
+ /// </summary>
+ public class AudioHelper
+ {
+ private readonly IDlnaManager _dlnaManager;
+ private readonly IAuthorizationContext _authContext;
+ private readonly IUserManager _userManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+ private readonly IMediaEncoder _mediaEncoder;
+ private readonly IFileSystem _fileSystem;
+ private readonly ISubtitleEncoder _subtitleEncoder;
+ private readonly IConfiguration _configuration;
+ private readonly IDeviceManager _deviceManager;
+ private readonly TranscodingJobHelper _transcodingJobHelper;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="AudioHelper"/> class.
+ /// </summary>
+ /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+ /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="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="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">Instance of <see cref="TranscodingJobHelper"/>.</param>
+ /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
+ /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+ public AudioHelper(
+ IDlnaManager dlnaManager,
+ IAuthorizationContext authContext,
+ IUserManager userManager,
+ ILibraryManager libraryManager,
+ IMediaSourceManager mediaSourceManager,
+ IServerConfigurationManager serverConfigurationManager,
+ IMediaEncoder mediaEncoder,
+ IFileSystem fileSystem,
+ ISubtitleEncoder subtitleEncoder,
+ IConfiguration configuration,
+ IDeviceManager deviceManager,
+ TranscodingJobHelper transcodingJobHelper,
+ IHttpClientFactory httpClientFactory,
+ IHttpContextAccessor httpContextAccessor)
+ {
+ _dlnaManager = dlnaManager;
+ _authContext = authContext;
+ _userManager = userManager;
+ _libraryManager = libraryManager;
+ _mediaSourceManager = mediaSourceManager;
+ _serverConfigurationManager = serverConfigurationManager;
+ _mediaEncoder = mediaEncoder;
+ _fileSystem = fileSystem;
+ _subtitleEncoder = subtitleEncoder;
+ _configuration = configuration;
+ _deviceManager = deviceManager;
+ _transcodingJobHelper = transcodingJobHelper;
+ _httpClientFactory = httpClientFactory;
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ /// <summary>
+ /// Get audio stream.
+ /// </summary>
+ /// <param name="transcodingJobType">Transcoding job type.</param>
+ /// <param name="streamingRequest">Streaming controller.Request dto.</param>
+ /// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns>
+ public async Task<ActionResult> GetAudioStream(
+ TranscodingJobType transcodingJobType,
+ StreamingRequestDto streamingRequest)
+ {
+ bool isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == System.Net.WebRequestMethods.Http.Head;
+ var cancellationTokenSource = new CancellationTokenSource();
+
+ using var state = await StreamingHelpers.GetStreamingState(
+ streamingRequest,
+ _httpContextAccessor.HttpContext.Request,
+ _authContext,
+ _mediaSourceManager,
+ _userManager,
+ _libraryManager,
+ _serverConfigurationManager,
+ _mediaEncoder,
+ _fileSystem,
+ _subtitleEncoder,
+ _configuration,
+ _dlnaManager,
+ _deviceManager,
+ _transcodingJobHelper,
+ transcodingJobType,
+ cancellationTokenSource.Token)
+ .ConfigureAwait(false);
+
+ if (streamingRequest.Static && state.DirectStreamProvider != null)
+ {
+ StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager);
+
+ await new ProgressiveFileCopier(state.DirectStreamProvider, null, _transcodingJobHelper, CancellationToken.None)
+ {
+ AllowEndOfFile = false
+ }.WriteToAsync(_httpContextAccessor.HttpContext.Response.Body, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ // TODO (moved from MediaBrowser.Api): Don't hardcode contentType
+ return new FileStreamResult(_httpContextAccessor.HttpContext.Response.Body, MimeTypes.GetMimeType("file.ts")!);
+ }
+
+ // Static remote stream
+ if (streamingRequest.Static && state.InputProtocol == MediaProtocol.Http)
+ {
+ StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager);
+
+ var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
+ return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, isHeadRequest, httpClient, _httpContextAccessor.HttpContext).ConfigureAwait(false);
+ }
+
+ if (streamingRequest.Static && state.InputProtocol != MediaProtocol.File)
+ {
+ return new BadRequestObjectResult($"Input protocol {state.InputProtocol} cannot be streamed statically");
+ }
+
+ var outputPath = state.OutputFilePath;
+ var outputPathExists = System.IO.File.Exists(outputPath);
+
+ var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive);
+ var isTranscodeCached = outputPathExists && transcodingJob != null;
+
+ StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, streamingRequest.Static || isTranscodeCached, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager);
+
+ // Static stream
+ if (streamingRequest.Static)
+ {
+ var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath);
+
+ if (state.MediaSource.IsInfiniteStream)
+ {
+ await new ProgressiveFileCopier(state.MediaPath, null, _transcodingJobHelper, CancellationToken.None)
+ {
+ AllowEndOfFile = false
+ }.WriteToAsync(_httpContextAccessor.HttpContext.Response.Body, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ return new FileStreamResult(_httpContextAccessor.HttpContext.Response.Body, contentType);
+ }
+
+ return FileStreamResponseHelpers.GetStaticFileResult(
+ state.MediaPath,
+ contentType,
+ isHeadRequest,
+ _httpContextAccessor.HttpContext);
+ }
+
+ // Need to start ffmpeg (because media can't be returned directly)
+ var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+ var encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration);
+ var ffmpegCommandLineArguments = encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath);
+ return await FileStreamResponseHelpers.GetTranscodedFile(
+ state,
+ isHeadRequest,
+ _httpContextAccessor.HttpContext,
+ _transcodingJobHelper,
+ ffmpegCommandLineArguments,
+ transcodingJobType,
+ cancellationTokenSource).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/ClaimHelpers.cs b/Jellyfin.Api/Helpers/ClaimHelpers.cs
new file mode 100644
index 000000000..df235ced2
--- /dev/null
+++ b/Jellyfin.Api/Helpers/ClaimHelpers.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Linq;
+using System.Security.Claims;
+using Jellyfin.Api.Constants;
+
+namespace Jellyfin.Api.Helpers
+{
+ /// <summary>
+ /// Claim Helpers.
+ /// </summary>
+ public static class ClaimHelpers
+ {
+ /// <summary>
+ /// Get user id from claims.
+ /// </summary>
+ /// <param name="user">Current claims principal.</param>
+ /// <returns>User id.</returns>
+ public static Guid? GetUserId(in ClaimsPrincipal user)
+ {
+ var value = GetClaimValue(user, InternalClaimTypes.UserId);
+ return string.IsNullOrEmpty(value)
+ ? null
+ : (Guid?)Guid.Parse(value);
+ }
+
+ /// <summary>
+ /// Get device id from claims.
+ /// </summary>
+ /// <param name="user">Current claims principal.</param>
+ /// <returns>Device id.</returns>
+ public static string? GetDeviceId(in ClaimsPrincipal user)
+ => GetClaimValue(user, InternalClaimTypes.DeviceId);
+
+ /// <summary>
+ /// Get device from claims.
+ /// </summary>
+ /// <param name="user">Current claims principal.</param>
+ /// <returns>Device.</returns>
+ public static string? GetDevice(in ClaimsPrincipal user)
+ => GetClaimValue(user, InternalClaimTypes.Device);
+
+ /// <summary>
+ /// Get client from claims.
+ /// </summary>
+ /// <param name="user">Current claims principal.</param>
+ /// <returns>Client.</returns>
+ public static string? GetClient(in ClaimsPrincipal user)
+ => GetClaimValue(user, InternalClaimTypes.Client);
+
+ /// <summary>
+ /// Get version from claims.
+ /// </summary>
+ /// <param name="user">Current claims principal.</param>
+ /// <returns>Version.</returns>
+ public static string? GetVersion(in ClaimsPrincipal user)
+ => GetClaimValue(user, InternalClaimTypes.Version);
+
+ /// <summary>
+ /// Get token from claims.
+ /// </summary>
+ /// <param name="user">Current claims principal.</param>
+ /// <returns>Token.</returns>
+ public static string? GetToken(in ClaimsPrincipal user)
+ => GetClaimValue(user, InternalClaimTypes.Token);
+
+ private static string? GetClaimValue(in ClaimsPrincipal user, string name)
+ {
+ return user?.Identities
+ .SelectMany(c => c.Claims)
+ .Where(claim => claim.Type.Equals(name, StringComparison.OrdinalIgnoreCase))
+ .Select(claim => claim.Value)
+ .FirstOrDefault();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
new file mode 100644
index 000000000..af0519ffa
--- /dev/null
+++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
@@ -0,0 +1,550 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Net;
+using System.Security.Claims;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.StreamingDtos;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Dlna;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Microsoft.Net.Http.Headers;
+
+namespace Jellyfin.Api.Helpers
+{
+ /// <summary>
+ /// Dynamic hls helper.
+ /// </summary>
+ public class DynamicHlsHelper
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IUserManager _userManager;
+ private readonly IDlnaManager _dlnaManager;
+ private readonly IAuthorizationContext _authContext;
+ private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+ private readonly IMediaEncoder _mediaEncoder;
+ private readonly IFileSystem _fileSystem;
+ private readonly ISubtitleEncoder _subtitleEncoder;
+ private readonly IConfiguration _configuration;
+ private readonly IDeviceManager _deviceManager;
+ private readonly TranscodingJobHelper _transcodingJobHelper;
+ private readonly INetworkManager _networkManager;
+ private readonly ILogger<DynamicHlsHelper> _logger;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class.
+ /// </summary>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+ /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> 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="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">Instance of <see cref="TranscodingJobHelper"/>.</param>
+ /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param>
+ /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+ public DynamicHlsHelper(
+ ILibraryManager libraryManager,
+ IUserManager userManager,
+ IDlnaManager dlnaManager,
+ IAuthorizationContext authContext,
+ IMediaSourceManager mediaSourceManager,
+ IServerConfigurationManager serverConfigurationManager,
+ IMediaEncoder mediaEncoder,
+ IFileSystem fileSystem,
+ ISubtitleEncoder subtitleEncoder,
+ IConfiguration configuration,
+ IDeviceManager deviceManager,
+ TranscodingJobHelper transcodingJobHelper,
+ INetworkManager networkManager,
+ ILogger<DynamicHlsHelper> logger,
+ IHttpContextAccessor httpContextAccessor)
+ {
+ _libraryManager = libraryManager;
+ _userManager = userManager;
+ _dlnaManager = dlnaManager;
+ _authContext = authContext;
+ _mediaSourceManager = mediaSourceManager;
+ _serverConfigurationManager = serverConfigurationManager;
+ _mediaEncoder = mediaEncoder;
+ _fileSystem = fileSystem;
+ _subtitleEncoder = subtitleEncoder;
+ _configuration = configuration;
+ _deviceManager = deviceManager;
+ _transcodingJobHelper = transcodingJobHelper;
+ _networkManager = networkManager;
+ _logger = logger;
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ /// <summary>
+ /// Get master hls playlist.
+ /// </summary>
+ /// <param name="transcodingJobType">Transcoding job type.</param>
+ /// <param name="streamingRequest">Streaming request dto.</param>
+ /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
+ /// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns>
+ public async Task<ActionResult> GetMasterHlsPlaylist(
+ TranscodingJobType transcodingJobType,
+ StreamingRequestDto streamingRequest,
+ bool enableAdaptiveBitrateStreaming)
+ {
+ var isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == WebRequestMethods.Http.Head;
+ var cancellationTokenSource = new CancellationTokenSource();
+ return await GetMasterPlaylistInternal(
+ streamingRequest,
+ isHeadRequest,
+ enableAdaptiveBitrateStreaming,
+ transcodingJobType,
+ cancellationTokenSource).ConfigureAwait(false);
+ }
+
+ private async Task<ActionResult> GetMasterPlaylistInternal(
+ StreamingRequestDto streamingRequest,
+ bool isHeadRequest,
+ bool enableAdaptiveBitrateStreaming,
+ TranscodingJobType transcodingJobType,
+ CancellationTokenSource cancellationTokenSource)
+ {
+ using var state = await StreamingHelpers.GetStreamingState(
+ streamingRequest,
+ _httpContextAccessor.HttpContext.Request,
+ _authContext,
+ _mediaSourceManager,
+ _userManager,
+ _libraryManager,
+ _serverConfigurationManager,
+ _mediaEncoder,
+ _fileSystem,
+ _subtitleEncoder,
+ _configuration,
+ _dlnaManager,
+ _deviceManager,
+ _transcodingJobHelper,
+ transcodingJobType,
+ cancellationTokenSource.Token)
+ .ConfigureAwait(false);
+
+ _httpContextAccessor.HttpContext.Response.Headers.Add(HeaderNames.Expires, "0");
+ if (isHeadRequest)
+ {
+ return new FileContentResult(Array.Empty<byte>(), MimeTypes.GetMimeType("playlist.m3u8"));
+ }
+
+ var totalBitrate = state.OutputAudioBitrate ?? 0 + state.OutputVideoBitrate ?? 0;
+
+ var builder = new StringBuilder();
+
+ builder.AppendLine("#EXTM3U");
+
+ var isLiveStream = state.IsSegmentedLiveStream;
+
+ var queryString = _httpContextAccessor.HttpContext.Request.QueryString.ToString();
+
+ // from universal audio service
+ if (queryString.IndexOf("SegmentContainer", StringComparison.OrdinalIgnoreCase) == -1 && !string.IsNullOrWhiteSpace(state.Request.SegmentContainer))
+ {
+ queryString += "&SegmentContainer=" + state.Request.SegmentContainer;
+ }
+
+ // from universal audio service
+ if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons) && queryString.IndexOf("TranscodeReasons=", StringComparison.OrdinalIgnoreCase) == -1)
+ {
+ queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
+ }
+
+ // Main stream
+ var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
+
+ playlistUrl += queryString;
+
+ var subtitleStreams = state.MediaSource
+ .MediaStreams
+ .Where(i => i.IsTextSubtitleStream)
+ .ToList();
+
+ var subtitleGroup = subtitleStreams.Count > 0 && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Hls || state.VideoRequest!.EnableSubtitlesInManifest)
+ ? "subs"
+ : null;
+
+ // If we're burning in subtitles then don't add additional subs to the manifest
+ if (state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
+ {
+ subtitleGroup = null;
+ }
+
+ if (!string.IsNullOrWhiteSpace(subtitleGroup))
+ {
+ AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
+ }
+
+ AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
+
+ if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIp()))
+ {
+ var requestedVideoBitrate = state.VideoRequest == null ? 0 : state.VideoRequest.VideoBitRate ?? 0;
+
+ // By default, vary by just 200k
+ var variation = GetBitrateVariation(totalBitrate);
+
+ var newBitrate = totalBitrate - variation;
+ var variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
+ AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
+
+ variation *= 2;
+ newBitrate = totalBitrate - variation;
+ variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
+ AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
+ }
+
+ return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
+ }
+
+ private void AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup)
+ {
+ builder.Append("#EXT-X-STREAM-INF:BANDWIDTH=")
+ .Append(bitrate.ToString(CultureInfo.InvariantCulture))
+ .Append(",AVERAGE-BANDWIDTH=")
+ .Append(bitrate.ToString(CultureInfo.InvariantCulture));
+
+ AppendPlaylistCodecsField(builder, state);
+
+ AppendPlaylistResolutionField(builder, state);
+
+ AppendPlaylistFramerateField(builder, state);
+
+ if (!string.IsNullOrWhiteSpace(subtitleGroup))
+ {
+ builder.Append(",SUBTITLES=\"")
+ .Append(subtitleGroup)
+ .Append('"');
+ }
+
+ builder.Append(Environment.NewLine);
+ builder.AppendLine(url);
+ }
+
+ /// <summary>
+ /// Appends a CODECS field containing formatted strings of
+ /// the active streams output video and audio codecs.
+ /// </summary>
+ /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
+ /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
+ /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/>
+ /// <param name="builder">StringBuilder to append the field to.</param>
+ /// <param name="state">StreamState of the current stream.</param>
+ private void AppendPlaylistCodecsField(StringBuilder builder, StreamState state)
+ {
+ // Video
+ string videoCodecs = string.Empty;
+ int? videoCodecLevel = GetOutputVideoCodecLevel(state);
+ if (!string.IsNullOrEmpty(state.ActualOutputVideoCodec) && videoCodecLevel.HasValue)
+ {
+ videoCodecs = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value);
+ }
+
+ // Audio
+ string audioCodecs = string.Empty;
+ if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec))
+ {
+ audioCodecs = GetPlaylistAudioCodecs(state);
+ }
+
+ StringBuilder codecs = new StringBuilder();
+
+ codecs.Append(videoCodecs);
+
+ if (!string.IsNullOrEmpty(videoCodecs) && !string.IsNullOrEmpty(audioCodecs))
+ {
+ codecs.Append(',');
+ }
+
+ codecs.Append(audioCodecs);
+
+ if (codecs.Length > 1)
+ {
+ builder.Append(",CODECS=\"")
+ .Append(codecs)
+ .Append('"');
+ }
+ }
+
+ /// <summary>
+ /// Appends a RESOLUTION field containing the resolution of the output stream.
+ /// </summary>
+ /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
+ /// <param name="builder">StringBuilder to append the field to.</param>
+ /// <param name="state">StreamState of the current stream.</param>
+ private void AppendPlaylistResolutionField(StringBuilder builder, StreamState state)
+ {
+ if (state.OutputWidth.HasValue && state.OutputHeight.HasValue)
+ {
+ builder.Append(",RESOLUTION=")
+ .Append(state.OutputWidth.GetValueOrDefault())
+ .Append('x')
+ .Append(state.OutputHeight.GetValueOrDefault());
+ }
+ }
+
+ /// <summary>
+ /// Appends a FRAME-RATE field containing the framerate of the output stream.
+ /// </summary>
+ /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
+ /// <param name="builder">StringBuilder to append the field to.</param>
+ /// <param name="state">StreamState of the current stream.</param>
+ private void AppendPlaylistFramerateField(StringBuilder builder, StreamState state)
+ {
+ double? framerate = null;
+ if (state.TargetFramerate.HasValue)
+ {
+ framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3);
+ }
+ else if (state.VideoStream?.RealFrameRate != null)
+ {
+ framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3);
+ }
+
+ if (framerate.HasValue)
+ {
+ builder.Append(",FRAME-RATE=")
+ .Append(framerate.Value);
+ }
+ }
+
+ private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming, string ipAddress)
+ {
+ // Within the local network this will likely do more harm than good.
+ if (_networkManager.IsInLocalNetwork(ipAddress))
+ {
+ return false;
+ }
+
+ if (!enableAdaptiveBitrateStreaming)
+ {
+ return false;
+ }
+
+ if (isLiveStream || string.IsNullOrWhiteSpace(state.MediaPath))
+ {
+ // Opening live streams is so slow it's not even worth it
+ return false;
+ }
+
+ if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+ {
+ return false;
+ }
+
+ if (EncodingHelper.IsCopyCodec(state.OutputAudioCodec))
+ {
+ return false;
+ }
+
+ if (!state.IsOutputVideo)
+ {
+ return false;
+ }
+
+ // Having problems in android
+ return false;
+ // return state.VideoRequest.VideoBitRate.HasValue;
+ }
+
+ private void AddSubtitles(StreamState state, IEnumerable<MediaStream> subtitles, StringBuilder builder, ClaimsPrincipal user)
+ {
+ var selectedIndex = state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index;
+ const string Format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\"";
+
+ foreach (var stream in subtitles)
+ {
+ var name = stream.DisplayTitle;
+
+ var isDefault = selectedIndex.HasValue && selectedIndex.Value == stream.Index;
+ var isForced = stream.IsForced;
+
+ var url = string.Format(
+ CultureInfo.InvariantCulture,
+ "{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&api_key={3}",
+ state.Request.MediaSourceId,
+ stream.Index.ToString(CultureInfo.InvariantCulture),
+ 30.ToString(CultureInfo.InvariantCulture),
+ ClaimHelpers.GetToken(user));
+
+ var line = string.Format(
+ CultureInfo.InvariantCulture,
+ Format,
+ name,
+ isDefault ? "YES" : "NO",
+ isForced ? "YES" : "NO",
+ url,
+ stream.Language ?? "Unknown");
+
+ builder.AppendLine(line);
+ }
+ }
+
+ /// <summary>
+ /// Get the H.26X level of the output video stream.
+ /// </summary>
+ /// <param name="state">StreamState of the current stream.</param>
+ /// <returns>H.26X level of the output video stream.</returns>
+ private int? GetOutputVideoCodecLevel(StreamState state)
+ {
+ string? levelString;
+ if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+ && state.VideoStream.Level.HasValue)
+ {
+ levelString = state.VideoStream?.Level.ToString();
+ }
+ else
+ {
+ levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec);
+ }
+
+ if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel))
+ {
+ return parsedLevel;
+ }
+
+ return null;
+ }
+
+ /// <summary>
+ /// Gets a formatted string of the output audio codec, for use in the CODECS field.
+ /// </summary>
+ /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
+ /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
+ /// <param name="state">StreamState of the current stream.</param>
+ /// <returns>Formatted audio codec string.</returns>
+ private string GetPlaylistAudioCodecs(StreamState state)
+ {
+ if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase))
+ {
+ string? profile = state.GetRequestedProfiles("aac").FirstOrDefault();
+ return HlsCodecStringHelpers.GetAACString(profile);
+ }
+
+ if (string.Equals(state.ActualOutputAudioCodec, "mp3", StringComparison.OrdinalIgnoreCase))
+ {
+ return HlsCodecStringHelpers.GetMP3String();
+ }
+
+ if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase))
+ {
+ return HlsCodecStringHelpers.GetAC3String();
+ }
+
+ if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase))
+ {
+ return HlsCodecStringHelpers.GetEAC3String();
+ }
+
+ return string.Empty;
+ }
+
+ /// <summary>
+ /// Gets a formatted string of the output video codec, for use in the CODECS field.
+ /// </summary>
+ /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
+ /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/>
+ /// <param name="state">StreamState of the current stream.</param>
+ /// <param name="codec">Video codec.</param>
+ /// <param name="level">Video level.</param>
+ /// <returns>Formatted video codec string.</returns>
+ private string GetPlaylistVideoCodecs(StreamState state, string codec, int level)
+ {
+ if (level == 0)
+ {
+ // This is 0 when there's no requested H.26X level in the device profile
+ // and the source is not encoded in H.26X
+ _logger.LogError("Got invalid H.26X level when building CODECS field for HLS master playlist");
+ return string.Empty;
+ }
+
+ if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase))
+ {
+ string profile = state.GetRequestedProfiles("h264").FirstOrDefault();
+ return HlsCodecStringHelpers.GetH264String(profile, level);
+ }
+
+ if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
+ {
+ string profile = state.GetRequestedProfiles("h265").FirstOrDefault();
+
+ return HlsCodecStringHelpers.GetH265String(profile, level);
+ }
+
+ return string.Empty;
+ }
+
+ private int GetBitrateVariation(int bitrate)
+ {
+ // By default, vary by just 50k
+ var variation = 50000;
+
+ if (bitrate >= 10000000)
+ {
+ variation = 2000000;
+ }
+ else if (bitrate >= 5000000)
+ {
+ variation = 1500000;
+ }
+ else if (bitrate >= 3000000)
+ {
+ variation = 1000000;
+ }
+ else if (bitrate >= 2000000)
+ {
+ variation = 500000;
+ }
+ else if (bitrate >= 1000000)
+ {
+ variation = 300000;
+ }
+ else if (bitrate >= 600000)
+ {
+ variation = 200000;
+ }
+ else if (bitrate >= 400000)
+ {
+ variation = 100000;
+ }
+
+ return variation;
+ }
+
+ private string ReplaceBitrate(string url, int oldValue, int newValue)
+ {
+ return url.Replace(
+ "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
+ "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
+ StringComparison.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
new file mode 100644
index 000000000..6b516977e
--- /dev/null
+++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
@@ -0,0 +1,136 @@
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.PlaybackDtos;
+using Jellyfin.Api.Models.StreamingDtos;
+using MediaBrowser.Controller.MediaEncoding;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Net.Http.Headers;
+
+namespace Jellyfin.Api.Helpers
+{
+ /// <summary>
+ /// The stream response helpers.
+ /// </summary>
+ public static class FileStreamResponseHelpers
+ {
+ /// <summary>
+ /// Returns a static file from a remote source.
+ /// </summary>
+ /// <param name="state">The current <see cref="StreamState"/>.</param>
+ /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param>
+ /// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param>
+ /// <param name="httpContext">The current http context.</param>
+ /// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns>
+ public static async Task<ActionResult> GetStaticRemoteStreamResult(
+ StreamState state,
+ bool isHeadRequest,
+ HttpClient httpClient,
+ HttpContext httpContext)
+ {
+ if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent))
+ {
+ httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent);
+ }
+
+ // Can't dispose the response as it's required up the call chain.
+ var response = await httpClient.GetAsync(state.MediaPath).ConfigureAwait(false);
+ var contentType = response.Content.Headers.ContentType.ToString();
+
+ httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none";
+
+ if (isHeadRequest)
+ {
+ return new FileContentResult(Array.Empty<byte>(), contentType);
+ }
+
+ return new FileStreamResult(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), contentType);
+ }
+
+ /// <summary>
+ /// Returns a static file from the server.
+ /// </summary>
+ /// <param name="path">The path to the file.</param>
+ /// <param name="contentType">The content type of the file.</param>
+ /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param>
+ /// <param name="httpContext">The current http context.</param>
+ /// <returns>An <see cref="ActionResult"/> the file.</returns>
+ public static ActionResult GetStaticFileResult(
+ string path,
+ string contentType,
+ bool isHeadRequest,
+ HttpContext httpContext)
+ {
+ httpContext.Response.ContentType = contentType;
+
+ // if the request is a head request, return a NoContent result with the same headers as it would with a GET request
+ if (isHeadRequest)
+ {
+ return new NoContentResult();
+ }
+
+ return new PhysicalFileResult(path, contentType) { EnableRangeProcessing = true };
+ }
+
+ /// <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="httpContext">The current http context.</param>
+ /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param>
+ /// <param name="ffmpegCommandLineArguments">The command line arguments to start ffmpeg.</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,
+ HttpContext httpContext,
+ TranscodingJobHelper transcodingJobHelper,
+ string ffmpegCommandLineArguments,
+ TranscodingJobType transcodingJobType,
+ CancellationTokenSource cancellationTokenSource)
+ {
+ // Use the command line args with a dummy playlist path
+ var outputPath = state.OutputFilePath;
+
+ httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none";
+
+ var contentType = state.GetMimeType(outputPath);
+
+ // Headers only
+ if (isHeadRequest)
+ {
+ return new FileContentResult(Array.Empty<byte>(), contentType);
+ }
+
+ var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath);
+ await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
+ try
+ {
+ TranscodingJobDto? job;
+ if (!File.Exists(outputPath))
+ {
+ job = await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, httpContext.Request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false);
+ }
+ else
+ {
+ job = transcodingJobHelper.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive);
+ state.Dispose();
+ }
+
+ await new ProgressiveFileCopier(outputPath, job, transcodingJobHelper, CancellationToken.None)
+ .WriteToAsync(httpContext.Response.Body, CancellationToken.None).ConfigureAwait(false);
+ return new FileStreamResult(httpContext.Response.Body, contentType);
+ }
+ finally
+ {
+ transcodingLock.Release();
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
new file mode 100644
index 000000000..95f1906ef
--- /dev/null
+++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Globalization;
+using System.Text;
+
+namespace Jellyfin.Api.Helpers
+{
+ /// <summary>
+ /// Hls Codec string helpers.
+ /// </summary>
+ public static class HlsCodecStringHelpers
+ {
+ /// <summary>
+ /// Gets a MP3 codec string.
+ /// </summary>
+ /// <returns>MP3 codec string.</returns>
+ public static string GetMP3String()
+ {
+ return "mp4a.40.34";
+ }
+
+ /// <summary>
+ /// Gets an AAC codec string.
+ /// </summary>
+ /// <param name="profile">AAC profile.</param>
+ /// <returns>AAC codec string.</returns>
+ public static string GetAACString(string profile)
+ {
+ StringBuilder result = new StringBuilder("mp4a", 9);
+
+ if (string.Equals(profile, "HE", StringComparison.OrdinalIgnoreCase))
+ {
+ result.Append(".40.5");
+ }
+ else
+ {
+ // Default to LC if profile is invalid
+ result.Append(".40.2");
+ }
+
+ return result.ToString();
+ }
+
+ /// <summary>
+ /// Gets a H.264 codec string.
+ /// </summary>
+ /// <param name="profile">H.264 profile.</param>
+ /// <param name="level">H.264 level.</param>
+ /// <returns>H.264 string.</returns>
+ public static string GetH264String(string profile, int level)
+ {
+ StringBuilder result = new StringBuilder("avc1", 11);
+
+ if (string.Equals(profile, "high", StringComparison.OrdinalIgnoreCase))
+ {
+ result.Append(".6400");
+ }
+ else if (string.Equals(profile, "main", StringComparison.OrdinalIgnoreCase))
+ {
+ result.Append(".4D40");
+ }
+ else if (string.Equals(profile, "baseline", StringComparison.OrdinalIgnoreCase))
+ {
+ result.Append(".42E0");
+ }
+ else
+ {
+ // Default to constrained baseline if profile is invalid
+ result.Append(".4240");
+ }
+
+ string levelHex = level.ToString("X2", CultureInfo.InvariantCulture);
+ result.Append(levelHex);
+
+ return result.ToString();
+ }
+
+ /// <summary>
+ /// Gets a H.265 codec string.
+ /// </summary>
+ /// <param name="profile">H.265 profile.</param>
+ /// <param name="level">H.265 level.</param>
+ /// <returns>H.265 string.</returns>
+ public static string GetH265String(string profile, int level)
+ {
+ // The h265 syntax is a bit of a mystery at the time this comment was written.
+ // This is what I've found through various sources:
+ // FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN]
+ StringBuilder result = new StringBuilder("hev1", 16);
+
+ if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase))
+ {
+ result.Append(".2.6");
+ }
+ else
+ {
+ // Default to main if profile is invalid
+ result.Append(".1.6");
+ }
+
+ result.Append(".L")
+ .Append(level * 3)
+ .Append(".B0");
+
+ return result.ToString();
+ }
+
+ /// <summary>
+ /// Gets an AC-3 codec string.
+ /// </summary>
+ /// <returns>AC-3 codec string.</returns>
+ public static string GetAC3String()
+ {
+ return "mp4a.a5";
+ }
+
+ /// <summary>
+ /// Gets an E-AC-3 codec string.
+ /// </summary>
+ /// <returns>E-AC-3 codec string.</returns>
+ public static string GetEAC3String()
+ {
+ return "mp4a.a6";
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs
new file mode 100644
index 000000000..242496697
--- /dev/null
+++ b/Jellyfin.Api/Helpers/HlsHelpers.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Helpers
+{
+ /// <summary>
+ /// The hls helpers.
+ /// </summary>
+ public static class HlsHelpers
+ {
+ /// <summary>
+ /// Waits for a minimum number of segments to be available.
+ /// </summary>
+ /// <param name="playlist">The playlist string.</param>
+ /// <param name="segmentCount">The segment count.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
+ /// <returns>A <see cref="Task"/> indicating the waiting process.</returns>
+ public static async Task WaitForMinimumSegmentCount(string playlist, int? segmentCount, ILogger logger, CancellationToken cancellationToken)
+ {
+ logger.LogDebug("Waiting for {0} segments in {1}", segmentCount, playlist);
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ // Need to use FileShare.ReadWrite because we're reading the file at the same time it's being written
+ var fileStream = new FileStream(
+ playlist,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.ReadWrite,
+ IODefaults.FileStreamBufferSize,
+ FileOptions.SequentialScan);
+ await using (fileStream.ConfigureAwait(false))
+ {
+ using var reader = new StreamReader(fileStream);
+ var count = 0;
+
+ while (!reader.EndOfStream)
+ {
+ var line = await reader.ReadLineAsync().ConfigureAwait(false);
+
+ if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ count++;
+ if (count >= segmentCount)
+ {
+ logger.LogDebug("Finished waiting for {0} segments in {1}", segmentCount, playlist);
+ return;
+ }
+ }
+ }
+ }
+
+ await Task.Delay(100, cancellationToken).ConfigureAwait(false);
+ }
+ catch (IOException)
+ {
+ // May get an error if the file is locked
+ }
+
+ await Task.Delay(50, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ /// <summary>
+ /// Gets the hls playlist text.
+ /// </summary>
+ /// <param name="path">The path to the playlist file.</param>
+ /// <param name="segmentLength">The segment length.</param>
+ /// <returns>The playlist text as a string.</returns>
+ public static string GetLivePlaylistText(string path, int segmentLength)
+ {
+ using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
+ using var reader = new StreamReader(stream);
+
+ var text = reader.ReadToEnd();
+
+ text = text.Replace("#EXTM3U", "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT", StringComparison.InvariantCulture);
+
+ var newDuration = "#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture);
+
+ text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength - 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase);
+ // text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength + 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase);
+
+ return text;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs
new file mode 100644
index 000000000..e78f63b25
--- /dev/null
+++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs
@@ -0,0 +1,578 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Session;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Helpers
+{
+ /// <summary>
+ /// Media info helper.
+ /// </summary>
+ public class MediaInfoHelper
+ {
+ private readonly IUserManager _userManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly IMediaEncoder _mediaEncoder;
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+ private readonly ILogger<MediaInfoHelper> _logger;
+ private readonly INetworkManager _networkManager;
+ private readonly IDeviceManager _deviceManager;
+ private readonly IAuthorizationContext _authContext;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MediaInfoHelper"/> class.
+ /// </summary>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> 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="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param>
+ /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+ /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+ /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+ public MediaInfoHelper(
+ IUserManager userManager,
+ ILibraryManager libraryManager,
+ IMediaSourceManager mediaSourceManager,
+ IMediaEncoder mediaEncoder,
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<MediaInfoHelper> logger,
+ INetworkManager networkManager,
+ IDeviceManager deviceManager,
+ IAuthorizationContext authContext)
+ {
+ _userManager = userManager;
+ _libraryManager = libraryManager;
+ _mediaSourceManager = mediaSourceManager;
+ _mediaEncoder = mediaEncoder;
+ _serverConfigurationManager = serverConfigurationManager;
+ _logger = logger;
+ _networkManager = networkManager;
+ _deviceManager = deviceManager;
+ _authContext = authContext;
+ }
+
+ /// <summary>
+ /// Get playback info.
+ /// </summary>
+ /// <param name="id">Item id.</param>
+ /// <param name="userId">User Id.</param>
+ /// <param name="mediaSourceId">Media source id.</param>
+ /// <param name="liveStreamId">Live stream id.</param>
+ /// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns>
+ public async Task<PlaybackInfoResponse> GetPlaybackInfo(
+ Guid id,
+ Guid? userId,
+ string? mediaSourceId = null,
+ string? liveStreamId = null)
+ {
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+ var item = _libraryManager.GetItemById(id);
+ var result = new PlaybackInfoResponse();
+
+ MediaSourceInfo[] mediaSources;
+ if (string.IsNullOrWhiteSpace(liveStreamId))
+ {
+ // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes?
+ var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false);
+
+ if (string.IsNullOrWhiteSpace(mediaSourceId))
+ {
+ mediaSources = mediaSourcesList.ToArray();
+ }
+ else
+ {
+ mediaSources = mediaSourcesList
+ .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+ }
+ }
+ else
+ {
+ var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false);
+
+ mediaSources = new[] { mediaSource };
+ }
+
+ if (mediaSources.Length == 0)
+ {
+ result.MediaSources = Array.Empty<MediaSourceInfo>();
+
+ result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream;
+ }
+ else
+ {
+ // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it
+ // Should we move this directly into MediaSourceManager?
+ var mediaSourcesClone = JsonSerializer.Deserialize<MediaSourceInfo[]>(JsonSerializer.SerializeToUtf8Bytes(mediaSources));
+ if (mediaSourcesClone != null)
+ {
+ result.MediaSources = mediaSourcesClone;
+ }
+
+ result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+ }
+
+ return result;
+ }
+
+ /// <summary>
+ /// SetDeviceSpecificData.
+ /// </summary>
+ /// <param name="item">Item to set data for.</param>
+ /// <param name="mediaSource">Media source info.</param>
+ /// <param name="profile">Device profile.</param>
+ /// <param name="auth">Authorization info.</param>
+ /// <param name="maxBitrate">Max bitrate.</param>
+ /// <param name="startTimeTicks">Start time ticks.</param>
+ /// <param name="mediaSourceId">Media source id.</param>
+ /// <param name="audioStreamIndex">Audio stream index.</param>
+ /// <param name="subtitleStreamIndex">Subtitle stream index.</param>
+ /// <param name="maxAudioChannels">Max audio channels.</param>
+ /// <param name="playSessionId">Play session id.</param>
+ /// <param name="userId">User id.</param>
+ /// <param name="enableDirectPlay">Enable direct play.</param>
+ /// <param name="enableDirectStream">Enable direct stream.</param>
+ /// <param name="enableTranscoding">Enable transcoding.</param>
+ /// <param name="allowVideoStreamCopy">Allow video stream copy.</param>
+ /// <param name="allowAudioStreamCopy">Allow audio stream copy.</param>
+ /// <param name="ipAddress">Requesting IP address.</param>
+ public void SetDeviceSpecificData(
+ BaseItem item,
+ MediaSourceInfo mediaSource,
+ DeviceProfile profile,
+ AuthorizationInfo auth,
+ long? maxBitrate,
+ long startTimeTicks,
+ string mediaSourceId,
+ int? audioStreamIndex,
+ int? subtitleStreamIndex,
+ int? maxAudioChannels,
+ string playSessionId,
+ Guid userId,
+ bool enableDirectPlay,
+ bool enableDirectStream,
+ bool enableTranscoding,
+ bool allowVideoStreamCopy,
+ bool allowAudioStreamCopy,
+ string ipAddress)
+ {
+ var streamBuilder = new StreamBuilder(_mediaEncoder, _logger);
+
+ var options = new VideoOptions
+ {
+ MediaSources = new[] { mediaSource },
+ Context = EncodingContext.Streaming,
+ DeviceId = auth.DeviceId,
+ ItemId = item.Id,
+ Profile = profile,
+ MaxAudioChannels = maxAudioChannels
+ };
+
+ if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase))
+ {
+ options.MediaSourceId = mediaSourceId;
+ options.AudioStreamIndex = audioStreamIndex;
+ options.SubtitleStreamIndex = subtitleStreamIndex;
+ }
+
+ var user = _userManager.GetUserById(userId);
+
+ if (!enableDirectPlay)
+ {
+ mediaSource.SupportsDirectPlay = false;
+ }
+
+ if (!enableDirectStream)
+ {
+ mediaSource.SupportsDirectStream = false;
+ }
+
+ if (!enableTranscoding)
+ {
+ mediaSource.SupportsTranscoding = false;
+ }
+
+ if (item is Audio)
+ {
+ _logger.LogInformation(
+ "User policy for {0}. EnableAudioPlaybackTranscoding: {1}",
+ user.Username,
+ user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding));
+ }
+ else
+ {
+ _logger.LogInformation(
+ "User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}",
+ user.Username,
+ user.HasPermission(PermissionKind.EnablePlaybackRemuxing),
+ user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding),
+ user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding));
+ }
+
+ // Beginning of Playback Determination: Attempt DirectPlay first
+ if (mediaSource.SupportsDirectPlay)
+ {
+ if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
+ {
+ mediaSource.SupportsDirectPlay = false;
+ }
+ else
+ {
+ var supportsDirectStream = mediaSource.SupportsDirectStream;
+
+ // Dummy this up to fool StreamBuilder
+ mediaSource.SupportsDirectStream = true;
+ options.MaxBitrate = maxBitrate;
+
+ if (item is Audio)
+ {
+ if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding))
+ {
+ options.ForceDirectPlay = true;
+ }
+ }
+ else if (item is Video)
+ {
+ if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)
+ && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)
+ && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing))
+ {
+ options.ForceDirectPlay = true;
+ }
+ }
+
+ // The MediaSource supports direct stream, now test to see if the client supports it
+ var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
+ ? streamBuilder.BuildAudioItem(options)
+ : streamBuilder.BuildVideoItem(options);
+
+ if (streamInfo == null || !streamInfo.IsDirectStream)
+ {
+ mediaSource.SupportsDirectPlay = false;
+ }
+
+ // Set this back to what it was
+ mediaSource.SupportsDirectStream = supportsDirectStream;
+
+ if (streamInfo != null)
+ {
+ SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
+ }
+ }
+ }
+
+ if (mediaSource.SupportsDirectStream)
+ {
+ if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
+ {
+ mediaSource.SupportsDirectStream = false;
+ }
+ else
+ {
+ options.MaxBitrate = GetMaxBitrate(maxBitrate, user, ipAddress);
+
+ if (item is Audio)
+ {
+ if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding))
+ {
+ options.ForceDirectStream = true;
+ }
+ }
+ else if (item is Video)
+ {
+ if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)
+ && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)
+ && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing))
+ {
+ options.ForceDirectStream = true;
+ }
+ }
+
+ // The MediaSource supports direct stream, now test to see if the client supports it
+ var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
+ ? streamBuilder.BuildAudioItem(options)
+ : streamBuilder.BuildVideoItem(options);
+
+ if (streamInfo == null || !streamInfo.IsDirectStream)
+ {
+ mediaSource.SupportsDirectStream = false;
+ }
+
+ if (streamInfo != null)
+ {
+ SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
+ }
+ }
+ }
+
+ if (mediaSource.SupportsTranscoding)
+ {
+ options.MaxBitrate = GetMaxBitrate(maxBitrate, user, ipAddress);
+
+ // The MediaSource supports direct stream, now test to see if the client supports it
+ var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
+ ? streamBuilder.BuildAudioItem(options)
+ : streamBuilder.BuildVideoItem(options);
+
+ if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
+ {
+ if (streamInfo != null)
+ {
+ streamInfo.PlaySessionId = playSessionId;
+ streamInfo.StartPositionTicks = startTimeTicks;
+ mediaSource.TranscodingUrl = streamInfo.ToUrl("-", auth.Token).TrimStart('-');
+ mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false";
+ mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
+ mediaSource.TranscodingContainer = streamInfo.Container;
+ mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
+
+ // Do this after the above so that StartPositionTicks is set
+ SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
+ }
+ }
+ else
+ {
+ if (streamInfo != null)
+ {
+ streamInfo.PlaySessionId = playSessionId;
+
+ if (streamInfo.PlayMethod == PlayMethod.Transcode)
+ {
+ streamInfo.StartPositionTicks = startTimeTicks;
+ mediaSource.TranscodingUrl = streamInfo.ToUrl("-", auth.Token).TrimStart('-');
+
+ if (!allowVideoStreamCopy)
+ {
+ mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false";
+ }
+
+ if (!allowAudioStreamCopy)
+ {
+ mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
+ }
+
+ mediaSource.TranscodingContainer = streamInfo.Container;
+ mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
+ }
+
+ if (!allowAudioStreamCopy)
+ {
+ mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
+ }
+
+ mediaSource.TranscodingContainer = streamInfo.Container;
+ mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
+
+ // Do this after the above so that StartPositionTicks is set
+ SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
+ }
+ }
+ }
+
+ foreach (var attachment in mediaSource.MediaAttachments)
+ {
+ attachment.DeliveryUrl = string.Format(
+ CultureInfo.InvariantCulture,
+ "/Videos/{0}/{1}/Attachments/{2}",
+ item.Id,
+ mediaSource.Id,
+ attachment.Index);
+ }
+ }
+
+ /// <summary>
+ /// Sort media source.
+ /// </summary>
+ /// <param name="result">Playback info response.</param>
+ /// <param name="maxBitrate">Max bitrate.</param>
+ public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate)
+ {
+ var originalList = result.MediaSources.ToList();
+
+ result.MediaSources = result.MediaSources.OrderBy(i =>
+ {
+ // Nothing beats direct playing a file
+ if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)
+ {
+ return 0;
+ }
+
+ return 1;
+ })
+ .ThenBy(i =>
+ {
+ // Let's assume direct streaming a file is just as desirable as direct playing a remote url
+ if (i.SupportsDirectPlay || i.SupportsDirectStream)
+ {
+ return 0;
+ }
+
+ return 1;
+ })
+ .ThenBy(i =>
+ {
+ return i.Protocol switch
+ {
+ MediaProtocol.File => 0,
+ _ => 1,
+ };
+ })
+ .ThenBy(i =>
+ {
+ if (maxBitrate.HasValue && i.Bitrate.HasValue)
+ {
+ return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2;
+ }
+
+ return 1;
+ })
+ .ThenBy(originalList.IndexOf)
+ .ToArray();
+ }
+
+ /// <summary>
+ /// Open media source.
+ /// </summary>
+ /// <param name="httpRequest">Http Request.</param>
+ /// <param name="request">Live stream request.</param>
+ /// <returns>A <see cref="Task"/> containing the <see cref="LiveStreamResponse"/>.</returns>
+ public async Task<LiveStreamResponse> OpenMediaSource(HttpRequest httpRequest, LiveStreamRequest request)
+ {
+ var authInfo = _authContext.GetAuthorizationInfo(httpRequest);
+
+ var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false);
+
+ var profile = request.DeviceProfile;
+ if (profile == null)
+ {
+ var clientCapabilities = _deviceManager.GetCapabilities(authInfo.DeviceId);
+ if (clientCapabilities != null)
+ {
+ profile = clientCapabilities.DeviceProfile;
+ }
+ }
+
+ if (profile != null)
+ {
+ var item = _libraryManager.GetItemById(request.ItemId);
+
+ SetDeviceSpecificData(
+ item,
+ result.MediaSource,
+ profile,
+ authInfo,
+ request.MaxStreamingBitrate,
+ request.StartTimeTicks ?? 0,
+ result.MediaSource.Id,
+ request.AudioStreamIndex,
+ request.SubtitleStreamIndex,
+ request.MaxAudioChannels,
+ request.PlaySessionId,
+ request.UserId,
+ request.EnableDirectPlay,
+ request.EnableDirectStream,
+ true,
+ true,
+ true,
+ httpRequest.HttpContext.GetNormalizedRemoteIp());
+ }
+ else
+ {
+ if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl))
+ {
+ result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId;
+ }
+ }
+
+ // here was a check if (result.MediaSource != null) but Rider said it will never be null
+ NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video);
+
+ return result;
+ }
+
+ /// <summary>
+ /// Normalize media source container.
+ /// </summary>
+ /// <param name="mediaSource">Media source.</param>
+ /// <param name="profile">Device profile.</param>
+ /// <param name="type">Dlna profile type.</param>
+ public void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type)
+ {
+ mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, mediaSource.Path, profile, type);
+ }
+
+ private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken)
+ {
+ var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken);
+ mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex;
+
+ mediaSource.TranscodeReasons = info.TranscodeReasons;
+
+ foreach (var profile in profiles)
+ {
+ foreach (var stream in mediaSource.MediaStreams)
+ {
+ if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index)
+ {
+ stream.DeliveryMethod = profile.DeliveryMethod;
+
+ if (profile.DeliveryMethod == SubtitleDeliveryMethod.External)
+ {
+ stream.DeliveryUrl = profile.Url.TrimStart('-');
+ stream.IsExternalUrl = profile.IsExternalUrl;
+ }
+ }
+ }
+ }
+ }
+
+ private long? GetMaxBitrate(long? clientMaxBitrate, User user, string ipAddress)
+ {
+ var maxBitrate = clientMaxBitrate;
+ var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0;
+
+ if (remoteClientMaxBitrate <= 0)
+ {
+ remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit;
+ }
+
+ if (remoteClientMaxBitrate > 0)
+ {
+ var isInLocalNetwork = _networkManager.IsInLocalNetwork(ipAddress);
+
+ _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, ipAddress, isInLocalNetwork);
+ if (!isInLocalNetwork)
+ {
+ maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate);
+ }
+ }
+
+ return maxBitrate;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs b/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs
new file mode 100644
index 000000000..e00ed3304
--- /dev/null
+++ b/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Buffers;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.PlaybackDtos;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.IO;
+
+namespace Jellyfin.Api.Helpers
+{
+ /// <summary>
+ /// Progressive file copier.
+ /// </summary>
+ public class ProgressiveFileCopier
+ {
+ private readonly TranscodingJobDto? _job;
+ private readonly string? _path;
+ private readonly CancellationToken _cancellationToken;
+ private readonly IDirectStreamProvider? _directStreamProvider;
+ private readonly TranscodingJobHelper _transcodingJobHelper;
+ private long _bytesWritten;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ProgressiveFileCopier"/> class.
+ /// </summary>
+ /// <param name="path">The path to copy from.</param>
+ /// <param name="job">The transcoding job.</param>
+ /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/>.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ public ProgressiveFileCopier(string path, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, CancellationToken cancellationToken)
+ {
+ _path = path;
+ _job = job;
+ _cancellationToken = cancellationToken;
+ _transcodingJobHelper = transcodingJobHelper;
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ProgressiveFileCopier"/> class.
+ /// </summary>
+ /// <param name="directStreamProvider">Instance of the <see cref="IDirectStreamProvider"/> interface.</param>
+ /// <param name="job">The transcoding job.</param>
+ /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/>.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ public ProgressiveFileCopier(IDirectStreamProvider directStreamProvider, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, CancellationToken cancellationToken)
+ {
+ _directStreamProvider = directStreamProvider;
+ _job = job;
+ _cancellationToken = cancellationToken;
+ _transcodingJobHelper = transcodingJobHelper;
+ }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether allow read end of file.
+ /// </summary>
+ public bool AllowEndOfFile { get; set; } = true;
+
+ /// <summary>
+ /// Gets or sets copy start position.
+ /// </summary>
+ public long StartPosition { get; set; }
+
+ /// <summary>
+ /// Write source stream to output.
+ /// </summary>
+ /// <param name="outputStream">Output stream.</param>
+ /// <param name="cancellationToken">Cancellation token.</param>
+ /// <returns>A <see cref="Task"/>.</returns>
+ public async Task WriteToAsync(Stream outputStream, CancellationToken cancellationToken)
+ {
+ cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationToken).Token;
+
+ try
+ {
+ if (_directStreamProvider != null)
+ {
+ await _directStreamProvider.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ var fileOptions = FileOptions.SequentialScan;
+ var allowAsyncFileRead = false;
+
+ // use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ fileOptions |= FileOptions.Asynchronous;
+ allowAsyncFileRead = true;
+ }
+
+ await using var inputStream = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions);
+
+ var eofCount = 0;
+ const int EmptyReadLimit = 20;
+ if (StartPosition > 0)
+ {
+ inputStream.Position = StartPosition;
+ }
+
+ while (eofCount < EmptyReadLimit || !AllowEndOfFile)
+ {
+ var bytesRead = await CopyToInternalAsync(inputStream, outputStream, allowAsyncFileRead, cancellationToken).ConfigureAwait(false);
+
+ if (bytesRead == 0)
+ {
+ if (_job == null || _job.HasExited)
+ {
+ eofCount++;
+ }
+
+ await Task.Delay(100, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ eofCount = 0;
+ }
+ }
+ }
+ finally
+ {
+ if (_job != null)
+ {
+ _transcodingJobHelper.OnTranscodeEndRequest(_job);
+ }
+ }
+ }
+
+ private async Task<int> CopyToInternalAsync(Stream source, Stream destination, bool readAsync, CancellationToken cancellationToken)
+ {
+ var array = ArrayPool<byte>.Shared.Rent(IODefaults.CopyToBufferSize);
+ try
+ {
+ int bytesRead;
+ int totalBytesRead = 0;
+
+ if (readAsync)
+ {
+ bytesRead = await source.ReadAsync(array, 0, array.Length, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ bytesRead = source.Read(array, 0, array.Length);
+ }
+
+ while (bytesRead != 0)
+ {
+ var bytesToWrite = bytesRead;
+
+ if (bytesToWrite > 0)
+ {
+ await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToWrite), cancellationToken).ConfigureAwait(false);
+
+ _bytesWritten += bytesRead;
+ totalBytesRead += bytesRead;
+
+ if (_job != null)
+ {
+ _job.BytesDownloaded = Math.Max(_job.BytesDownloaded ?? _bytesWritten, _bytesWritten);
+ }
+ }
+
+ if (readAsync)
+ {
+ bytesRead = await source.ReadAsync(array, 0, array.Length, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ bytesRead = source.Read(array, 0, array.Length);
+ }
+ }
+
+ return totalBytesRead;
+ }
+ finally
+ {
+ ArrayPool<byte>.Shared.Return(array);
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs
new file mode 100644
index 000000000..fb5c283f5
--- /dev/null
+++ b/Jellyfin.Api/Helpers/RequestHelpers.cs
@@ -0,0 +1,165 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Helpers
+{
+ /// <summary>
+ /// Request Extensions.
+ /// </summary>
+ public static class RequestHelpers
+ {
+ /// <summary>
+ /// Get Order By.
+ /// </summary>
+ /// <param name="sortBy">Sort By. Comma delimited string.</param>
+ /// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param>
+ /// <returns>Order By.</returns>
+ public static ValueTuple<string, SortOrder>[] GetOrderBy(string? sortBy, string? requestedSortOrder)
+ {
+ var val = sortBy;
+
+ if (string.IsNullOrEmpty(val))
+ {
+ return Array.Empty<ValueTuple<string, SortOrder>>();
+ }
+
+ var vals = val.Split(',');
+ if (string.IsNullOrWhiteSpace(requestedSortOrder))
+ {
+ requestedSortOrder = "Ascending";
+ }
+
+ var sortOrders = requestedSortOrder.Split(',');
+
+ var result = new ValueTuple<string, SortOrder>[vals.Length];
+
+ for (var i = 0; i < vals.Length; i++)
+ {
+ var sortOrderIndex = sortOrders.Length > i ? i : 0;
+
+ var sortOrderValue = sortOrders.Length > sortOrderIndex ? sortOrders[sortOrderIndex] : null;
+ var sortOrder = string.Equals(sortOrderValue, "Descending", StringComparison.OrdinalIgnoreCase)
+ ? SortOrder.Descending
+ : SortOrder.Ascending;
+
+ result[i] = new ValueTuple<string, SortOrder>(vals[i], sortOrder);
+ }
+
+ return result;
+ }
+
+ /// <summary>
+ /// Splits a string at a separating character into an array of substrings.
+ /// </summary>
+ /// <param name="value">The string to split.</param>
+ /// <param name="separator">The char that separates the substrings.</param>
+ /// <param name="removeEmpty">Option to remove empty substrings from the array.</param>
+ /// <returns>An array of the substrings.</returns>
+ internal static string[] Split(string? value, char separator, bool removeEmpty)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return Array.Empty<string>();
+ }
+
+ return removeEmpty
+ ? value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries)
+ : value.Split(separator);
+ }
+
+ /// <summary>
+ /// Checks if the user can update an entry.
+ /// </summary>
+ /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+ /// <param name="requestContext">The <see cref="HttpRequest"/>.</param>
+ /// <param name="userId">The user id.</param>
+ /// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param>
+ /// <returns>A <see cref="bool"/> whether the user can update the entry.</returns>
+ internal static bool AssertCanUpdateUser(IAuthorizationContext authContext, HttpRequest requestContext, Guid userId, bool restrictUserPreferences)
+ {
+ var auth = authContext.GetAuthorizationInfo(requestContext);
+
+ var authenticatedUser = auth.User;
+
+ // If they're going to update the record of another user, they must be an administrator
+ if ((!userId.Equals(auth.UserId) && !authenticatedUser.HasPermission(PermissionKind.IsAdministrator))
+ || (restrictUserPreferences && !authenticatedUser.EnableUserPreferenceAccess))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ internal static SessionInfo GetSession(ISessionManager sessionManager, IAuthorizationContext authContext, HttpRequest request)
+ {
+ var authorization = authContext.GetAuthorizationInfo(request);
+ var user = authorization.User;
+ var session = sessionManager.LogSessionActivity(
+ authorization.Client,
+ authorization.Version,
+ authorization.DeviceId,
+ authorization.Device,
+ request.HttpContext.GetNormalizedRemoteIp(),
+ user);
+
+ if (session == null)
+ {
+ throw new ArgumentException("Session not found.");
+ }
+
+ return session;
+ }
+
+ /// <summary>
+ /// Get Guid array from string.
+ /// </summary>
+ /// <param name="value">String value.</param>
+ /// <returns>Guid array.</returns>
+ internal static Guid[] GetGuids(string? value)
+ {
+ if (value == null)
+ {
+ return Array.Empty<Guid>();
+ }
+
+ return Split(value, ',', true)
+ .Select(i => new Guid(i))
+ .ToArray();
+ }
+
+ /// <summary>
+ /// Gets the item fields.
+ /// </summary>
+ /// <param name="fields">The fields string.</param>
+ /// <returns>IEnumerable{ItemFields}.</returns>
+ internal static ItemFields[] GetItemFields(string? fields)
+ {
+ if (string.IsNullOrEmpty(fields))
+ {
+ return Array.Empty<ItemFields>();
+ }
+
+ return Split(fields, ',', true)
+ .Select(v =>
+ {
+ if (Enum.TryParse(v, true, out ItemFields value))
+ {
+ return (ItemFields?)value;
+ }
+
+ return null;
+ }).Where(i => i.HasValue)
+ .Select(i => i!.Value)
+ .ToArray();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/SimilarItemsHelper.cs b/Jellyfin.Api/Helpers/SimilarItemsHelper.cs
new file mode 100644
index 000000000..b922e76cf
--- /dev/null
+++ b/Jellyfin.Api/Helpers/SimilarItemsHelper.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+
+namespace Jellyfin.Api.Helpers
+{
+ /// <summary>
+ /// The similar items helper class.
+ /// </summary>
+ public static class SimilarItemsHelper
+ {
+ internal static QueryResult<BaseItemDto> GetSimilarItemsResult(
+ DtoOptions dtoOptions,
+ IUserManager userManager,
+ ILibraryManager libraryManager,
+ IDtoService dtoService,
+ Guid? userId,
+ string id,
+ string? excludeArtistIds,
+ int? limit,
+ Type[] includeTypes,
+ Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore)
+ {
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? userManager.GetUserById(userId.Value)
+ : null;
+
+ var item = string.IsNullOrEmpty(id) ?
+ (!userId.Equals(Guid.Empty) ? libraryManager.GetUserRootFolder() :
+ libraryManager.RootFolder) : libraryManager.GetItemById(id);
+
+ var query = new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = includeTypes.Select(i => i.Name).ToArray(),
+ Recursive = true,
+ DtoOptions = dtoOptions,
+ ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds)
+ };
+
+ var inputItems = libraryManager.GetItemList(query);
+
+ var items = GetSimilaritems(item, libraryManager, inputItems, getSimilarityScore)
+ .ToList();
+
+ var returnItems = items;
+
+ if (limit.HasValue)
+ {
+ returnItems = returnItems.Take(limit.Value).ToList();
+ }
+
+ var dtos = dtoService.GetBaseItemDtos(returnItems, dtoOptions, user);
+
+ return new QueryResult<BaseItemDto>
+ {
+ Items = dtos,
+ TotalRecordCount = items.Count
+ };
+ }
+
+ /// <summary>
+ /// Gets the similaritems.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="inputItems">The input items.</param>
+ /// <param name="getSimilarityScore">The get similarity score.</param>
+ /// <returns>IEnumerable{BaseItem}.</returns>
+ private static IEnumerable<BaseItem> GetSimilaritems(
+ BaseItem item,
+ ILibraryManager libraryManager,
+ IEnumerable<BaseItem> inputItems,
+ Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore)
+ {
+ var itemId = item.Id;
+ inputItems = inputItems.Where(i => i.Id != itemId);
+ var itemPeople = libraryManager.GetPeople(item);
+ var allPeople = libraryManager.GetPeople(new InternalPeopleQuery
+ {
+ AppearsInItemId = item.Id
+ });
+
+ return inputItems.Select(i => new Tuple<BaseItem, int>(i, getSimilarityScore(item, itemPeople, allPeople, i)))
+ .Where(i => i.Item2 > 2)
+ .OrderByDescending(i => i.Item2)
+ .Select(i => i.Item1);
+ }
+
+ private static IEnumerable<string> GetTags(BaseItem item)
+ {
+ return item.Tags;
+ }
+
+ /// <summary>
+ /// Gets the similiarity score.
+ /// </summary>
+ /// <param name="item1">The item1.</param>
+ /// <param name="item1People">The item1 people.</param>
+ /// <param name="allPeople">All people.</param>
+ /// <param name="item2">The item2.</param>
+ /// <returns>System.Int32.</returns>
+ internal static int GetSimiliarityScore(BaseItem item1, List<PersonInfo> item1People, List<PersonInfo> allPeople, BaseItem item2)
+ {
+ var points = 0;
+
+ if (!string.IsNullOrEmpty(item1.OfficialRating) && string.Equals(item1.OfficialRating, item2.OfficialRating, StringComparison.OrdinalIgnoreCase))
+ {
+ points += 10;
+ }
+
+ // Find common genres
+ points += item1.Genres.Where(i => item2.Genres.Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 10);
+
+ // Find common tags
+ points += GetTags(item1).Where(i => GetTags(item2).Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 10);
+
+ // Find common studios
+ points += item1.Studios.Where(i => item2.Studios.Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 3);
+
+ var item2PeopleNames = allPeople.Where(i => i.ItemId == item2.Id)
+ .Select(i => i.Name)
+ .Where(i => !string.IsNullOrWhiteSpace(i))
+ .DistinctNames()
+ .ToDictionary(i => i, StringComparer.OrdinalIgnoreCase);
+
+ points += item1People.Where(i => item2PeopleNames.ContainsKey(i.Name)).Sum(i =>
+ {
+ if (string.Equals(i.Type, PersonType.Director, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Director, StringComparison.OrdinalIgnoreCase))
+ {
+ return 5;
+ }
+
+ if (string.Equals(i.Type, PersonType.Actor, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Actor, StringComparison.OrdinalIgnoreCase))
+ {
+ return 3;
+ }
+
+ if (string.Equals(i.Type, PersonType.Composer, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Composer, StringComparison.OrdinalIgnoreCase))
+ {
+ return 3;
+ }
+
+ if (string.Equals(i.Type, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase))
+ {
+ return 3;
+ }
+
+ if (string.Equals(i.Type, PersonType.Writer, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Writer, StringComparison.OrdinalIgnoreCase))
+ {
+ return 2;
+ }
+
+ return 1;
+ });
+
+ if (item1.ProductionYear.HasValue && item2.ProductionYear.HasValue)
+ {
+ var diff = Math.Abs(item1.ProductionYear.Value - item2.ProductionYear.Value);
+
+ // Add if they came out within the same decade
+ if (diff < 10)
+ {
+ points += 2;
+ }
+
+ // And more if within five years
+ if (diff < 5)
+ {
+ points += 2;
+ }
+ }
+
+ return points;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs
new file mode 100644
index 000000000..f4ec29bde
--- /dev/null
+++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs
@@ -0,0 +1,758 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.StreamingDtos;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Dlna;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Primitives;
+using Microsoft.Net.Http.Headers;
+
+namespace Jellyfin.Api.Helpers
+{
+ /// <summary>
+ /// The streaming helpers.
+ /// </summary>
+ public static class StreamingHelpers
+ {
+ /// <summary>
+ /// Gets the current streaming state.
+ /// </summary>
+ /// <param name="streamingRequest">The <see cref="StreamingRequestDto"/>.</param>
+ /// <param name="httpRequest">The <see cref="HttpRequest"/>.</param>
+ /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+ /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param>
+ /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param>
+ /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+ /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+ /// <param name="transcodingJobHelper">Initialized <see cref="TranscodingJobHelper"/>.</param>
+ /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
+ /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
+ /// <returns>A <see cref="Task"/> containing the current <see cref="StreamState"/>.</returns>
+ public static async Task<StreamState> GetStreamingState(
+ StreamingRequestDto streamingRequest,
+ HttpRequest httpRequest,
+ IAuthorizationContext authorizationContext,
+ IMediaSourceManager mediaSourceManager,
+ IUserManager userManager,
+ ILibraryManager libraryManager,
+ IServerConfigurationManager serverConfigurationManager,
+ IMediaEncoder mediaEncoder,
+ IFileSystem fileSystem,
+ ISubtitleEncoder subtitleEncoder,
+ IConfiguration configuration,
+ IDlnaManager dlnaManager,
+ IDeviceManager deviceManager,
+ TranscodingJobHelper transcodingJobHelper,
+ TranscodingJobType transcodingJobType,
+ CancellationToken cancellationToken)
+ {
+ EncodingHelper encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration);
+ // Parse the DLNA time seek header
+ if (!streamingRequest.StartTimeTicks.HasValue)
+ {
+ var timeSeek = httpRequest.Headers["TimeSeekRange.dlna.org"];
+
+ streamingRequest.StartTimeTicks = ParseTimeSeekHeader(timeSeek.ToString());
+ }
+
+ if (!string.IsNullOrWhiteSpace(streamingRequest.Params))
+ {
+ ParseParams(streamingRequest);
+ }
+
+ streamingRequest.StreamOptions = ParseStreamOptions(httpRequest.Query);
+
+ var url = httpRequest.Path.Value.Split('.').Last();
+
+ if (string.IsNullOrEmpty(streamingRequest.AudioCodec))
+ {
+ streamingRequest.AudioCodec = encodingHelper.InferAudioCodec(url);
+ }
+
+ var enableDlnaHeaders = !string.IsNullOrWhiteSpace(streamingRequest.Params) ||
+ string.Equals(httpRequest.Headers["GetContentFeatures.DLNA.ORG"], "1", StringComparison.OrdinalIgnoreCase);
+
+ var state = new StreamState(mediaSourceManager, transcodingJobType, transcodingJobHelper)
+ {
+ Request = streamingRequest,
+ RequestedUrl = url,
+ UserAgent = httpRequest.Headers[HeaderNames.UserAgent],
+ EnableDlnaHeaders = enableDlnaHeaders
+ };
+
+ var auth = authorizationContext.GetAuthorizationInfo(httpRequest);
+ if (!auth.UserId.Equals(Guid.Empty))
+ {
+ state.User = userManager.GetUserById(auth.UserId);
+ }
+
+ if (state.IsVideoRequest && !string.IsNullOrWhiteSpace(state.Request.VideoCodec))
+ {
+ state.SupportedVideoCodecs = state.Request.VideoCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
+ state.Request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault();
+ }
+
+ if (!string.IsNullOrWhiteSpace(streamingRequest.AudioCodec))
+ {
+ state.SupportedAudioCodecs = streamingRequest.AudioCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
+ state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => mediaEncoder.CanEncodeToAudioCodec(i))
+ ?? state.SupportedAudioCodecs.FirstOrDefault();
+ }
+
+ if (!string.IsNullOrWhiteSpace(streamingRequest.SubtitleCodec))
+ {
+ state.SupportedSubtitleCodecs = streamingRequest.SubtitleCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
+ state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(i => mediaEncoder.CanEncodeToSubtitleCodec(i))
+ ?? state.SupportedSubtitleCodecs.FirstOrDefault();
+ }
+
+ var item = libraryManager.GetItemById(streamingRequest.Id);
+
+ state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase);
+
+ MediaSourceInfo? mediaSource = null;
+ if (string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId))
+ {
+ var currentJob = !string.IsNullOrWhiteSpace(streamingRequest.PlaySessionId)
+ ? transcodingJobHelper.GetTranscodingJob(streamingRequest.PlaySessionId)
+ : null;
+
+ if (currentJob != null)
+ {
+ mediaSource = currentJob.MediaSource;
+ }
+
+ if (mediaSource == null)
+ {
+ var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false);
+
+ mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId)
+ ? mediaSources[0]
+ : mediaSources.Find(i => string.Equals(i.Id, streamingRequest.MediaSourceId, StringComparison.InvariantCulture));
+
+ if (mediaSource == null && Guid.Parse(streamingRequest.MediaSourceId) == streamingRequest.Id)
+ {
+ mediaSource = mediaSources[0];
+ }
+ }
+ }
+ else
+ {
+ var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false);
+ mediaSource = liveStreamInfo.Item1;
+ state.DirectStreamProvider = liveStreamInfo.Item2;
+ }
+
+ encodingHelper.AttachMediaSourceInfo(state, mediaSource, url);
+
+ string? containerInternal = Path.GetExtension(state.RequestedUrl);
+
+ if (!string.IsNullOrEmpty(streamingRequest.Container))
+ {
+ containerInternal = streamingRequest.Container;
+ }
+
+ if (string.IsNullOrEmpty(containerInternal))
+ {
+ containerInternal = streamingRequest.Static ?
+ StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, state.MediaPath, null, DlnaProfileType.Audio)
+ : GetOutputFileExtension(state);
+ }
+
+ state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.');
+
+ state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, state.AudioStream);
+
+ state.OutputAudioCodec = streamingRequest.AudioCodec;
+
+ state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec);
+
+ if (state.VideoRequest != null)
+ {
+ state.OutputVideoCodec = state.Request.VideoCodec;
+ state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec);
+
+ encodingHelper.TryStreamCopy(state);
+
+ if (state.OutputVideoBitrate.HasValue && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+ {
+ var resolution = ResolutionNormalizer.Normalize(
+ state.VideoStream?.BitRate,
+ state.VideoStream?.Width,
+ state.VideoStream?.Height,
+ state.OutputVideoBitrate.Value,
+ state.VideoStream?.Codec,
+ state.OutputVideoCodec,
+ state.VideoRequest.MaxWidth,
+ state.VideoRequest.MaxHeight);
+
+ state.VideoRequest.MaxWidth = resolution.MaxWidth;
+ state.VideoRequest.MaxHeight = resolution.MaxHeight;
+ }
+ }
+
+ ApplyDeviceProfileSettings(state, dlnaManager, deviceManager, httpRequest, streamingRequest.DeviceProfileId, streamingRequest.Static);
+
+ var ext = string.IsNullOrWhiteSpace(state.OutputContainer)
+ ? GetOutputFileExtension(state)
+ : ('.' + state.OutputContainer);
+
+ state.OutputFilePath = GetOutputFilePath(state, ext!, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId);
+
+ return state;
+ }
+
+ /// <summary>
+ /// Adds the dlna headers.
+ /// </summary>
+ /// <param name="state">The state.</param>
+ /// <param name="responseHeaders">The response headers.</param>
+ /// <param name="isStaticallyStreamed">if set to <c>true</c> [is statically streamed].</param>
+ /// <param name="startTimeTicks">The start time in ticks.</param>
+ /// <param name="request">The <see cref="HttpRequest"/>.</param>
+ /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+ public static void AddDlnaHeaders(
+ StreamState state,
+ IHeaderDictionary responseHeaders,
+ bool isStaticallyStreamed,
+ long? startTimeTicks,
+ HttpRequest request,
+ IDlnaManager dlnaManager)
+ {
+ if (!state.EnableDlnaHeaders)
+ {
+ return;
+ }
+
+ var profile = state.DeviceProfile;
+
+ StringValues transferMode = request.Headers["transferMode.dlna.org"];
+ responseHeaders.Add("transferMode.dlna.org", string.IsNullOrEmpty(transferMode) ? "Streaming" : transferMode.ToString());
+ responseHeaders.Add("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*");
+
+ if (state.RunTimeTicks.HasValue)
+ {
+ if (string.Equals(request.Headers["getMediaInfo.sec"], "1", StringComparison.OrdinalIgnoreCase))
+ {
+ var ms = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalMilliseconds;
+ responseHeaders.Add("MediaInfo.sec", string.Format(
+ CultureInfo.InvariantCulture,
+ "SEC_Duration={0};",
+ Convert.ToInt32(ms)));
+ }
+
+ if (!isStaticallyStreamed && profile != null)
+ {
+ AddTimeSeekResponseHeaders(state, responseHeaders, startTimeTicks);
+ }
+ }
+
+ if (profile == null)
+ {
+ profile = dlnaManager.GetDefaultProfile();
+ }
+
+ var audioCodec = state.ActualOutputAudioCodec;
+
+ if (!state.IsVideoRequest)
+ {
+ responseHeaders.Add("contentFeatures.dlna.org", new ContentFeatureBuilder(profile).BuildAudioHeader(
+ state.OutputContainer,
+ audioCodec,
+ state.OutputAudioBitrate,
+ state.OutputAudioSampleRate,
+ state.OutputAudioChannels,
+ state.OutputAudioBitDepth,
+ isStaticallyStreamed,
+ state.RunTimeTicks,
+ state.TranscodeSeekInfo));
+ }
+ else
+ {
+ var videoCodec = state.ActualOutputVideoCodec;
+
+ responseHeaders.Add(
+ "contentFeatures.dlna.org",
+ new ContentFeatureBuilder(profile).BuildVideoHeader(state.OutputContainer, videoCodec, audioCodec, state.OutputWidth, state.OutputHeight, state.TargetVideoBitDepth, state.OutputVideoBitrate, state.TargetTimestamp, isStaticallyStreamed, state.RunTimeTicks, state.TargetVideoProfile, state.TargetVideoLevel, state.TargetFramerate, state.TargetPacketLength, state.TranscodeSeekInfo, state.IsTargetAnamorphic, state.IsTargetInterlaced, state.TargetRefFrames, state.TargetVideoStreamCount, state.TargetAudioStreamCount, state.TargetVideoCodecTag, state.IsTargetAVC).FirstOrDefault() ?? string.Empty);
+ }
+ }
+
+ /// <summary>
+ /// Parses the time seek header.
+ /// </summary>
+ /// <param name="value">The time seek header string.</param>
+ /// <returns>A nullable <see cref="long"/> representing the seek time in ticks.</returns>
+ private static long? ParseTimeSeekHeader(ReadOnlySpan<char> value)
+ {
+ if (value.IsEmpty)
+ {
+ return null;
+ }
+
+ const string npt = "npt=";
+ if (!value.StartsWith(npt, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException("Invalid timeseek header");
+ }
+
+ var index = value.IndexOf('-');
+ value = index == -1
+ ? value.Slice(npt.Length)
+ : value.Slice(npt.Length, index - npt.Length);
+ if (value.IndexOf(':') == -1)
+ {
+ // Parses npt times in the format of '417.33'
+ if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds))
+ {
+ return TimeSpan.FromSeconds(seconds).Ticks;
+ }
+
+ throw new ArgumentException("Invalid timeseek header");
+ }
+
+ try
+ {
+ // Parses npt times in the format of '10:19:25.7'
+ return TimeSpan.Parse(value).Ticks;
+ }
+ catch
+ {
+ throw new ArgumentException("Invalid timeseek header");
+ }
+ }
+
+ /// <summary>
+ /// Parses query parameters as StreamOptions.
+ /// </summary>
+ /// <param name="queryString">The query string.</param>
+ /// <returns>A <see cref="Dictionary{String,String}"/> containing the stream options.</returns>
+ private static Dictionary<string, string> ParseStreamOptions(IQueryCollection queryString)
+ {
+ Dictionary<string, string> streamOptions = new Dictionary<string, string>();
+ foreach (var param in queryString)
+ {
+ if (char.IsLower(param.Key[0]))
+ {
+ // This was probably not parsed initially and should be a StreamOptions
+ // or the generated URL should correctly serialize it
+ // TODO: This should be incorporated either in the lower framework for parsing requests
+ streamOptions[param.Key] = param.Value;
+ }
+ }
+
+ return streamOptions;
+ }
+
+ /// <summary>
+ /// Adds the dlna time seek headers to the response.
+ /// </summary>
+ /// <param name="state">The current <see cref="StreamState"/>.</param>
+ /// <param name="responseHeaders">The <see cref="IHeaderDictionary"/> of the response.</param>
+ /// <param name="startTimeTicks">The start time in ticks.</param>
+ private static void AddTimeSeekResponseHeaders(StreamState state, IHeaderDictionary responseHeaders, long? startTimeTicks)
+ {
+ var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks!.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture);
+ var startSeconds = TimeSpan.FromTicks(startTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture);
+
+ responseHeaders.Add("TimeSeekRange.dlna.org", string.Format(
+ CultureInfo.InvariantCulture,
+ "npt={0}-{1}/{1}",
+ startSeconds,
+ runtimeSeconds));
+ responseHeaders.Add("X-AvailableSeekRange", string.Format(
+ CultureInfo.InvariantCulture,
+ "1 npt={0}-{1}",
+ startSeconds,
+ runtimeSeconds));
+ }
+
+ /// <summary>
+ /// Gets the output file extension.
+ /// </summary>
+ /// <param name="state">The state.</param>
+ /// <returns>System.String.</returns>
+ private static string? GetOutputFileExtension(StreamState state)
+ {
+ var ext = Path.GetExtension(state.RequestedUrl);
+
+ if (!string.IsNullOrEmpty(ext))
+ {
+ return ext;
+ }
+
+ // Try to infer based on the desired video codec
+ if (state.IsVideoRequest)
+ {
+ var videoCodec = state.Request.VideoCodec;
+
+ if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(videoCodec, "h265", StringComparison.OrdinalIgnoreCase))
+ {
+ return ".ts";
+ }
+
+ if (string.Equals(videoCodec, "theora", StringComparison.OrdinalIgnoreCase))
+ {
+ return ".ogv";
+ }
+
+ if (string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase))
+ {
+ return ".webm";
+ }
+
+ if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase))
+ {
+ return ".asf";
+ }
+ }
+
+ // Try to infer based on the desired audio codec
+ if (!state.IsVideoRequest)
+ {
+ var audioCodec = state.Request.AudioCodec;
+
+ if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase))
+ {
+ return ".aac";
+ }
+
+ if (string.Equals("mp3", audioCodec, StringComparison.OrdinalIgnoreCase))
+ {
+ return ".mp3";
+ }
+
+ if (string.Equals("vorbis", audioCodec, StringComparison.OrdinalIgnoreCase))
+ {
+ return ".ogg";
+ }
+
+ if (string.Equals("wma", audioCodec, StringComparison.OrdinalIgnoreCase))
+ {
+ return ".wma";
+ }
+ }
+
+ return null;
+ }
+
+ /// <summary>
+ /// Gets the output file path for transcoding.
+ /// </summary>
+ /// <param name="state">The current <see cref="StreamState"/>.</param>
+ /// <param name="outputFileExtension">The file extension of the output file.</param>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ /// <param name="deviceId">The device id.</param>
+ /// <param name="playSessionId">The play session id.</param>
+ /// <returns>The complete file path, including the folder, for the transcoding file.</returns>
+ private static string GetOutputFilePath(StreamState state, string outputFileExtension, IServerConfigurationManager serverConfigurationManager, string? deviceId, string? playSessionId)
+ {
+ var data = $"{state.MediaPath}-{state.UserAgent}-{deviceId!}-{playSessionId!}";
+
+ var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+ var ext = outputFileExtension?.ToLowerInvariant();
+ var folder = serverConfigurationManager.GetTranscodePath();
+
+ return Path.Combine(folder, filename + ext);
+ }
+
+ private static void ApplyDeviceProfileSettings(StreamState state, IDlnaManager dlnaManager, IDeviceManager deviceManager, HttpRequest request, string? deviceProfileId, bool? @static)
+ {
+ var headers = request.Headers;
+
+ if (!string.IsNullOrWhiteSpace(deviceProfileId))
+ {
+ state.DeviceProfile = dlnaManager.GetProfile(deviceProfileId);
+ }
+ else if (!string.IsNullOrWhiteSpace(deviceProfileId))
+ {
+ var caps = deviceManager.GetCapabilities(deviceProfileId);
+
+ state.DeviceProfile = caps == null ? dlnaManager.GetProfile(headers) : caps.DeviceProfile;
+ }
+
+ var profile = state.DeviceProfile;
+
+ if (profile == null)
+ {
+ // Don't use settings from the default profile.
+ // Only use a specific profile if it was requested.
+ return;
+ }
+
+ var audioCodec = state.ActualOutputAudioCodec;
+ var videoCodec = state.ActualOutputVideoCodec;
+
+ var mediaProfile = !state.IsVideoRequest
+ ? profile.GetAudioMediaProfile(state.OutputContainer, audioCodec, state.OutputAudioChannels, state.OutputAudioBitrate, state.OutputAudioSampleRate, state.OutputAudioBitDepth)
+ : profile.GetVideoMediaProfile(
+ state.OutputContainer,
+ audioCodec,
+ videoCodec,
+ state.OutputWidth,
+ state.OutputHeight,
+ state.TargetVideoBitDepth,
+ state.OutputVideoBitrate,
+ state.TargetVideoProfile,
+ state.TargetVideoLevel,
+ state.TargetFramerate,
+ state.TargetPacketLength,
+ state.TargetTimestamp,
+ state.IsTargetAnamorphic,
+ state.IsTargetInterlaced,
+ state.TargetRefFrames,
+ state.TargetVideoStreamCount,
+ state.TargetAudioStreamCount,
+ state.TargetVideoCodecTag,
+ state.IsTargetAVC);
+
+ if (mediaProfile != null)
+ {
+ state.MimeType = mediaProfile.MimeType;
+ }
+
+ if (!(@static.HasValue && @static.Value))
+ {
+ var transcodingProfile = !state.IsVideoRequest ? profile.GetAudioTranscodingProfile(state.OutputContainer, audioCodec) : profile.GetVideoTranscodingProfile(state.OutputContainer, audioCodec, videoCodec);
+
+ if (transcodingProfile != null)
+ {
+ state.EstimateContentLength = transcodingProfile.EstimateContentLength;
+ // state.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode;
+ state.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo;
+
+ if (state.VideoRequest != null)
+ {
+ state.VideoRequest.CopyTimestamps = transcodingProfile.CopyTimestamps;
+ state.VideoRequest.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest;
+ }
+ }
+ }
+ }
+
+ /// <summary>
+ /// Parses the parameters.
+ /// </summary>
+ /// <param name="request">The request.</param>
+ private static void ParseParams(StreamingRequestDto request)
+ {
+ if (string.IsNullOrEmpty(request.Params))
+ {
+ return;
+ }
+
+ var vals = request.Params.Split(';');
+
+ var videoRequest = request as VideoRequestDto;
+
+ for (var i = 0; i < vals.Length; i++)
+ {
+ var val = vals[i];
+
+ if (string.IsNullOrWhiteSpace(val))
+ {
+ continue;
+ }
+
+ switch (i)
+ {
+ case 0:
+ request.DeviceProfileId = val;
+ break;
+ case 1:
+ request.DeviceId = val;
+ break;
+ case 2:
+ request.MediaSourceId = val;
+ break;
+ case 3:
+ request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+ break;
+ case 4:
+ if (videoRequest != null)
+ {
+ videoRequest.VideoCodec = val;
+ }
+
+ break;
+ case 5:
+ request.AudioCodec = val;
+ break;
+ case 6:
+ if (videoRequest != null)
+ {
+ videoRequest.AudioStreamIndex = int.Parse(val, CultureInfo.InvariantCulture);
+ }
+
+ break;
+ case 7:
+ if (videoRequest != null)
+ {
+ videoRequest.SubtitleStreamIndex = int.Parse(val, CultureInfo.InvariantCulture);
+ }
+
+ break;
+ case 8:
+ if (videoRequest != null)
+ {
+ videoRequest.VideoBitRate = int.Parse(val, CultureInfo.InvariantCulture);
+ }
+
+ break;
+ case 9:
+ request.AudioBitRate = int.Parse(val, CultureInfo.InvariantCulture);
+ break;
+ case 10:
+ request.MaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture);
+ break;
+ case 11:
+ if (videoRequest != null)
+ {
+ videoRequest.MaxFramerate = float.Parse(val, CultureInfo.InvariantCulture);
+ }
+
+ break;
+ case 12:
+ if (videoRequest != null)
+ {
+ videoRequest.MaxWidth = int.Parse(val, CultureInfo.InvariantCulture);
+ }
+
+ break;
+ case 13:
+ if (videoRequest != null)
+ {
+ videoRequest.MaxHeight = int.Parse(val, CultureInfo.InvariantCulture);
+ }
+
+ break;
+ case 14:
+ request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture);
+ break;
+ case 15:
+ if (videoRequest != null)
+ {
+ videoRequest.Level = val;
+ }
+
+ break;
+ case 16:
+ if (videoRequest != null)
+ {
+ videoRequest.MaxRefFrames = int.Parse(val, CultureInfo.InvariantCulture);
+ }
+
+ break;
+ case 17:
+ if (videoRequest != null)
+ {
+ videoRequest.MaxVideoBitDepth = int.Parse(val, CultureInfo.InvariantCulture);
+ }
+
+ break;
+ case 18:
+ if (videoRequest != null)
+ {
+ videoRequest.Profile = val;
+ }
+
+ break;
+ case 19:
+ // cabac no longer used
+ break;
+ case 20:
+ request.PlaySessionId = val;
+ break;
+ case 21:
+ // api_key
+ break;
+ case 22:
+ request.LiveStreamId = val;
+ break;
+ case 23:
+ // Duplicating ItemId because of MediaMonkey
+ break;
+ case 24:
+ if (videoRequest != null)
+ {
+ videoRequest.CopyTimestamps = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+ }
+
+ break;
+ case 25:
+ if (!string.IsNullOrWhiteSpace(val) && videoRequest != null)
+ {
+ if (Enum.TryParse(val, out SubtitleDeliveryMethod method))
+ {
+ videoRequest.SubtitleMethod = method;
+ }
+ }
+
+ break;
+ case 26:
+ request.TranscodingMaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture);
+ break;
+ case 27:
+ if (videoRequest != null)
+ {
+ videoRequest.EnableSubtitlesInManifest = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+ }
+
+ break;
+ case 28:
+ request.Tag = val;
+ break;
+ case 29:
+ if (videoRequest != null)
+ {
+ videoRequest.RequireAvc = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+ }
+
+ break;
+ case 30:
+ request.SubtitleCodec = val;
+ break;
+ case 31:
+ if (videoRequest != null)
+ {
+ videoRequest.RequireNonAnamorphic = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+ }
+
+ break;
+ case 32:
+ if (videoRequest != null)
+ {
+ videoRequest.DeInterlace = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+ }
+
+ break;
+ case 33:
+ request.TranscodeReasons = val;
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs
new file mode 100644
index 000000000..0db1fabff
--- /dev/null
+++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs
@@ -0,0 +1,888 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.PlaybackDtos;
+using Jellyfin.Api.Models.StreamingDtos;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Session;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Helpers
+{
+ /// <summary>
+ /// Transcoding job helpers.
+ /// </summary>
+ public class TranscodingJobHelper : IDisposable
+ {
+ /// <summary>
+ /// The active transcoding jobs.
+ /// </summary>
+ private static readonly List<TranscodingJobDto> _activeTranscodingJobs = new List<TranscodingJobDto>();
+
+ /// <summary>
+ /// The transcoding locks.
+ /// </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 IServerConfigurationManager _serverConfigurationManager;
+ private readonly ISessionManager _sessionManager;
+ private readonly ILoggerFactory _loggerFactory;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="TranscodingJobHelper"/> class.
+ /// </summary>
+ /// <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,
+ 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();
+
+ sessionManager!.PlaybackProgress += OnPlaybackProgress;
+ sessionManager!.PlaybackStart += OnPlaybackProgress;
+ }
+
+ /// <summary>
+ /// Get transcoding job.
+ /// </summary>
+ /// <param name="playSessionId">Playback session id.</param>
+ /// <returns>The transcoding job.</returns>
+ public TranscodingJobDto GetTranscodingJob(string playSessionId)
+ {
+ lock (_activeTranscodingJobs)
+ {
+ return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase));
+ }
+ }
+
+ /// <summary>
+ /// Get transcoding job.
+ /// </summary>
+ /// <param name="path">Path to the transcoding file.</param>
+ /// <param name="type">The <see cref="TranscodingJobType"/>.</param>
+ /// <returns>The transcoding job.</returns>
+ public TranscodingJobDto GetTranscodingJob(string path, TranscodingJobType type)
+ {
+ lock (_activeTranscodingJobs)
+ {
+ return _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase));
+ }
+ }
+
+ /// <summary>
+ /// Ping transcoding job.
+ /// </summary>
+ /// <param name="playSessionId">Play session id.</param>
+ /// <param name="isUserPaused">Is user paused.</param>
+ /// <exception cref="ArgumentNullException">Play session id is null.</exception>
+ public void PingTranscodingJob(string playSessionId, bool? isUserPaused)
+ {
+ if (string.IsNullOrEmpty(playSessionId))
+ {
+ throw new ArgumentNullException(nameof(playSessionId));
+ }
+
+ _logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused);
+
+ List<TranscodingJobDto> jobs;
+
+ lock (_activeTranscodingJobs)
+ {
+ // This is really only needed for HLS.
+ // Progressive streams can stop on their own reliably
+ jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList();
+ }
+
+ foreach (var job in jobs)
+ {
+ if (isUserPaused.HasValue)
+ {
+ _logger.LogDebug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id);
+ job.IsUserPaused = isUserPaused.Value;
+ }
+
+ PingTimer(job, true);
+ }
+ }
+
+ private void PingTimer(TranscodingJobDto job, bool isProgressCheckIn)
+ {
+ if (job.HasExited)
+ {
+ job.StopKillTimer();
+ return;
+ }
+
+ var timerDuration = 10000;
+
+ if (job.Type != TranscodingJobType.Progressive)
+ {
+ timerDuration = 60000;
+ }
+
+ job.PingTimeout = timerDuration;
+ job.LastPingDate = DateTime.UtcNow;
+
+ // Don't start the timer for playback checkins with progressive streaming
+ if (job.Type != TranscodingJobType.Progressive || !isProgressCheckIn)
+ {
+ job.StartKillTimer(OnTranscodeKillTimerStopped);
+ }
+ else
+ {
+ job.ChangeKillTimerIfStarted();
+ }
+ }
+
+ /// <summary>
+ /// Called when [transcode kill timer stopped].
+ /// </summary>
+ /// <param name="state">The state.</param>
+ private async void OnTranscodeKillTimerStopped(object state)
+ {
+ var job = (TranscodingJobDto)state;
+
+ if (!job.HasExited && job.Type != TranscodingJobType.Progressive)
+ {
+ var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds;
+
+ if (timeSinceLastPing < job.PingTimeout)
+ {
+ job.StartKillTimer(OnTranscodeKillTimerStopped, job.PingTimeout);
+ return;
+ }
+ }
+
+ _logger.LogInformation("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId);
+
+ await KillTranscodingJob(job, true, path => true).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Kills the single transcoding job.
+ /// </summary>
+ /// <param name="deviceId">The device id.</param>
+ /// <param name="playSessionId">The play session identifier.</param>
+ /// <param name="deleteFiles">The delete files.</param>
+ /// <returns>Task.</returns>
+ public Task KillTranscodingJobs(string deviceId, string? playSessionId, Func<string, bool> deleteFiles)
+ {
+ return KillTranscodingJobs(
+ j => string.IsNullOrWhiteSpace(playSessionId)
+ ? string.Equals(deviceId, j.DeviceId, StringComparison.OrdinalIgnoreCase)
+ : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase), deleteFiles);
+ }
+
+ /// <summary>
+ /// Kills the transcoding jobs.
+ /// </summary>
+ /// <param name="killJob">The kill job.</param>
+ /// <param name="deleteFiles">The delete files.</param>
+ /// <returns>Task.</returns>
+ private Task KillTranscodingJobs(Func<TranscodingJobDto, bool> killJob, Func<string, bool> deleteFiles)
+ {
+ var jobs = new List<TranscodingJobDto>();
+
+ lock (_activeTranscodingJobs)
+ {
+ // This is really only needed for HLS.
+ // Progressive streams can stop on their own reliably
+ jobs.AddRange(_activeTranscodingJobs.Where(killJob));
+ }
+
+ if (jobs.Count == 0)
+ {
+ return Task.CompletedTask;
+ }
+
+ IEnumerable<Task> GetKillJobs()
+ {
+ foreach (var job in jobs)
+ {
+ yield return KillTranscodingJob(job, false, deleteFiles);
+ }
+ }
+
+ return Task.WhenAll(GetKillJobs());
+ }
+
+ /// <summary>
+ /// Kills the transcoding job.
+ /// </summary>
+ /// <param name="job">The job.</param>
+ /// <param name="closeLiveStream">if set to <c>true</c> [close live stream].</param>
+ /// <param name="delete">The delete.</param>
+ private async Task KillTranscodingJob(TranscodingJobDto job, bool closeLiveStream, Func<string, bool> delete)
+ {
+ job.DisposeKillTimer();
+
+ _logger.LogDebug("KillTranscodingJob - JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId);
+
+ lock (_activeTranscodingJobs)
+ {
+ _activeTranscodingJobs.Remove(job);
+
+ if (!job.CancellationTokenSource!.IsCancellationRequested)
+ {
+ job.CancellationTokenSource.Cancel();
+ }
+ }
+
+ lock (_transcodingLocks)
+ {
+ _transcodingLocks.Remove(job.Path!);
+ }
+
+ lock (job.ProcessLock!)
+ {
+ job.TranscodingThrottler?.Stop().GetAwaiter().GetResult();
+
+ var process = job.Process;
+
+ var hasExited = job.HasExited;
+
+ if (!hasExited)
+ {
+ try
+ {
+ _logger.LogInformation("Stopping ffmpeg process with q command for {Path}", job.Path);
+
+ process!.StandardInput.WriteLine("q");
+
+ // Need to wait because killing is asynchronous
+ if (!process.WaitForExit(5000))
+ {
+ _logger.LogInformation("Killing ffmpeg process for {Path}", job.Path);
+ process.Kill();
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ }
+ }
+ }
+
+ if (delete(job.Path!))
+ {
+ await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false);
+ }
+
+ if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId))
+ {
+ try
+ {
+ await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error closing live stream for {Path}", job.Path);
+ }
+ }
+ }
+
+ private async Task DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs)
+ {
+ if (retryCount >= 10)
+ {
+ return;
+ }
+
+ _logger.LogInformation("Deleting partial stream file(s) {Path}", path);
+
+ await Task.Delay(delayMs).ConfigureAwait(false);
+
+ try
+ {
+ if (jobType == TranscodingJobType.Progressive)
+ {
+ DeleteProgressivePartialStreamFiles(path);
+ }
+ else
+ {
+ DeleteHlsPartialStreamFiles(path);
+ }
+ }
+ catch (IOException ex)
+ {
+ _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
+
+ await DeletePartialStreamFiles(path, jobType, retryCount + 1, 500).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
+ }
+ }
+
+ /// <summary>
+ /// Deletes the progressive partial stream files.
+ /// </summary>
+ /// <param name="outputFilePath">The output file path.</param>
+ private void DeleteProgressivePartialStreamFiles(string outputFilePath)
+ {
+ if (File.Exists(outputFilePath))
+ {
+ _fileSystem.DeleteFile(outputFilePath);
+ }
+ }
+
+ /// <summary>
+ /// Deletes the HLS partial stream files.
+ /// </summary>
+ /// <param name="outputFilePath">The output file path.</param>
+ private void DeleteHlsPartialStreamFiles(string outputFilePath)
+ {
+ var directory = Path.GetDirectoryName(outputFilePath);
+ var name = Path.GetFileNameWithoutExtension(outputFilePath);
+
+ var filesToDelete = _fileSystem.GetFilePaths(directory)
+ .Where(f => f.IndexOf(name, StringComparison.OrdinalIgnoreCase) != -1);
+
+ List<Exception>? exs = null;
+ foreach (var file in filesToDelete)
+ {
+ try
+ {
+ _logger.LogDebug("Deleting HLS file {0}", file);
+ _fileSystem.DeleteFile(file);
+ }
+ catch (IOException ex)
+ {
+ (exs ??= new List<Exception>(4)).Add(ex);
+ _logger.LogError(ex, "Error deleting HLS file {Path}", file);
+ }
+ }
+
+ if (exs != null)
+ {
+ throw new AggregateException("Error deleting HLS files", exs);
+ }
+ }
+
+ /// <summary>
+ /// Report the transcoding progress to the session manager.
+ /// </summary>
+ /// <param name="job">The <see cref="TranscodingJobDto"/> of which the progress will be reported.</param>
+ /// <param name="state">The <see cref="StreamState"/> of the current transcoding job.</param>
+ /// <param name="transcodingPosition">The current transcoding position.</param>
+ /// <param name="framerate">The framerate of the transcoding job.</param>
+ /// <param name="percentComplete">The completion percentage of the transcode.</param>
+ /// <param name="bytesTranscoded">The number of bytes transcoded.</param>
+ /// <param name="bitRate">The bitrate of the transcoding job.</param>
+ public void ReportTranscodingProgress(
+ TranscodingJobDto job,
+ StreamState state,
+ TimeSpan? transcodingPosition,
+ float? framerate,
+ double? percentComplete,
+ long? bytesTranscoded,
+ int? bitRate)
+ {
+ var ticks = transcodingPosition?.Ticks;
+
+ if (job != null)
+ {
+ job.Framerate = framerate;
+ job.CompletionPercentage = percentComplete;
+ job.TranscodingPositionTicks = ticks;
+ job.BytesTranscoded = bytesTranscoded;
+ job.BitRate = bitRate;
+ }
+
+ var deviceId = state.Request.DeviceId;
+
+ if (!string.IsNullOrWhiteSpace(deviceId))
+ {
+ var audioCodec = state.ActualOutputAudioCodec;
+ var videoCodec = state.ActualOutputVideoCodec;
+
+ _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo
+ {
+ Bitrate = bitRate ?? state.TotalOutputBitrate,
+ AudioCodec = audioCodec,
+ VideoCodec = videoCodec,
+ Container = state.OutputContainer,
+ Framerate = framerate,
+ CompletionPercentage = percentComplete,
+ Width = state.OutputWidth,
+ Height = state.OutputHeight,
+ AudioChannels = state.OutputAudioChannels,
+ IsAudioDirect = EncodingHelper.IsCopyCodec(state.OutputAudioCodec),
+ IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec),
+ TranscodeReasons = state.TranscodeReasons
+ });
+ }
+ }
+
+ /// <summary>
+ /// Starts the FFMPEG.
+ /// </summary>
+ /// <param name="state">The state.</param>
+ /// <param name="outputPath">The output path.</param>
+ /// <param name="commandLineArguments">The command line arguments for ffmpeg.</param>
+ /// <param name="request">The <see cref="HttpRequest"/>.</param>
+ /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
+ /// <param name="cancellationTokenSource">The cancellation token source.</param>
+ /// <param name="workingDirectory">The working directory.</param>
+ /// <returns>Task.</returns>
+ public async Task<TranscodingJobDto> StartFfMpeg(
+ StreamState state,
+ string outputPath,
+ string commandLineArguments,
+ HttpRequest request,
+ TranscodingJobType transcodingJobType,
+ CancellationTokenSource cancellationTokenSource,
+ string? workingDirectory = null)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(outputPath));
+
+ await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false);
+
+ if (state.VideoRequest != null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+ {
+ var auth = _authorizationContext.GetAuthorizationInfo(request);
+ if (auth.User != null && !auth.User.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding))
+ {
+ this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
+
+ throw new ArgumentException("User does not have access to video transcoding");
+ }
+ }
+
+ if (string.IsNullOrEmpty(_mediaEncoder.EncoderPath))
+ {
+ throw new ArgumentException("FFMPEG path not set.");
+ }
+
+ 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>
+ /// Called when [transcode end].
+ /// </summary>
+ /// <param name="job">The transcode job.</param>
+ public void OnTranscodeEndRequest(TranscodingJobDto job)
+ {
+ job.ActiveRequestCount--;
+ _logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", job.ActiveRequestCount);
+ if (job.ActiveRequestCount <= 0)
+ {
+ PingTimer(job, false);
+ }
+ }
+
+ /// <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)
+ {
+ 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;
+ }
+ }
+
+ private void OnPlaybackProgress(object sender, PlaybackProgressEventArgs e)
+ {
+ if (!string.IsNullOrWhiteSpace(e.PlaySessionId))
+ {
+ PingTranscodingJob(e.PlaySessionId, e.IsPaused);
+ }
+ }
+
+ /// <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);
+ }
+ }
+
+ /// <summary>
+ /// Dispose transcoding job helper.
+ /// </summary>
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ /// <summary>
+ /// Dispose throttler.
+ /// </summary>
+ /// <param name="disposing">Disposing.</param>
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _loggerFactory.Dispose();
+ _sessionManager!.PlaybackProgress -= OnPlaybackProgress;
+ _sessionManager!.PlaybackStart -= OnPlaybackProgress;
+ }
+ }
+ }
+}