diff options
Diffstat (limited to 'Jellyfin.Api/Helpers')
| -rw-r--r-- | Jellyfin.Api/Helpers/AudioHelper.cs | 9 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/ClaimHelpers.cs | 13 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/ClassMigrationHelper.cs | 71 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/DynamicHlsHelper.cs | 207 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs | 15 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs | 98 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/HlsHelpers.cs | 62 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/MediaInfoHelper.cs | 4 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/ProgressiveFileCopier.cs | 6 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/ProgressiveFileStream.cs | 166 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/RequestHelpers.cs | 75 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/SimilarItemsHelper.cs | 182 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/StreamingHelpers.cs | 59 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/TranscodingJobHelper.cs | 64 |
14 files changed, 699 insertions, 332 deletions
diff --git a/Jellyfin.Api/Helpers/AudioHelper.cs b/Jellyfin.Api/Helpers/AudioHelper.cs index a3f2d88ce..21ec2d32f 100644 --- a/Jellyfin.Api/Helpers/AudioHelper.cs +++ b/Jellyfin.Api/Helpers/AudioHelper.cs @@ -1,8 +1,10 @@ -using System.Net.Http; +using System; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; @@ -98,6 +100,11 @@ namespace Jellyfin.Api.Helpers TranscodingJobType transcodingJobType, StreamingRequestDto streamingRequest) { + if (_httpContextAccessor.HttpContext == null) + { + throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); + } + bool isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == System.Net.WebRequestMethods.Http.Head; var cancellationTokenSource = new CancellationTokenSource(); diff --git a/Jellyfin.Api/Helpers/ClaimHelpers.cs b/Jellyfin.Api/Helpers/ClaimHelpers.cs index df235ced2..29e6b4193 100644 --- a/Jellyfin.Api/Helpers/ClaimHelpers.cs +++ b/Jellyfin.Api/Helpers/ClaimHelpers.cs @@ -63,6 +63,19 @@ namespace Jellyfin.Api.Helpers public static string? GetToken(in ClaimsPrincipal user) => GetClaimValue(user, InternalClaimTypes.Token); + /// <summary> + /// Gets a flag specifying whether the request is using an api key. + /// </summary> + /// <param name="user">Current claims principal.</param> + /// <returns>The flag specifying whether the request is using an api key.</returns> + public static bool GetIsApiKey(in ClaimsPrincipal user) + { + var claimValue = GetClaimValue(user, InternalClaimTypes.IsApiKey); + return !string.IsNullOrEmpty(claimValue) + && bool.TryParse(claimValue, out var parsedClaimValue) + && parsedClaimValue; + } + private static string? GetClaimValue(in ClaimsPrincipal user, string name) { return user?.Identities diff --git a/Jellyfin.Api/Helpers/ClassMigrationHelper.cs b/Jellyfin.Api/Helpers/ClassMigrationHelper.cs new file mode 100644 index 000000000..a911a3324 --- /dev/null +++ b/Jellyfin.Api/Helpers/ClassMigrationHelper.cs @@ -0,0 +1,71 @@ +using System; +using System.Reflection; + +namespace Jellyfin.Api.Helpers +{ + /// <summary> + /// A static class for copying matching properties from one object to another. + /// TODO: remove at the point when a fixed migration path has been decided upon. + /// </summary> + public static class ClassMigrationHelper + { + /// <summary> + /// Extension for 'Object' that copies the properties to a destination object. + /// </summary> + /// <param name="source">The source.</param> + /// <param name="destination">The destination.</param> + public static void CopyProperties(this object source, object destination) + { + // If any this null throw an exception. + if (source == null || destination == null) + { + throw new Exception("Source or/and Destination Objects are null"); + } + + // Getting the Types of the objects. + Type typeDest = destination.GetType(); + Type typeSrc = source.GetType(); + + // Iterate the Properties of the source instance and populate them from their destination counterparts. + PropertyInfo[] srcProps = typeSrc.GetProperties(); + foreach (PropertyInfo srcProp in srcProps) + { + if (!srcProp.CanRead) + { + continue; + } + + var targetProperty = typeDest.GetProperty(srcProp.Name); + if (targetProperty == null) + { + continue; + } + + if (!targetProperty.CanWrite) + { + continue; + } + + var obj = targetProperty.GetSetMethod(true); + if (obj != null && obj.IsPrivate) + { + continue; + } + + var target = targetProperty.GetSetMethod(); + if (target != null && (target.Attributes & MethodAttributes.Static) != 0) + { + continue; + } + + if (!targetProperty.PropertyType.IsAssignableFrom(srcProp.PropertyType)) + { + continue; + } + + // Passed all tests, lets set the value. + targetProperty.SetValue(destination, srcProp.GetValue(source, null), null); + } + } + } +} diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index af0519ffa..a4da54cfd 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -113,7 +113,7 @@ namespace Jellyfin.Api.Helpers StreamingRequestDto streamingRequest, bool enableAdaptiveBitrateStreaming) { - var isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == WebRequestMethods.Http.Head; + var isHeadRequest = _httpContextAccessor.HttpContext?.Request.Method == WebRequestMethods.Http.Head; var cancellationTokenSource = new CancellationTokenSource(); return await GetMasterPlaylistInternal( streamingRequest, @@ -130,6 +130,11 @@ namespace Jellyfin.Api.Helpers TranscodingJobType transcodingJobType, CancellationTokenSource cancellationTokenSource) { + if (_httpContextAccessor.HttpContext == null) + { + throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); + } + using var state = await StreamingHelpers.GetStreamingState( streamingRequest, _httpContextAccessor.HttpContext.Request, @@ -155,7 +160,7 @@ namespace Jellyfin.Api.Helpers return new FileContentResult(Array.Empty<byte>(), MimeTypes.GetMimeType("playlist.m3u8")); } - var totalBitrate = state.OutputAudioBitrate ?? 0 + state.OutputVideoBitrate ?? 0; + var totalBitrate = (state.OutputAudioBitrate ?? 0) + (state.OutputVideoBitrate ?? 0); var builder = new StringBuilder(); @@ -202,7 +207,61 @@ namespace Jellyfin.Api.Helpers AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User); } - AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); + var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); + + if (state.VideoStream != null && state.VideoRequest != null) + { + // Provide SDR HEVC entrance for backward compatibility. + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && !string.IsNullOrEmpty(state.VideoStream.VideoRange) + && string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase) + && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + var requestedVideoProfiles = state.GetRequestedProfiles("hevc"); + if (requestedVideoProfiles != null && requestedVideoProfiles.Length > 0) + { + // Force HEVC Main Profile and disable video stream copy. + state.OutputVideoCodec = "hevc"; + var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(",", requestedVideoProfiles), "main"); + sdrVideoUrl += "&AllowVideoStreamCopy=false"; + + EncodingHelper encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration); + var sdrOutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec) ?? 0; + var sdrOutputAudioBitrate = encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream) ?? 0; + var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate; + + AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup); + + // Restore the video codec + state.OutputVideoCodec = "copy"; + } + } + + // Provide Level 5.0 entrance for backward compatibility. + // e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video, + // but in fact it is capable of playing videos up to Level 6.1. + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream.Level.HasValue + && state.VideoStream.Level > 150 + && !string.IsNullOrEmpty(state.VideoStream.VideoRange) + && string.Equals(state.VideoStream.VideoRange, "SDR", StringComparison.OrdinalIgnoreCase) + && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + var playlistCodecsField = new StringBuilder(); + AppendPlaylistCodecsField(playlistCodecsField, state); + + // Force the video level to 5.0. + var originalLevel = state.VideoStream.Level; + state.VideoStream.Level = 150; + var newPlaylistCodecsField = new StringBuilder(); + AppendPlaylistCodecsField(newPlaylistCodecsField, state); + + // Restore the video level. + state.VideoStream.Level = originalLevel; + var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField); + builder.Append(newPlaylist); + } + } if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIp())) { @@ -212,40 +271,77 @@ namespace Jellyfin.Api.Helpers var variation = GetBitrateVariation(totalBitrate); var newBitrate = totalBitrate - variation; - var variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); variation *= 2; newBitrate = totalBitrate - variation; - variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + variantUrl = ReplaceVideoBitrate(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) + private StringBuilder AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) { - builder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") + var playlistBuilder = new StringBuilder(); + playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") .Append(bitrate.ToString(CultureInfo.InvariantCulture)) .Append(",AVERAGE-BANDWIDTH=") .Append(bitrate.ToString(CultureInfo.InvariantCulture)); - AppendPlaylistCodecsField(builder, state); + AppendPlaylistVideoRangeField(playlistBuilder, state); + + AppendPlaylistCodecsField(playlistBuilder, state); - AppendPlaylistResolutionField(builder, state); + AppendPlaylistResolutionField(playlistBuilder, state); - AppendPlaylistFramerateField(builder, state); + AppendPlaylistFramerateField(playlistBuilder, state); if (!string.IsNullOrWhiteSpace(subtitleGroup)) { - builder.Append(",SUBTITLES=\"") + playlistBuilder.Append(",SUBTITLES=\"") .Append(subtitleGroup) .Append('"'); } - builder.Append(Environment.NewLine); - builder.AppendLine(url); + playlistBuilder.Append(Environment.NewLine); + playlistBuilder.AppendLine(url); + builder.Append(playlistBuilder); + + return playlistBuilder; + } + + /// <summary> + /// Appends a VIDEO-RANGE field containing the range of the output video 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 AppendPlaylistVideoRangeField(StringBuilder builder, StreamState state) + { + if (state.VideoStream != null && !string.IsNullOrEmpty(state.VideoStream.VideoRange)) + { + var videoRange = state.VideoStream.VideoRange; + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + if (string.Equals(videoRange, "SDR", StringComparison.OrdinalIgnoreCase)) + { + builder.Append(",VIDEO-RANGE=SDR"); + } + + if (string.Equals(videoRange, "HDR", StringComparison.OrdinalIgnoreCase)) + { + builder.Append(",VIDEO-RANGE=PQ"); + } + } + else + { + // Currently we only encode to SDR. + builder.Append(",VIDEO-RANGE=SDR"); + } + } } /// <summary> @@ -414,15 +510,27 @@ namespace Jellyfin.Api.Helpers /// <returns>H.26X level of the output video stream.</returns> private int? GetOutputVideoCodecLevel(StreamState state) { - string? levelString; + string levelString = string.Empty; if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream != null && state.VideoStream.Level.HasValue) { - levelString = state.VideoStream?.Level.ToString(); + levelString = state.VideoStream.Level.ToString() ?? string.Empty; } else { - levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec); + if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) + { + levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec) ?? "41"; + levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString); + } + + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + levelString = state.GetRequestedLevel("h265") ?? state.GetRequestedLevel("hevc") ?? "120"; + levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString); + } } if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel)) @@ -434,6 +542,38 @@ namespace Jellyfin.Api.Helpers } /// <summary> + /// Get the H.26X profile of the output video stream. + /// </summary> + /// <param name="state">StreamState of the current stream.</param> + /// <param name="codec">Video codec.</param> + /// <returns>H.26X profile of the output video stream.</returns> + private string GetOutputVideoCodecProfile(StreamState state, string codec) + { + string profileString = string.Empty; + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && !string.IsNullOrEmpty(state.VideoStream.Profile)) + { + profileString = state.VideoStream.Profile; + } + else if (!string.IsNullOrEmpty(codec)) + { + profileString = state.GetRequestedProfiles(codec).FirstOrDefault() ?? string.Empty; + if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) + { + profileString = profileString ?? "high"; + } + + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + profileString = profileString ?? "main"; + } + } + + return profileString; + } + + /// <summary> /// Gets a formatted string of the output audio codec, for use in the CODECS field. /// </summary> /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/> @@ -463,6 +603,16 @@ namespace Jellyfin.Api.Helpers return HlsCodecStringHelpers.GetEAC3String(); } + if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetFLACString(); + } + + if (string.Equals(state.ActualOutputAudioCodec, "alac", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetALACString(); + } + return string.Empty; } @@ -487,15 +637,14 @@ namespace Jellyfin.Api.Helpers if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)) { - string profile = state.GetRequestedProfiles("h264").FirstOrDefault(); + string profile = GetOutputVideoCodecProfile(state, "h264"); return HlsCodecStringHelpers.GetH264String(profile, level); } if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) { - string profile = state.GetRequestedProfiles("h265").FirstOrDefault(); - + string profile = GetOutputVideoCodecProfile(state, "hevc"); return HlsCodecStringHelpers.GetH265String(profile, level); } @@ -539,12 +688,30 @@ namespace Jellyfin.Api.Helpers return variation; } - private string ReplaceBitrate(string url, int oldValue, int newValue) + private string ReplaceVideoBitrate(string url, int oldValue, int newValue) { return url.Replace( "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture), "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase); } + + private string ReplaceProfile(string url, string codec, string oldValue, string newValue) + { + string profileStr = codec + "-profile="; + return url.Replace( + profileStr + oldValue, + profileStr + newValue, + StringComparison.OrdinalIgnoreCase); + } + + private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue) + { + var oldPlaylist = playlist.ToString(); + return oldPlaylist.Replace( + oldValue.ToString(), + newValue.ToString(), + StringComparison.OrdinalIgnoreCase); + } } } diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 6b516977e..cfa2c1229 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -24,12 +24,14 @@ namespace Jellyfin.Api.Helpers /// <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> + /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</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) + HttpContext httpContext, + CancellationToken cancellationToken = default) { if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent)) { @@ -37,8 +39,8 @@ namespace Jellyfin.Api.Helpers } // 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(); + var response = await httpClient.GetAsync(new Uri(state.MediaPath)).ConfigureAwait(false); + var contentType = response.Content.Headers.ContentType?.ToString(); httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none"; @@ -47,7 +49,7 @@ namespace Jellyfin.Api.Helpers return new FileContentResult(Array.Empty<byte>(), contentType); } - return new FileStreamResult(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), contentType); + return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType); } /// <summary> @@ -123,9 +125,8 @@ namespace Jellyfin.Api.Helpers 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); + var stream = new ProgressiveFileStream(outputPath, job, transcodingJobHelper); + return new FileStreamResult(stream, contentType); } finally { diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs index 95f1906ef..a5369c441 100644 --- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs @@ -10,12 +10,37 @@ namespace Jellyfin.Api.Helpers public static class HlsCodecStringHelpers { /// <summary> + /// Codec name for MP3. + /// </summary> + public const string MP3 = "mp4a.40.34"; + + /// <summary> + /// Codec name for AC-3. + /// </summary> + public const string AC3 = "mp4a.a5"; + + /// <summary> + /// Codec name for E-AC-3. + /// </summary> + public const string EAC3 = "mp4a.a6"; + + /// <summary> + /// Codec name for FLAC. + /// </summary> + public const string FLAC = "fLaC"; + + /// <summary> + /// Codec name for ALAC. + /// </summary> + public const string ALAC = "alac"; + + /// <summary> /// Gets a MP3 codec string. /// </summary> /// <returns>MP3 codec string.</returns> public static string GetMP3String() { - return "mp4a.40.34"; + return MP3; } /// <summary> @@ -23,7 +48,7 @@ namespace Jellyfin.Api.Helpers /// </summary> /// <param name="profile">AAC profile.</param> /// <returns>AAC codec string.</returns> - public static string GetAACString(string profile) + public static string GetAACString(string? profile) { StringBuilder result = new StringBuilder("mp4a", 9); @@ -41,12 +66,48 @@ namespace Jellyfin.Api.Helpers } /// <summary> + /// Gets an AC-3 codec string. + /// </summary> + /// <returns>AC-3 codec string.</returns> + public static string GetAC3String() + { + return AC3; + } + + /// <summary> + /// Gets an E-AC-3 codec string. + /// </summary> + /// <returns>E-AC-3 codec string.</returns> + public static string GetEAC3String() + { + return EAC3; + } + + /// <summary> + /// Gets an FLAC codec string. + /// </summary> + /// <returns>FLAC codec string.</returns> + public static string GetFLACString() + { + return FLAC; + } + + /// <summary> + /// Gets an ALAC codec string. + /// </summary> + /// <returns>ALAC codec string.</returns> + public static string GetALACString() + { + return ALAC; + } + + /// <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) + public static string GetH264String(string? profile, int level) { StringBuilder result = new StringBuilder("avc1", 11); @@ -80,46 +141,29 @@ namespace Jellyfin.Api.Helpers /// <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) + 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); + StringBuilder result = new StringBuilder("hvc1", 16); - if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase)) { - result.Append(".2.6"); + result.Append(".2.4"); } else { // Default to main if profile is invalid - result.Append(".1.6"); + result.Append(".1.4"); } result.Append(".L") - .Append(level * 3) + .Append(level) .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 index 242496697..18e23fb5c 100644 --- a/Jellyfin.Api/Helpers/HlsHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsHelpers.cs @@ -1,8 +1,11 @@ using System; using System.Globalization; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; @@ -45,6 +48,11 @@ namespace Jellyfin.Api.Helpers while (!reader.EndOfStream) { var line = await reader.ReadLineAsync().ConfigureAwait(false); + if (line == null) + { + // Nothing currently in buffer. + break; + } if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1) { @@ -70,24 +78,64 @@ namespace Jellyfin.Api.Helpers } /// <summary> + /// Gets the #EXT-X-MAP string. + /// </summary> + /// <param name="outputPath">The output path of the file.</param> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <param name="isOsDepends">Get a normal string or depends on OS.</param> + /// <returns>The string text of #EXT-X-MAP.</returns> + public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends) + { + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); + var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); + var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); + + // on Linux/Unix + // #EXT-X-MAP:URI="prefix-1.mp4" + var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension; + if (!isOsDepends) + { + return fmp4InitFileName; + } + + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + if (isWindows) + { + // on Windows + // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4" + fmp4InitFileName = outputPrefix + "-1" + outputExtension; + } + + return fmp4InitFileName; + } + + /// <summary> /// Gets the hls playlist text. /// </summary> /// <param name="path">The path to the playlist file.</param> - /// <param name="segmentLength">The segment length.</param> + /// <param name="state">The <see cref="StreamState"/>.</param> /// <returns>The playlist text as a string.</returns> - public static string GetLivePlaylistText(string path, int segmentLength) + public static string GetLivePlaylistText(string path, StreamState state) { 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); + var segmentFormat = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.'); + if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) + { + var fmp4InitFileName = GetFmp4InitFileName(path, state, true); + var baseUrlParam = string.Format( + CultureInfo.InvariantCulture, + "hls/{0}/", + Path.GetFileNameWithoutExtension(path)); + var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false); - 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); + // Replace fMP4 init file URI. + text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture); + } return text; } diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index e78f63b25..0d8315dee 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -166,7 +166,7 @@ namespace Jellyfin.Api.Helpers MediaSourceInfo mediaSource, DeviceProfile profile, AuthorizationInfo auth, - long? maxBitrate, + int? maxBitrate, long startTimeTicks, string mediaSourceId, int? audioStreamIndex, @@ -551,7 +551,7 @@ namespace Jellyfin.Api.Helpers } } - private long? GetMaxBitrate(long? clientMaxBitrate, User user, string ipAddress) + private int? GetMaxBitrate(int? clientMaxBitrate, User user, string ipAddress) { var maxBitrate = clientMaxBitrate; var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0; diff --git a/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs b/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs index e00ed3304..8bddf00d5 100644 --- a/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs +++ b/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs @@ -5,6 +5,7 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.PlaybackDtos; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Model.IO; @@ -90,6 +91,11 @@ namespace Jellyfin.Api.Helpers allowAsyncFileRead = true; } + if (_path == null) + { + throw new ResourceNotFoundException(nameof(_path)); + } + await using var inputStream = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions); var eofCount = 0; diff --git a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs new file mode 100644 index 000000000..824870c7e --- /dev/null +++ b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs @@ -0,0 +1,166 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Models.PlaybackDtos; +using MediaBrowser.Model.IO; + +namespace Jellyfin.Api.Helpers +{ + /// <summary> + /// A progressive file stream for transferring transcoded files as they are written to. + /// </summary> + public class ProgressiveFileStream : Stream + { + private readonly FileStream _fileStream; + private readonly TranscodingJobDto? _job; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly bool _allowAsyncFileRead; + private int _bytesWritten; + private bool _disposed; + + /// <summary> + /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class. + /// </summary> + /// <param name="filePath">The path to the transcoded file.</param> + /// <param name="job">The transcoding job information.</param> + /// <param name="transcodingJobHelper">The transcoding job helper.</param> + public ProgressiveFileStream(string filePath, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper) + { + _job = job; + _transcodingJobHelper = transcodingJobHelper; + _bytesWritten = 0; + + var fileOptions = FileOptions.SequentialScan; + _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; + } + + _fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions); + } + + /// <inheritdoc /> + public override bool CanRead => _fileStream.CanRead; + + /// <inheritdoc /> + public override bool CanSeek => false; + + /// <inheritdoc /> + public override bool CanWrite => false; + + /// <inheritdoc /> + public override long Length => throw new NotSupportedException(); + + /// <inheritdoc /> + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + /// <inheritdoc /> + public override void Flush() + { + _fileStream.Flush(); + } + + /// <inheritdoc /> + public override int Read(byte[] buffer, int offset, int count) + { + return _fileStream.Read(buffer, offset, count); + } + + /// <inheritdoc /> + public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + int totalBytesRead = 0; + int remainingBytesToRead = count; + + int newOffset = offset; + while (remainingBytesToRead > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + int bytesRead; + if (_allowAsyncFileRead) + { + bytesRead = await _fileStream.ReadAsync(buffer, newOffset, remainingBytesToRead, cancellationToken).ConfigureAwait(false); + } + else + { + bytesRead = _fileStream.Read(buffer, newOffset, remainingBytesToRead); + } + + remainingBytesToRead -= bytesRead; + newOffset += bytesRead; + + if (bytesRead > 0) + { + _bytesWritten += bytesRead; + totalBytesRead += bytesRead; + + if (_job != null) + { + _job.BytesDownloaded = Math.Max(_job.BytesDownloaded ?? _bytesWritten, _bytesWritten); + } + } + else + { + // If the job is null it's a live stream and will require user action to close + if (_job?.HasExited ?? false) + { + break; + } + + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + } + + return totalBytesRead; + } + + /// <inheritdoc /> + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + /// <inheritdoc /> + public override void SetLength(long value) + => throw new NotSupportedException(); + + /// <inheritdoc /> + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + /// <inheritdoc /> + protected override void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + try + { + if (disposing) + { + _fileStream.Dispose(); + + if (_job != null) + { + _transcodingJobHelper.OnTranscodeEndRequest(_job); + } + } + } + finally + { + _disposed = true; + base.Dispose(disposing); + } + } + } +} diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index fb5c283f5..efce11f8a 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -1,11 +1,14 @@ using System; -using System.Collections.Generic; using System.Linq; -using System.Net; +using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Http; @@ -71,7 +74,7 @@ namespace Jellyfin.Api.Helpers } return removeEmpty - ? value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries) + ? value.Split(separator, StringSplitOptions.RemoveEmptyEntries) : value.Split(separator); } @@ -119,47 +122,39 @@ namespace Jellyfin.Api.Helpers 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) + internal static QueryResult<BaseItemDto> CreateQueryResult( + QueryResult<(BaseItem, ItemCounts)> result, + DtoOptions dtoOptions, + IDtoService dtoService, + bool includeItemTypes, + User? user) { - if (string.IsNullOrEmpty(fields)) + var dtos = result.Items.Select(i => { - return Array.Empty<ItemFields>(); - } + var (baseItem, counts) = i; + var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - return Split(fields, ',', true) - .Select(v => + if (includeItemTypes) { - if (Enum.TryParse(v, true, out ItemFields value)) - { - return (ItemFields?)value; - } - - return null; - }).Where(i => i.HasValue) - .Select(i => i!.Value) - .ToArray(); + dto.ChildCount = counts.ItemCount; + dto.ProgramCount = counts.ProgramCount; + dto.SeriesCount = counts.SeriesCount; + dto.EpisodeCount = counts.EpisodeCount; + dto.MovieCount = counts.MovieCount; + dto.TrailerCount = counts.TrailerCount; + dto.AlbumCount = counts.AlbumCount; + dto.SongCount = counts.SongCount; + dto.ArtistCount = counts.ArtistCount; + } + + return dto; + }); + + return new QueryResult<BaseItemDto> + { + Items = dtos.ToArray(), + TotalRecordCount = result.TotalRecordCount + }; } } } diff --git a/Jellyfin.Api/Helpers/SimilarItemsHelper.cs b/Jellyfin.Api/Helpers/SimilarItemsHelper.cs deleted file mode 100644 index b922e76cf..000000000 --- a/Jellyfin.Api/Helpers/SimilarItemsHelper.cs +++ /dev/null @@ -1,182 +0,0 @@ -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 index f4ec29bde..c6d844c4f 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -83,8 +83,12 @@ namespace Jellyfin.Api.Helpers } streamingRequest.StreamOptions = ParseStreamOptions(httpRequest.Query); + if (httpRequest.Path.Value == null) + { + throw new ResourceNotFoundException(nameof(httpRequest.Path)); + } - var url = httpRequest.Path.Value.Split('.').Last(); + var url = httpRequest.Path.Value.Split('.')[^1]; if (string.IsNullOrEmpty(streamingRequest.AudioCodec)) { @@ -165,7 +169,9 @@ namespace Jellyfin.Api.Helpers state.DirectStreamProvider = liveStreamInfo.Item2; } - encodingHelper.AttachMediaSourceInfo(state, mediaSource, url); + var encodingOptions = serverConfigurationManager.GetEncodingOptions(); + + encodingHelper.AttachMediaSourceInfo(state, encodingOptions, mediaSource, url); string? containerInternal = Path.GetExtension(state.RequestedUrl); @@ -183,7 +189,7 @@ namespace Jellyfin.Api.Helpers state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); - state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, state.AudioStream); + state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream); state.OutputAudioCodec = streamingRequest.AudioCodec; @@ -196,20 +202,41 @@ namespace Jellyfin.Api.Helpers encodingHelper.TryStreamCopy(state); - if (state.OutputVideoBitrate.HasValue && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue) { - 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; + var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue + && !state.VideoRequest.Height.HasValue + && !state.VideoRequest.MaxWidth.HasValue + && !state.VideoRequest.MaxHeight.HasValue; + + if (isVideoResolutionNotRequested + && state.VideoRequest.VideoBitRate.HasValue + && state.VideoStream.BitRate.HasValue + && state.VideoRequest.VideoBitRate.Value >= state.VideoStream.BitRate.Value) + { + // Don't downscale the resolution if the width/height/MaxWidth/MaxHeight is not requested, + // and the requested video bitrate is higher than source video bitrate. + if (state.VideoStream.Width.HasValue || state.VideoStream.Height.HasValue) + { + state.VideoRequest.MaxWidth = state.VideoStream?.Width; + state.VideoRequest.MaxHeight = state.VideoStream?.Height; + } + } + else + { + 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; + } } } diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs index 0db1fabff..99c90c315 100644 --- a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs +++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; @@ -12,6 +12,7 @@ using Jellyfin.Api.Models.PlaybackDtos; using Jellyfin.Api.Models.StreamingDtos; using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -102,7 +103,7 @@ namespace Jellyfin.Api.Helpers /// </summary> /// <param name="playSessionId">Playback session id.</param> /// <returns>The transcoding job.</returns> - public TranscodingJobDto GetTranscodingJob(string playSessionId) + public TranscodingJobDto? GetTranscodingJob(string playSessionId) { lock (_activeTranscodingJobs) { @@ -116,7 +117,7 @@ namespace Jellyfin.Api.Helpers /// <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) + public TranscodingJobDto? GetTranscodingJob(string path, TranscodingJobType type) { lock (_activeTranscodingJobs) { @@ -144,7 +145,7 @@ namespace Jellyfin.Api.Helpers lock (_activeTranscodingJobs) { // This is really only needed for HLS. - // Progressive streams can stop on their own reliably + // Progressive streams can stop on their own reliably. jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList(); } @@ -193,10 +194,9 @@ namespace Jellyfin.Api.Helpers /// Called when [transcode kill timer stopped]. /// </summary> /// <param name="state">The state.</param> - private async void OnTranscodeKillTimerStopped(object state) + private async void OnTranscodeKillTimerStopped(object? state) { - var job = (TranscodingJobDto)state; - + var job = state as TranscodingJobDto ?? throw new ArgumentException($"{nameof(state)} is not of type {nameof(TranscodingJobDto)}", nameof(state)); if (!job.HasExited && job.Type != TranscodingJobType.Progressive) { var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds; @@ -241,7 +241,7 @@ namespace Jellyfin.Api.Helpers lock (_activeTranscodingJobs) { // This is really only needed for HLS. - // Progressive streams can stop on their own reliably + // Progressive streams can stop on their own reliably. jobs.AddRange(_activeTranscodingJobs.Where(killJob)); } @@ -304,10 +304,10 @@ namespace Jellyfin.Api.Helpers process!.StandardInput.WriteLine("q"); - // Need to wait because killing is asynchronous + // Need to wait because killing is asynchronous. if (!process.WaitForExit(5000)) { - _logger.LogInformation("Killing ffmpeg process for {Path}", job.Path); + _logger.LogInformation("Killing FFmpeg process for {Path}", job.Path); process.Kill(); } } @@ -470,11 +470,11 @@ namespace Jellyfin.Api.Helpers } /// <summary> - /// Starts the FFMPEG. + /// Starts 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="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> @@ -489,7 +489,8 @@ namespace Jellyfin.Api.Helpers CancellationTokenSource cancellationTokenSource, string? workingDirectory = null) { - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + Directory.CreateDirectory(directory); await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false); @@ -500,13 +501,13 @@ namespace Jellyfin.Api.Helpers { this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); - throw new ArgumentException("User does not have access to video transcoding"); + throw new ArgumentException("User does not have access to video transcoding."); } } if (string.IsNullOrEmpty(_mediaEncoder.EncoderPath)) { - throw new ArgumentException("FFMPEG path not set."); + throw new ArgumentException("FFmpeg path not set."); } var process = new Process @@ -523,7 +524,7 @@ namespace Jellyfin.Api.Helpers RedirectStandardInput = true, FileName = _mediaEncoder.EncoderPath, Arguments = commandLineArguments, - WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory, + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? string.Empty : workingDirectory, ErrorDialog = false }, EnableRaisingEvents = true @@ -543,18 +544,20 @@ namespace Jellyfin.Api.Helpers var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; _logger.LogInformation(commandLineLogMessage); - var logFilePrefix = "ffmpeg-transcode"; + var logFilePrefix = "FFmpeg.Transcode-"; if (state.VideoRequest != null && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) { logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) - ? "ffmpeg-remux" - : "ffmpeg-directstream"; + ? "FFmpeg.Remux-" + : "FFmpeg.DirectStream-"; } - var logFilePath = Path.Combine(_serverConfigurationManager.ApplicationPaths.LogDirectoryPath, logFilePrefix + "-" + Guid.NewGuid() + ".txt"); + var logFilePath = Path.Combine( + _serverConfigurationManager.ApplicationPaths.LogDirectoryPath, + $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log"); - // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. + // 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); @@ -568,20 +571,20 @@ namespace Jellyfin.Api.Helpers } catch (Exception ex) { - _logger.LogError(ex, "Error starting ffmpeg"); + _logger.LogError(ex, "Error starting FFmpeg"); this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); throw; } - _logger.LogDebug("Launched ffmpeg process"); + _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 + // 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 + // Wait for the file to exist before proceeding var ffmpegTargetFile = state.WaitForPath ?? outputPath; _logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile); while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited) @@ -747,11 +750,11 @@ namespace Jellyfin.Api.Helpers if (process.ExitCode == 0) { - _logger.LogInformation("FFMpeg exited with code 0"); + _logger.LogInformation("FFmpeg exited with code 0"); } else { - _logger.LogError("FFMpeg exited with code {0}", process.ExitCode); + _logger.LogError("FFmpeg exited with code {0}", process.ExitCode); } process.Dispose(); @@ -770,8 +773,9 @@ namespace Jellyfin.Api.Helpers new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken }, cancellationTokenSource.Token) .ConfigureAwait(false); + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - _encodingHelper.AttachMediaSourceInfo(state, liveStreamResponse.MediaSource, state.RequestedUrl); + _encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl); if (state.VideoRequest != null) { @@ -827,7 +831,7 @@ namespace Jellyfin.Api.Helpers { lock (_transcodingLocks) { - if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim result)) + if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim? result)) { result = new SemaphoreSlim(1, 1); _transcodingLocks[outputPath] = result; @@ -837,7 +841,7 @@ namespace Jellyfin.Api.Helpers } } - private void OnPlaybackProgress(object sender, PlaybackProgressEventArgs e) + private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e) { if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) { |
