aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Api/Helpers
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Api/Helpers')
-rw-r--r--Jellyfin.Api/Helpers/ClaimHelpers.cs75
-rw-r--r--Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs138
-rw-r--r--Jellyfin.Api/Helpers/ProgressiveFileCopier.cs175
-rw-r--r--Jellyfin.Api/Helpers/RequestHelpers.cs176
-rw-r--r--Jellyfin.Api/Helpers/SimilarItemsHelper.cs182
-rw-r--r--Jellyfin.Api/Helpers/StreamingHelpers.cs758
-rw-r--r--Jellyfin.Api/Helpers/TranscodingJobHelper.cs854
7 files changed, 2358 insertions, 0 deletions
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/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
new file mode 100644
index 000000000..a463783e0
--- /dev/null
+++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
@@ -0,0 +1,138 @@
+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="controller">The <see cref="ControllerBase"/> managing the response.</param>
+ /// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param>
+ /// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns>
+ public static async Task<ActionResult> GetStaticRemoteStreamResult(
+ StreamState state,
+ bool isHeadRequest,
+ ControllerBase controller,
+ HttpClient httpClient)
+ {
+ if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent))
+ {
+ httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent);
+ }
+
+ using var response = await httpClient.GetAsync(state.MediaPath).ConfigureAwait(false);
+ var contentType = response.Content.Headers.ContentType.ToString();
+
+ controller.Response.Headers[HeaderNames.AcceptRanges] = "none";
+
+ if (isHeadRequest)
+ {
+ return controller.File(Array.Empty<byte>(), contentType);
+ }
+
+ return controller.File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), contentType);
+ }
+
+ /// <summary>
+ /// Returns a static file from the server.
+ /// </summary>
+ /// <param name="path">The path to the file.</param>
+ /// <param name="contentType">The content type of the file.</param>
+ /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param>
+ /// <param name="controller">The <see cref="ControllerBase"/> managing the response.</param>
+ /// <returns>An <see cref="ActionResult"/> the file.</returns>
+ public static ActionResult GetStaticFileResult(
+ string path,
+ string contentType,
+ bool isHeadRequest,
+ ControllerBase controller)
+ {
+ controller.Response.ContentType = contentType;
+
+ // if the request is a head request, return a NoContent result with the same headers as it would with a GET request
+ if (isHeadRequest)
+ {
+ return controller.NoContent();
+ }
+
+ return controller.PhysicalFile(path, contentType);
+ }
+
+ /// <summary>
+ /// Returns a transcoded file from the server.
+ /// </summary>
+ /// <param name="state">The current <see cref="StreamState"/>.</param>
+ /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param>
+ /// <param name="controller">The <see cref="ControllerBase"/> managing the response.</param>
+ /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param>
+ /// <param name="ffmpegCommandLineArguments">The command line arguments to start ffmpeg.</param>
+ /// <param name="request">The <see cref="HttpRequest"/> starting the transcoding.</param>
+ /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
+ /// <param name="cancellationTokenSource">The <see cref="CancellationTokenSource"/>.</param>
+ /// <returns>A <see cref="Task{ActionResult}"/> containing the transcoded file.</returns>
+ public static async Task<ActionResult> GetTranscodedFile(
+ StreamState state,
+ bool isHeadRequest,
+ ControllerBase controller,
+ TranscodingJobHelper transcodingJobHelper,
+ string ffmpegCommandLineArguments,
+ HttpRequest request,
+ TranscodingJobType transcodingJobType,
+ CancellationTokenSource cancellationTokenSource)
+ {
+ // Use the command line args with a dummy playlist path
+ var outputPath = state.OutputFilePath;
+
+ controller.Response.Headers[HeaderNames.AcceptRanges] = "none";
+
+ var contentType = state.GetMimeType(outputPath);
+
+ // Headers only
+ if (isHeadRequest)
+ {
+ return controller.File(Array.Empty<byte>(), contentType);
+ }
+
+ var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath);
+ await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
+ try
+ {
+ TranscodingJobDto? job;
+ if (!File.Exists(outputPath))
+ {
+ job = await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false);
+ }
+ else
+ {
+ job = transcodingJobHelper.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive);
+ state.Dispose();
+ }
+
+ var memoryStream = new MemoryStream();
+ await new ProgressiveFileCopier(outputPath, job, transcodingJobHelper, CancellationToken.None).WriteToAsync(memoryStream, CancellationToken.None).ConfigureAwait(false);
+ memoryStream.Position = 0;
+ return controller.File(memoryStream, contentType);
+ }
+ finally
+ {
+ transcodingLock.Release();
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs b/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs
new file mode 100644
index 000000000..432df9708
--- /dev/null
+++ b/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs
@@ -0,0 +1,175 @@
+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);
+ 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;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs
new file mode 100644
index 000000000..299c7d4aa
--- /dev/null
+++ b/Jellyfin.Api/Helpers/RequestHelpers.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Entities;
+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>
+ /// Get parsed filters.
+ /// </summary>
+ /// <param name="filters">The filters.</param>
+ /// <returns>Item filters.</returns>
+ public static IEnumerable<ItemFilter> GetFilters(string? filters)
+ {
+ return string.IsNullOrEmpty(filters)
+ ? Array.Empty<ItemFilter>()
+ : filters.Split(',').Select(v => Enum.Parse<ItemFilter>(v, true));
+ }
+
+ /// <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.Connection.RemoteIpAddress.ToString(),
+ 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..b12590080
--- /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..fc38eacaf
--- /dev/null
+++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs
@@ -0,0 +1,854 @@
+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
+ {
+ /// <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();
+ }
+
+ /// <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");
+ }
+ }
+
+ 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)
+ {
+ if (job != null)
+ {
+ job.HasExited = true;
+ }
+
+ _logger.LogDebug("Disposing stream resources");
+ state.Dispose();
+
+ if (process.ExitCode == 0)
+ {
+ _logger.LogInformation("FFMpeg exited with code 0");
+ }
+ else
+ {
+ _logger.LogError("FFMpeg exited with code {0}", process.ExitCode);
+ }
+
+ process.Dispose();
+ }
+
+ private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource)
+ {
+ if (state.VideoType == VideoType.Iso && state.IsoType.HasValue && _isoManager.CanMount(state.MediaPath))
+ {
+ state.IsoMount = await _isoManager.Mount(state.MediaPath, cancellationTokenSource.Token).ConfigureAwait(false);
+ }
+
+ if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId))
+ {
+ var liveStreamResponse = await _mediaSourceManager.OpenLiveStream(
+ new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken },
+ cancellationTokenSource.Token)
+ .ConfigureAwait(false);
+
+ _encodingHelper.AttachMediaSourceInfo(state, liveStreamResponse.MediaSource, state.RequestedUrl);
+
+ if (state.VideoRequest != null)
+ {
+ _encodingHelper.TryStreamCopy(state);
+ }
+ }
+
+ if (state.MediaSource.BufferMs.HasValue)
+ {
+ await Task.Delay(state.MediaSource.BufferMs.Value, cancellationTokenSource.Token).ConfigureAwait(false);
+ }
+ }
+
+ /// <summary>
+ /// Called when [transcode begin request].
+ /// </summary>
+ /// <param name="path">The path.</param>
+ /// <param name="type">The type.</param>
+ /// <returns>The <see cref="TranscodingJobDto"/>.</returns>
+ public TranscodingJobDto? OnTranscodeBeginRequest(string path, TranscodingJobType type)
+ {
+ lock (_activeTranscodingJobs)
+ {
+ var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase));
+
+ if (job == null)
+ {
+ return null;
+ }
+
+ OnTranscodeBeginRequest(job);
+
+ return job;
+ }
+ }
+
+ private void OnTranscodeBeginRequest(TranscodingJobDto job)
+ {
+ job.ActiveRequestCount++;
+
+ if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive)
+ {
+ job.StopKillTimer();
+ }
+ }
+
+ /// <summary>
+ /// Gets the transcoding lock.
+ /// </summary>
+ /// <param name="outputPath">The output path of the transcoded file.</param>
+ /// <returns>A <see cref="SemaphoreSlim"/>.</returns>
+ public SemaphoreSlim GetTranscodingLock(string outputPath)
+ {
+ lock (_transcodingLocks)
+ {
+ if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim result))
+ {
+ result = new SemaphoreSlim(1, 1);
+ _transcodingLocks[outputPath] = result;
+ }
+
+ return result;
+ }
+ }
+
+ /// <summary>
+ /// Deletes the encoded media cache.
+ /// </summary>
+ private void DeleteEncodedMediaCache()
+ {
+ var path = _serverConfigurationManager.GetTranscodePath();
+ if (!Directory.Exists(path))
+ {
+ return;
+ }
+
+ foreach (var file in _fileSystem.GetFilePaths(path, true))
+ {
+ _fileSystem.DeleteFile(file);
+ }
+ }
+ }
+}