Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ae347a7
Initial plan
Copilot Apr 21, 2026
9977318
Add IsUpscaled property to VideoQuality to detect AI-upscaled streams
Copilot Apr 21, 2026
16096f9
asd
Tyrrrz Apr 21, 2026
5f7bbee
zxc
Tyrrrz Apr 21, 2026
0e150f2
Fix IsUpscaled detection: use xtags protobuf "sr" key instead of qual…
Copilot Apr 21, 2026
fa0fe12
Address review: specific FormatException catch, add XML doc to 3-para…
Copilot Apr 21, 2026
d83b7ad
Move IsUpscaled from VideoQuality to IVideoStreamInfo and its impleme…
Copilot Apr 21, 2026
33d426b
Move IsVideoUpscaled to after VideoFramerate in IStreamData and imple…
Copilot Apr 21, 2026
8852ed6
Refactor IsVideoUpscaled: use proper protobuf map parser that validat…
Copilot Apr 21, 2026
22d6ef4
Extract protobuf parsing into Utils.Protobuf helper class with Deseri…
Copilot Apr 21, 2026
71f0907
Simplify Protobuf.Deserialize to string-only (map<string,string> is t…
Copilot Apr 21, 2026
5ec2327
refactor: rename Deserialize to TryDeserialize, add base64 overload, …
Copilot Apr 22, 2026
d346d11
refactor: add IsLenField/TryReadString helpers, rename IsUpscaled to …
Copilot Apr 22, 2026
d4bcbc1
revert: restore VideoQuality.FromItag to use 2-param constructor (no …
Copilot Apr 22, 2026
f4ec180
Update Protobuf.cs
Tyrrrz Apr 22, 2026
95a43d5
reorder: move IsVideoUpscaled after VideoWidth/VideoHeight in IStream…
Copilot Apr 22, 2026
9ed7b5c
Update PlayerResponse.cs
Tyrrrz Apr 22, 2026
aa6032f
refactor: rename TryDeserialize→TryDeserializeMap; use nullable retur…
Copilot Apr 22, 2026
3a76e4e
refactor: use var everywhere in Protobuf.cs
Copilot Apr 22, 2026
ff35cf0
Update Protobuf.cs
Tyrrrz Apr 22, 2026
94539a8
Add backwards-compatible constructor overloads to VideoOnlyStreamInfo…
Copilot Apr 22, 2026
53207b7
Update YoutubeExplode/Utils/Protobuf.cs
Tyrrrz Apr 22, 2026
bccda04
Update Protobuf.cs
Tyrrrz Apr 22, 2026
f55b58a
asd
Tyrrrz Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions YoutubeExplode.Tests/StreamSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
1 change: 1 addition & 0 deletions YoutubeExplode.Tests/TestData/VideoIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
2 changes: 2 additions & 0 deletions YoutubeExplode/Bridge/DashManifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
2 changes: 2 additions & 0 deletions YoutubeExplode/Bridge/IStreamData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,7 @@ internal interface IStreamData

int? VideoHeight { get; }

bool IsVideoUpscaled { get; }

int? VideoFramerate { get; }
}
11 changes: 11 additions & 0 deletions YoutubeExplode/Bridge/PlayerResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>.
// 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();
}
Expand Down
122 changes: 122 additions & 0 deletions YoutubeExplode/Utils/Protobuf.cs
Original file line number Diff line number Diff line change
@@ -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<string, string> 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<string, string?>? TryDeserializeMap(byte[] data)
{
Comment thread
Tyrrrz marked this conversation as resolved.
var result = new Dictionary<string, string?>(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
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to support other types of fields? @copilot

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No — the only field type present in xtags is string (wire type 2), since it's a map<string, string>. Simplified in 71f0907: removed the varint branch and changed the return type to IReadOnlyDictionary<string, string?>, which also makes the call site cleaner (sr == "1" instead of sr is "1").

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;
Comment thread
Tyrrrz marked this conversation as resolved.

// Parse the map entry submessage: field 1 = key (string), field 2 = value (string)
var key = default(string);
var value = default(string);
Comment thread
Tyrrrz marked this conversation as resolved.
var j = i;
Comment thread
Tyrrrz marked this conversation as resolved.
while (j < entryEnd)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe move this to a helper method @copilot

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in d346d11. Extracted private static bool TryReadString(byte[] data, ref int i, out string? value) which handles the varint-length read, bounds check, and UTF-8 decode. The inner loop now calls TryReadString instead of repeating those three steps inline.

{
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<string, string> payload into a dictionary.
// Returns null if the string is not valid base64 or cannot be parsed.
public static IReadOnlyDictionary<string, string?>? TryDeserializeMap(string base64)
{
try
{
var bytes = Convert.FromBase64String(base64);
return TryDeserializeMap(bytes);
}
catch (FormatException)
{
return null;
}
}
}
5 changes: 5 additions & 0 deletions YoutubeExplode/Videos/Streams/IVideoStreamInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ public interface IVideoStreamInfo : IStreamInfo
/// Video resolution.
/// </summary>
Resolution VideoResolution { get; }

/// <summary>
/// Whether this stream was produced by YouTube's Super Resolution (AI upscaling) feature.
/// </summary>
bool IsVideoUpscaled { get; }
}

/// <summary>
Expand Down
36 changes: 35 additions & 1 deletion YoutubeExplode/Videos/Streams/MuxedStreamInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,40 @@ public class MuxedStreamInfo(
bool? isAudioLanguageDefault,
string videoCodec,
VideoQuality videoQuality,
Resolution videoResolution
Resolution videoResolution,
bool isVideoUpscaled
) : IAudioStreamInfo, IVideoStreamInfo
{
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding isUpscaled as a new required parameter changes the public constructor signature and is a breaking change for consumers that instantiate MuxedStreamInfo directly. If you want to preserve compatibility, consider a default value (= false) and/or an overload without this parameter.

Suggested change
{
{
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
) { }

Copilot uses AI. Check for mistakes.
/// <summary>
/// Initializes an instance of <see cref="MuxedStreamInfo" />.
/// </summary>
// 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
) { }

/// <inheritdoc />
public string Url { get; } = url;

Expand Down Expand Up @@ -50,6 +81,9 @@ Resolution videoResolution
/// <inheritdoc />
public Resolution VideoResolution { get; } = videoResolution;

/// <inheritdoc />
public bool IsVideoUpscaled { get; } = isVideoUpscaled;

/// <inheritdoc />
[ExcludeFromCodeCoverage]
public override string ToString() => $"Muxed ({VideoQuality} | {Container})";
Expand Down
6 changes: 4 additions & 2 deletions YoutubeExplode/Videos/Streams/StreamClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
21 changes: 20 additions & 1 deletion YoutubeExplode/Videos/Streams/VideoOnlyStreamInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,25 @@ public class VideoOnlyStreamInfo(
Bitrate bitrate,
string videoCodec,
VideoQuality videoQuality,
Resolution videoResolution
Resolution videoResolution,
bool isVideoUpscaled
) : IVideoStreamInfo
{
/// <summary>
/// Initializes an instance of <see cref="VideoOnlyStreamInfo" />.
/// </summary>
// 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) { }

/// <inheritdoc />
public string Url { get; } = url;

Expand All @@ -37,6 +53,9 @@ Resolution videoResolution
/// <inheritdoc />
public Resolution VideoResolution { get; } = videoResolution;

/// <inheritdoc />
public bool IsVideoUpscaled { get; } = isVideoUpscaled;

/// <inheritdoc />
[ExcludeFromCodeCoverage]
public override string ToString() => $"Video-only ({VideoQuality} | {Container})";
Expand Down