diff --git a/YoutubeExplode.Tests/StreamSpecs.cs b/YoutubeExplode.Tests/StreamSpecs.cs index 72e24383..b8096a00 100644 --- a/YoutubeExplode.Tests/StreamSpecs.cs +++ b/YoutubeExplode.Tests/StreamSpecs.cs @@ -112,6 +112,21 @@ public async Task I_can_get_the_list_of_available_streams_of_a_video_with_multip ); } + [Fact] + public async Task I_can_get_the_list_of_available_streams_of_a_video_with_upscaled_streams() + { + // Arrange + using var youtube = new YoutubeClient(); + + // Act + var manifest = await youtube.Videos.Streams.GetManifestAsync(VideoIds.WithUpscaledStreams); + + // Assert + manifest.Streams.Should().NotBeEmpty(); + manifest.GetVideoStreams().Should().Contain(s => s.IsVideoUpscaled); + manifest.GetVideoStreams().Should().Contain(s => !s.IsVideoUpscaled); + } + [Theory] [InlineData(VideoIds.Normal)] [InlineData(VideoIds.Unlisted)] diff --git a/YoutubeExplode.Tests/TestData/VideoIds.cs b/YoutubeExplode.Tests/TestData/VideoIds.cs index 1140b380..b303bf4d 100644 --- a/YoutubeExplode.Tests/TestData/VideoIds.cs +++ b/YoutubeExplode.Tests/TestData/VideoIds.cs @@ -22,4 +22,5 @@ internal static class VideoIds public const string WithClosedCaptions = "YltHGKX80Y8"; public const string WithBrokenClosedCaptions = "1VKIIw05JnE"; public const string WithMultipleAudioLanguages = "ngqcjXfggHQ"; + public const string WithUpscaledStreams = "IFACrIx5SZ0"; } diff --git a/YoutubeExplode/Bridge/DashManifest.cs b/YoutubeExplode/Bridge/DashManifest.cs index 2228f92b..f68df5f7 100644 --- a/YoutubeExplode/Bridge/DashManifest.cs +++ b/YoutubeExplode/Bridge/DashManifest.cs @@ -88,6 +88,8 @@ public class StreamData(XElement content) : IStreamData [Lazy] public int? VideoHeight => (int?)content.Attribute("height"); + public bool IsVideoUpscaled => false; + [Lazy] public int? VideoFramerate => (int?)content.Attribute("frameRate"); } diff --git a/YoutubeExplode/Bridge/IStreamData.cs b/YoutubeExplode/Bridge/IStreamData.cs index 067e91e1..27e79b1b 100644 --- a/YoutubeExplode/Bridge/IStreamData.cs +++ b/YoutubeExplode/Bridge/IStreamData.cs @@ -32,5 +32,7 @@ internal interface IStreamData int? VideoHeight { get; } + bool IsVideoUpscaled { get; } + int? VideoFramerate { get; } } diff --git a/YoutubeExplode/Bridge/PlayerResponse.cs b/YoutubeExplode/Bridge/PlayerResponse.cs index 35131cb1..32bf1c7c 100644 --- a/YoutubeExplode/Bridge/PlayerResponse.cs +++ b/YoutubeExplode/Bridge/PlayerResponse.cs @@ -305,6 +305,17 @@ public string? VideoCodec [Lazy] public int? VideoHeight => content.GetPropertyOrNull("height")?.GetInt32OrNull(); + [Lazy] + public bool IsVideoUpscaled => + // xtags is a base64-encoded protobuf map. + // Streams upscaled with YouTube's Super Resolution feature carry the entry {"sr": "1"}. + content + .GetPropertyOrNull("xtags") + ?.GetStringOrNull() + ?.NullIfWhiteSpace() + ?.Pipe(Protobuf.TryDeserializeMap) + ?.GetValueOrDefault("sr") == "1"; + [Lazy] public int? VideoFramerate => content.GetPropertyOrNull("fps")?.GetInt32OrNull(); } diff --git a/YoutubeExplode/Utils/Protobuf.cs b/YoutubeExplode/Utils/Protobuf.cs new file mode 100644 index 00000000..ae8c38b6 --- /dev/null +++ b/YoutubeExplode/Utils/Protobuf.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace YoutubeExplode.Utils; + +internal static class Protobuf +{ + private static bool IsLenField(ulong tag) => (tag & 0x7) == 2; + + private static ulong? TryReadVarint(byte[] data, ref int i) + { + var value = 0UL; + var shift = 0; + while (i < data.Length) + { + var b = data[i++]; + value |= (ulong)(b & 0x7F) << shift; + + if ((b & 0x80) == 0) + return value; + + shift += 7; + if (shift >= 64) + break; + } + + return null; + } + + private static string? TryReadString(byte[] data, ref int i) + { + var length = TryReadVarint(data, ref i); + if (length is null) + return null; + + if (length.Value > int.MaxValue) + return null; + + var result = Encoding.UTF8.GetString(data, i, (int)length); + i += (int)length; + + return result; + } + + // Deserializes a protobuf-encoded map payload into a dictionary. + // Each top-level LEN field (wire type 2) is treated as a map entry submessage where + // field 1 is the string key and field 2 is the string value. + // Returns null if the data cannot be parsed. + public static IReadOnlyDictionary? TryDeserializeMap(byte[] data) + { + var result = new Dictionary(StringComparer.Ordinal); + + var i = 0; + while (i < data.Length) + { + var outerTag = TryReadVarint(data, ref i); + if (outerTag is null) + return null; + + // Only process LEN-encoded fields (wire type 2) as map entries + if (!IsLenField(outerTag.Value)) + return null; + + var entryLen = TryReadVarint(data, ref i); + if (entryLen is null) + return null; + + var entryEnd = i + (int)entryLen.Value; + if (entryEnd > data.Length) + return null; + + // Parse the map entry submessage: field 1 = key (string), field 2 = value (string) + var key = default(string); + var value = default(string); + var j = i; + while (j < entryEnd) + { + var fieldTag = TryReadVarint(data, ref j); + if (fieldTag is null) + break; + + // Only handle LEN-encoded (string) fields + if (!IsLenField(fieldTag.Value)) + break; + + var fieldNum = (int)(fieldTag.Value >> 3); + + var str = TryReadString(data, ref j); + if (str is null) + break; + + if (fieldNum == 1) + key = str; + else if (fieldNum == 2) + value = str; + } + + if (key is not null) + result[key] = value; + + i = entryEnd; + } + + return result; + } + + // Decodes a base64-encoded protobuf map payload into a dictionary. + // Returns null if the string is not valid base64 or cannot be parsed. + public static IReadOnlyDictionary? TryDeserializeMap(string base64) + { + try + { + var bytes = Convert.FromBase64String(base64); + return TryDeserializeMap(bytes); + } + catch (FormatException) + { + return null; + } + } +} diff --git a/YoutubeExplode/Videos/Streams/IVideoStreamInfo.cs b/YoutubeExplode/Videos/Streams/IVideoStreamInfo.cs index dbda0bfb..a253fe76 100644 --- a/YoutubeExplode/Videos/Streams/IVideoStreamInfo.cs +++ b/YoutubeExplode/Videos/Streams/IVideoStreamInfo.cs @@ -24,6 +24,11 @@ public interface IVideoStreamInfo : IStreamInfo /// Video resolution. /// Resolution VideoResolution { get; } + + /// + /// Whether this stream was produced by YouTube's Super Resolution (AI upscaling) feature. + /// + bool IsVideoUpscaled { get; } } /// diff --git a/YoutubeExplode/Videos/Streams/MuxedStreamInfo.cs b/YoutubeExplode/Videos/Streams/MuxedStreamInfo.cs index 88258441..a6e87da7 100644 --- a/YoutubeExplode/Videos/Streams/MuxedStreamInfo.cs +++ b/YoutubeExplode/Videos/Streams/MuxedStreamInfo.cs @@ -17,9 +17,40 @@ public class MuxedStreamInfo( bool? isAudioLanguageDefault, string videoCodec, VideoQuality videoQuality, - Resolution videoResolution + Resolution videoResolution, + bool isVideoUpscaled ) : IAudioStreamInfo, IVideoStreamInfo { + /// + /// Initializes an instance of . + /// + // Backwards-compatible overload without isVideoUpscaled + public MuxedStreamInfo( + string url, + Container container, + FileSize size, + Bitrate bitrate, + string audioCodec, + Language? audioLanguage, + bool? isAudioLanguageDefault, + string videoCodec, + VideoQuality videoQuality, + Resolution videoResolution + ) + : this( + url, + container, + size, + bitrate, + audioCodec, + audioLanguage, + isAudioLanguageDefault, + videoCodec, + videoQuality, + videoResolution, + false + ) { } + /// public string Url { get; } = url; @@ -50,6 +81,9 @@ Resolution videoResolution /// public Resolution VideoResolution { get; } = videoResolution; + /// + public bool IsVideoUpscaled { get; } = isVideoUpscaled; + /// [ExcludeFromCodeCoverage] public override string ToString() => $"Muxed ({VideoQuality} | {Container})"; diff --git a/YoutubeExplode/Videos/Streams/StreamClient.cs b/YoutubeExplode/Videos/Streams/StreamClient.cs index 2976ab18..0441222b 100644 --- a/YoutubeExplode/Videos/Streams/StreamClient.cs +++ b/YoutubeExplode/Videos/Streams/StreamClient.cs @@ -158,7 +158,8 @@ streamData.VideoWidth is not null && streamData.VideoHeight is not null streamData.IsAudioLanguageDefault, streamData.VideoCodec, videoQuality, - videoResolution + videoResolution, + streamData.IsVideoUpscaled ); yield return streamInfo; @@ -173,7 +174,8 @@ streamData.VideoWidth is not null && streamData.VideoHeight is not null bitrate, streamData.VideoCodec, videoQuality, - videoResolution + videoResolution, + streamData.IsVideoUpscaled ); yield return streamInfo; diff --git a/YoutubeExplode/Videos/Streams/VideoOnlyStreamInfo.cs b/YoutubeExplode/Videos/Streams/VideoOnlyStreamInfo.cs index b96a43f7..d86908cc 100644 --- a/YoutubeExplode/Videos/Streams/VideoOnlyStreamInfo.cs +++ b/YoutubeExplode/Videos/Streams/VideoOnlyStreamInfo.cs @@ -13,9 +13,25 @@ public class VideoOnlyStreamInfo( Bitrate bitrate, string videoCodec, VideoQuality videoQuality, - Resolution videoResolution + Resolution videoResolution, + bool isVideoUpscaled ) : IVideoStreamInfo { + /// + /// Initializes an instance of . + /// + // Backwards-compatible overload without isVideoUpscaled + public VideoOnlyStreamInfo( + string url, + Container container, + FileSize size, + Bitrate bitrate, + string videoCodec, + VideoQuality videoQuality, + Resolution videoResolution + ) + : this(url, container, size, bitrate, videoCodec, videoQuality, videoResolution, false) { } + /// public string Url { get; } = url; @@ -37,6 +53,9 @@ Resolution videoResolution /// public Resolution VideoResolution { get; } = videoResolution; + /// + public bool IsVideoUpscaled { get; } = isVideoUpscaled; + /// [ExcludeFromCodeCoverage] public override string ToString() => $"Video-only ({VideoQuality} | {Container})";