diff options
Diffstat (limited to 'Jellyfin.Api/Helpers')
| -rw-r--r-- | Jellyfin.Api/Helpers/DynamicHlsHelper.cs | 78 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs | 21 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/HlsHelpers.cs | 10 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/StreamingHelpers.cs | 29 |
4 files changed, 121 insertions, 17 deletions
diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index 44e1c6d5a2..b09b279699 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -209,6 +209,25 @@ public class DynamicHlsHelper AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User); } + // For DoVi profiles without a compatible base layer (P5 HEVC, P10/bl0 AV1), + // add a spec-compliant dvh1/dav1 variant before the hvc1 hack variant. + // SUPPLEMENTAL-CODECS cannot be used for these profiles (no compatible BL to supplement). + // The DoVi variant is listed first so spec-compliant clients (Apple TV, webOS 24+) + // select it over the fallback when both have identical BANDWIDTH. + // Only emit for clients that explicitly declared DOVI support to avoid breaking + // non-compliant players that don't recognize dvh1/dav1 CODECS strings. + if (state.VideoStream is not null + && state.VideoRequest is not null + && EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream.VideoRangeType == VideoRangeType.DOVI + && state.VideoStream.DvProfile.HasValue + && state.VideoStream.DvLevel.HasValue + && state.GetRequestedRangeTypes(state.VideoStream.Codec) + .Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase)) + { + AppendDoviPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); + } + var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); if (state.VideoStream is not null && state.VideoRequest is not null) @@ -356,6 +375,65 @@ public class DynamicHlsHelper } /// <summary> + /// Appends a Dolby Vision variant with dvh1/dav1 CODECS for profiles without a compatible + /// base layer (P5 HEVC, P10/bl0 AV1). This enables spec-compliant HLS clients to detect + /// DoVi from the manifest rather than relying on init segment inspection. + /// </summary> + /// <param name="builder">StringBuilder for the master playlist.</param> + /// <param name="state">StreamState of the current stream.</param> + /// <param name="url">Playlist URL for this variant.</param> + /// <param name="bitrate">Bitrate for the BANDWIDTH field.</param> + /// <param name="subtitleGroup">Subtitle group identifier, or null.</param> + private void AppendDoviPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) + { + var dvProfile = state.VideoStream.DvProfile; + var dvLevel = state.VideoStream.DvLevel; + if (dvProfile is null || dvLevel is null) + { + return; + } + + var playlistBuilder = new StringBuilder(); + playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") + .Append(bitrate.ToString(CultureInfo.InvariantCulture)) + .Append(",AVERAGE-BANDWIDTH=") + .Append(bitrate.ToString(CultureInfo.InvariantCulture)); + + playlistBuilder.Append(",VIDEO-RANGE=PQ"); + + var dvCodec = HlsCodecStringHelpers.GetDoviString(dvProfile.Value, dvLevel.Value, state.ActualOutputVideoCodec); + + string audioCodecs = string.Empty; + if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec)) + { + audioCodecs = GetPlaylistAudioCodecs(state); + } + + playlistBuilder.Append(",CODECS=\"") + .Append(dvCodec); + if (!string.IsNullOrEmpty(audioCodecs)) + { + playlistBuilder.Append(',').Append(audioCodecs); + } + + playlistBuilder.Append('"'); + + AppendPlaylistResolutionField(playlistBuilder, state); + AppendPlaylistFramerateField(playlistBuilder, state); + + if (!string.IsNullOrWhiteSpace(subtitleGroup)) + { + playlistBuilder.Append(",SUBTITLES=\"") + .Append(subtitleGroup) + .Append('"'); + } + + playlistBuilder.AppendLine(); + playlistBuilder.AppendLine(url); + builder.Append(playlistBuilder); + } + + /// <summary> /// Appends a VIDEO-RANGE field containing the range of the output video stream. /// </summary> /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs index cf42d5f10b..1ac2abcfbf 100644 --- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs @@ -346,4 +346,25 @@ public static class HlsCodecStringHelpers return result.ToString(); } + + /// <summary> + /// Gets a Dolby Vision codec string for profiles without a compatible base layer. + /// </summary> + /// <param name="dvProfile">Dolby Vision profile number.</param> + /// <param name="dvLevel">Dolby Vision level number.</param> + /// <param name="codec">Video codec name (e.g. "hevc", "av1") to determine the DoVi FourCC.</param> + /// <returns>Dolby Vision codec string.</returns> + public static string GetDoviString(int dvProfile, int dvLevel, string codec) + { + // HEVC DoVi uses dvh1, AV1 DoVi uses dav1 (out-of-band parameter sets, recommended by Apple HLS spec Rule 1.10) + var fourCc = string.Equals(codec, "av1", StringComparison.OrdinalIgnoreCase) ? "dav1" : "dvh1"; + StringBuilder result = new StringBuilder(fourCc, 12); + + result.Append('.') + .AppendFormat(CultureInfo.InvariantCulture, "{0:D2}", dvProfile) + .Append('.') + .AppendFormat(CultureInfo.InvariantCulture, "{0:D2}", dvLevel); + + return result.ToString(); + } } diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs index cad8d650e9..15540338b3 100644 --- a/Jellyfin.Api/Helpers/HlsHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsHelpers.cs @@ -45,15 +45,9 @@ public static class HlsHelpers using var reader = new StreamReader(fileStream); var count = 0; - while (!reader.EndOfStream) + string? line; + while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null) { - var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); - if (line is null) - { - // Nothing currently in buffer. - break; - } - if (line.Contains("#EXTINF:", StringComparison.OrdinalIgnoreCase)) { count++; diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index b3f5b9a801..bae2756303 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -17,9 +17,7 @@ using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Streaming; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Net.Http.Headers; namespace Jellyfin.Api.Helpers; @@ -201,7 +199,7 @@ public static class StreamingHelpers state.OutputVideoCodec = state.Request.VideoCodec; state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); - encodingHelper.TryStreamCopy(state); + encodingHelper.TryStreamCopy(state, encodingOptions); if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue) { @@ -268,7 +266,7 @@ public static class StreamingHelpers Dictionary<string, string?> streamOptions = new Dictionary<string, string?>(); foreach (var param in queryString) { - if (char.IsLower(param.Key[0])) + if (param.Key.Length > 0 && char.IsLower(param.Key[0])) { // This was probably not parsed initially and should be a StreamOptions // or the generated URL should correctly serialize it @@ -422,14 +420,18 @@ public static class StreamingHelpers request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); break; case 4: - if (videoRequest is not null) + if (videoRequest is not null && IsValidCodecName(val)) { videoRequest.VideoCodec = val; } break; case 5: - request.AudioCodec = val; + if (IsValidCodecName(val)) + { + request.AudioCodec = val; + } + break; case 6: if (videoRequest is not null) @@ -483,7 +485,7 @@ public static class StreamingHelpers request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture); break; case 15: - if (videoRequest is not null) + if (videoRequest is not null && EncodingHelper.LevelValidationRegex().IsMatch(val)) { videoRequest.Level = val; } @@ -504,7 +506,7 @@ public static class StreamingHelpers break; case 18: - if (videoRequest is not null) + if (videoRequest is not null && IsValidCodecName(val)) { videoRequest.Profile = val; } @@ -563,7 +565,11 @@ public static class StreamingHelpers break; case 30: - request.SubtitleCodec = val; + if (IsValidCodecName(val)) + { + request.SubtitleCodec = val; + } + break; case 31: if (videoRequest is not null) @@ -586,6 +592,11 @@ public static class StreamingHelpers } } + private static bool IsValidCodecName(string val) + { + return EncodingHelper.ContainerValidationRegex().IsMatch(val); + } + /// <summary> /// Parses the container into its file extension. /// </summary> |
