aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Api/Helpers
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Api/Helpers')
-rw-r--r--Jellyfin.Api/Helpers/DynamicHlsHelper.cs78
-rw-r--r--Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs21
-rw-r--r--Jellyfin.Api/Helpers/HlsHelpers.cs10
-rw-r--r--Jellyfin.Api/Helpers/StreamingHelpers.cs29
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>