Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions YoutubeExplode.Tests/StreamSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ public async Task I_can_get_the_list_of_available_streams_of_a_video_with_upscal
[InlineData(VideoIds.LiveStreamRecording)]
[InlineData(VideoIds.WithOmnidirectionalStreams)]
[InlineData(VideoIds.WithHighDynamicRangeStreams)]
[InlineData(VideoIds.ForKids)]
public async Task I_can_get_the_list_of_available_streams_of_any_playable_video(string videoId)
{
// Arrange
Expand Down Expand Up @@ -233,6 +234,7 @@ public async Task I_can_get_a_specific_stream_of_a_video(string videoId)
[InlineData(VideoIds.ContentCheckSuicide)]
[InlineData(VideoIds.LiveStreamRecording)]
[InlineData(VideoIds.WithOmnidirectionalStreams)]
[InlineData(VideoIds.ForKids)]
public async Task I_can_download_a_specific_stream_of_a_video(string videoId)
{
// Arrange
Expand Down
5 changes: 3 additions & 2 deletions YoutubeExplode.Tests/TestData/VideoIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ internal static class VideoIds
public const string ContentCheckViolent = "rXMX4YJ7Lks";
public const string ContentCheckSexual = "SkRSXFQerZs";
public const string ContentCheckSuicide = "4QXCPuwBz2E";
public const string ForKids = "nQuzyJ-C1Fc";
public const string RequiresPurchase = "p3dDcKOFXQg";
public const string RequiresPurchaseDistributed = "qs3NZHVM_Ik";
public const string LiveStream = "jfKfPfyJRdk";
public const string LiveStream = "4xDzrJKXOOY";
public const string LiveStreamRecording = "rsAAeyAr-9Y";
public const string WithBrokenTitle = "4ZJWv6t-PfY";
public const string WithHighQualityStreams = "V5Fsj_sCKdg";
Expand All @@ -22,5 +23,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";
public const string WithUpscaledStreams = "0N1_0SUGlDQ";
}
1 change: 1 addition & 0 deletions YoutubeExplode.Tests/VideoSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public async Task I_can_try_to_get_the_metadata_of_a_video_and_get_an_error_if_i
[InlineData(VideoIds.EmbedRestrictedByAuthor)]
[InlineData(VideoIds.ContentCheckViolent)]
[InlineData(VideoIds.WithBrokenTitle)]
[InlineData(VideoIds.ForKids)]
public async Task I_can_get_the_metadata_of_any_available_video(string videoId)
{
// Arrange
Expand Down
11 changes: 7 additions & 4 deletions YoutubeExplode/Videos/Streams/StreamClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,17 @@ private async IAsyncEnumerable<IStreamInfo> GetStreamInfosAsync(
{
foreach (var streamData in streamDatas)
{
// SABR / server-side streams have no progressive URL we can download.
// ANDROID in particular mixes one muxed itag-18 URL with SABR-only adaptive
// formats, so skip those instead of failing the whole manifest.
var url = streamData.Url;
if (string.IsNullOrWhiteSpace(url))
continue;

var itag =
streamData.Itag
?? throw new YoutubeExplodeException("Failed to extract the stream itag.");

var url =
streamData.Url
?? throw new YoutubeExplodeException("Failed to extract the stream URL.");

// Handle cipher-protected streams
if (!string.IsNullOrWhiteSpace(streamData.Signature))
{
Expand Down
176 changes: 117 additions & 59 deletions YoutubeExplode/Videos/VideoController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,79 +92,129 @@ public async ValueTask<PlayerResponse> GetPlayerResponseAsync(
{
var visitorData = await ResolveVisitorDataAsync(cancellationToken);

// The most optimal client to impersonate is any mobile client, because they
// don't require signature deciphering (for both normal and n-parameter signatures).
// YouTube now requires Proof of Origin (PO) tokens for most Innertube clients (iOS, Android, etc.),
// causing stream downloads to fail with 403 Forbidden errors. The ANDROID_VR client (Oculus Quest)
// still works without PO tokens and provides full format access.
// https://github.com/Tyrrrz/YoutubeExplode/issues/933
using var request = new HttpRequestMessage(
HttpMethod.Post,
"https://www.youtube.com/youtubei/v1/player"
// YouTube now requires Proof of Origin (PO) tokens for most Innertube clients
// (WEB, iOS, ANDROID, ANDROID_VR, etc.), causing stream downloads to fail with
// 403 Forbidden or LOGIN_REQUIRED bot-check errors.
// VISIONOS currently returns progressive streams without PO tokens or deciphering,
// matching yt-dlp's default JS-less client.
var visionResponse = await GetPlayerResponseForVisionOsAsync(
videoId,
visitorData,
cancellationToken
);
if (visionResponse.IsPlayable)
return visionResponse;

// "Made for kids" videos are not available on VISIONOS (or ANDROID_VR).
// ANDROID still returns a muxed itag-18 stream without deciphering.
var androidResponse = await GetPlayerResponseForAndroidAsync(
videoId,
visitorData,
cancellationToken
);
if (androidResponse.IsPlayable)
return androidResponse;

if (!visionResponse.IsAvailable && !androidResponse.IsAvailable)
throw new VideoUnavailableException($"Video '{videoId}' is not available.");

return visionResponse.IsAvailable ? visionResponse : androidResponse;
}

public async ValueTask<PlayerResponse> GetPlayerResponseAsync(
Comment thread
mysteryx93 marked this conversation as resolved.
VideoId videoId,
string? signatureTimestamp,
CancellationToken cancellationToken = default
)
{
var visitorData = await ResolveVisitorDataAsync(cancellationToken);

// The only client that can handle age-restricted videos without authentication is the
// TVHTML5_SIMPLY_EMBEDDED_PLAYER client.
// This client does require signature deciphering, so we only use it as a fallback.
var playerResponse = await GetPlayerResponseForTvAsync(
videoId,
visitorData,
signatureTimestamp,
cancellationToken
);

request.Content = new StringContent(
if (!playerResponse.IsAvailable)
throw new VideoUnavailableException($"Video '{videoId}' is not available.");

return playerResponse;
}

private ValueTask<PlayerResponse> GetPlayerResponseForVisionOsAsync(
VideoId videoId,
string visitorData,
CancellationToken cancellationToken = default
) =>
SendPlayerRequestAsync(
// lang=json
$$"""
{
"videoId": {{Json.Encode(videoId)}},
"contentCheckOk": true,
"racyCheckOk": true,
"context": {
"client": {
"clientName": "ANDROID_VR",
"clientVersion": "1.60.19",
"deviceMake": "Oculus",
"deviceModel": "Quest 3",
"osName": "Android",
"osVersion": "12L",
"platform": "MOBILE",
"clientName": "VISIONOS",
"clientVersion": "1.02",
"deviceMake": "Apple",
"deviceModel": "RealityDevice17,1",
"osName": "visionOS",
"osVersion": "26.5.23O471",
"visitorData": {{Json.Encode(visitorData)}},
"hl": "en",
"gl": "US",
"utcOffsetMinutes": 0
}
}
}
"""
);

// User agent appears to be sometimes required when impersonating Android
// https://github.com/iv-org/invidious/issues/3230#issuecomment-1226887639
request.Headers.Add(
"User-Agent",
"com.google.android.apps.youtube.vr.oculus/1.60.19 (Linux; U; Android 12L; Quest 3 Build/SQ3A.220605.009.A1) gzip"
""",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
cancellationToken
);

using var response = await Http.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();

var playerResponse = PlayerResponse.Parse(
await response.Content.ReadAsStringAsync(cancellationToken)
private ValueTask<PlayerResponse> GetPlayerResponseForAndroidAsync(
VideoId videoId,
string visitorData,
CancellationToken cancellationToken = default
) =>
SendPlayerRequestAsync(
// lang=json
$$"""
{
"videoId": {{Json.Encode(videoId)}},
"contentCheckOk": true,
"racyCheckOk": true,
"context": {
"client": {
"clientName": "ANDROID",
"clientVersion": "21.26.364",
"androidSdkVersion": 30,
"osName": "Android",
"osVersion": "11",
"visitorData": {{Json.Encode(visitorData)}},
"hl": "en",
"gl": "US",
"utcOffsetMinutes": 0
}
}
}
""",
"com.google.android.youtube/21.26.364 (Linux; U; Android 11) gzip",
cancellationToken
);

if (!playerResponse.IsAvailable)
throw new VideoUnavailableException($"Video '{videoId}' is not available.");

return playerResponse;
}

public async ValueTask<PlayerResponse> GetPlayerResponseAsync(
private ValueTask<PlayerResponse> GetPlayerResponseForTvAsync(
VideoId videoId,
string visitorData,
string? signatureTimestamp,
CancellationToken cancellationToken = default
)
{
var visitorData = await ResolveVisitorDataAsync(cancellationToken);

// The only client that can handle age-restricted videos without authentication is the
// TVHTML5_SIMPLY_EMBEDDED_PLAYER client.
// This client does require signature deciphering, so we only use it as a fallback.
using var request = new HttpRequestMessage(
HttpMethod.Post,
"https://www.youtube.com/youtubei/v1/player"
);

request.Content = new StringContent(
) =>
SendPlayerRequestAsync(
// lang=json
$$"""
{
Expand All @@ -188,19 +238,27 @@ public async ValueTask<PlayerResponse> GetPlayerResponseAsync(
}
}
}
"""
""",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36",
cancellationToken
);

using var response = await Http.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();

var playerResponse = PlayerResponse.Parse(
await response.Content.ReadAsStringAsync(cancellationToken)
private async ValueTask<PlayerResponse> SendPlayerRequestAsync(
string content,
string userAgent,
CancellationToken cancellationToken = default
)
{
using var request = new HttpRequestMessage(
HttpMethod.Post,
"https://www.youtube.com/youtubei/v1/player"
);

if (!playerResponse.IsAvailable)
throw new VideoUnavailableException($"Video '{videoId}' is not available.");
request.Content = new StringContent(content);
request.Headers.Add("User-Agent", userAgent);

return playerResponse;
using var response = await Http.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
return PlayerResponse.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
}
}