From 8c54cabc160a5e5f6ac4eccc1bd2f535df13abe9 Mon Sep 17 00:00:00 2001 From: Etienne Charland Date: Tue, 18 Aug 2026 20:17:34 -0500 Subject: [PATCH 1/3] Fixed YouTube downloads by mostly using VISIONOS agent. --- YoutubeExplode.Tests/StreamSpecs.cs | 19 +++ YoutubeExplode.Tests/TestData/VideoIds.cs | 2 + YoutubeExplode.Tests/VideoSpecs.cs | 2 + YoutubeExplode/Videos/Streams/StreamClient.cs | 11 +- YoutubeExplode/Videos/VideoController.cs | 117 +++++++++++------- 5 files changed, 101 insertions(+), 50 deletions(-) diff --git a/YoutubeExplode.Tests/StreamSpecs.cs b/YoutubeExplode.Tests/StreamSpecs.cs index b8096a00..44f8275a 100644 --- a/YoutubeExplode.Tests/StreamSpecs.cs +++ b/YoutubeExplode.Tests/StreamSpecs.cs @@ -138,6 +138,8 @@ 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.AndroidVrBotCheck)] + [InlineData(VideoIds.MadeForKids)] public async Task I_can_get_the_list_of_available_streams_of_any_playable_video(string videoId) { // Arrange @@ -233,6 +235,8 @@ 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.AndroidVrBotCheck)] + [InlineData(VideoIds.MadeForKids)] public async Task I_can_download_a_specific_stream_of_a_video(string videoId) { // Arrange @@ -251,6 +255,21 @@ public async Task I_can_download_a_specific_stream_of_a_video(string videoId) fileInfo.Length.Should().Be(streamInfo.Size.Bytes); } + [Fact] + public async Task I_can_get_the_list_of_available_streams_of_a_video_blocked_by_android_vr() + { + // Arrange + using var youtube = new YoutubeClient(); + + // Act + var manifest = await youtube.Videos.Streams.GetManifestAsync(VideoIds.AndroidVrBotCheck); + + // Assert + manifest.Streams.Should().NotBeEmpty(); + manifest.GetAudioStreams().Should().NotBeEmpty(); + manifest.GetVideoStreams().Should().NotBeEmpty(); + } + [Fact] public async Task I_can_download_the_highest_bitrate_stream_of_a_video() { diff --git a/YoutubeExplode.Tests/TestData/VideoIds.cs b/YoutubeExplode.Tests/TestData/VideoIds.cs index b303bf4d..20331d90 100644 --- a/YoutubeExplode.Tests/TestData/VideoIds.cs +++ b/YoutubeExplode.Tests/TestData/VideoIds.cs @@ -23,4 +23,6 @@ internal static class VideoIds public const string WithBrokenClosedCaptions = "1VKIIw05JnE"; public const string WithMultipleAudioLanguages = "ngqcjXfggHQ"; public const string WithUpscaledStreams = "IFACrIx5SZ0"; + public const string AndroidVrBotCheck = "u9Dg-g7t2l4"; + public const string MadeForKids = "nQuzyJ-C1Fc"; } diff --git a/YoutubeExplode.Tests/VideoSpecs.cs b/YoutubeExplode.Tests/VideoSpecs.cs index af0959b2..dfe52482 100644 --- a/YoutubeExplode.Tests/VideoSpecs.cs +++ b/YoutubeExplode.Tests/VideoSpecs.cs @@ -93,6 +93,8 @@ 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.AndroidVrBotCheck)] + [InlineData(VideoIds.MadeForKids)] public async Task I_can_get_the_metadata_of_any_available_video(string videoId) { // Arrange diff --git a/YoutubeExplode/Videos/Streams/StreamClient.cs b/YoutubeExplode/Videos/Streams/StreamClient.cs index 0441222b..af86e93d 100644 --- a/YoutubeExplode/Videos/Streams/StreamClient.cs +++ b/YoutubeExplode/Videos/Streams/StreamClient.cs @@ -92,14 +92,17 @@ private async IAsyncEnumerable 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)) { diff --git a/YoutubeExplode/Videos/VideoController.cs b/YoutubeExplode/Videos/VideoController.cs index 8132a89e..4013df15 100644 --- a/YoutubeExplode/Videos/VideoController.cs +++ b/YoutubeExplode/Videos/VideoController.cs @@ -85,39 +85,52 @@ await Http.GetStringAsync( } } - public async ValueTask GetPlayerResponseAsync( - VideoId videoId, + private async ValueTask SendPlayerRequestAsync( + string content, + string userAgent, CancellationToken cancellationToken = default ) { - 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" ); - request.Content = new StringContent( + request.Content = new StringContent(content); + request.Headers.Add("User-Agent", userAgent); + + using var response = await Http.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + return PlayerResponse.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + } + + public async ValueTask GetPlayerResponseAsync( + VideoId videoId, + CancellationToken cancellationToken = default + ) + { + var visitorData = await ResolveVisitorDataAsync(cancellationToken); + + // 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 playerResponse = await 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", @@ -125,27 +138,49 @@ public async ValueTask GetPlayerResponseAsync( } } } - """ + """, + "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 ); - // 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" - ); + if (playerResponse.IsPlayable) + return playerResponse; - using var response = await Http.SendAsync(request, cancellationToken); - response.EnsureSuccessStatusCode(); - - var playerResponse = PlayerResponse.Parse( - await response.Content.ReadAsStringAsync(cancellationToken) + // "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 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) + if (androidResponse.IsPlayable) + return androidResponse; + + if (!playerResponse.IsAvailable && !androidResponse.IsAvailable) throw new VideoUnavailableException($"Video '{videoId}' is not available."); - return playerResponse; + return playerResponse.IsAvailable ? playerResponse : androidResponse; } public async ValueTask GetPlayerResponseAsync( @@ -159,12 +194,7 @@ public async ValueTask GetPlayerResponseAsync( // 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( + var playerResponse = await SendPlayerRequestAsync( // lang=json $$""" { @@ -188,14 +218,9 @@ public async ValueTask GetPlayerResponseAsync( } } } - """ - ); - - using var response = await Http.SendAsync(request, cancellationToken); - response.EnsureSuccessStatusCode(); - - var playerResponse = PlayerResponse.Parse( - await response.Content.ReadAsStringAsync(cancellationToken) + """, + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36", + cancellationToken ); if (!playerResponse.IsAvailable) From 1d2f63ab2bbc6c42879e4bd5f82a60579dc6a6f8 Mon Sep 17 00:00:00 2001 From: Etienne Charland Date: Tue, 18 Aug 2026 20:26:35 -0500 Subject: [PATCH 2/3] Swap Live Stream and Upscaled video IDs with working ones. Code is fine. --- YoutubeExplode.Tests/TestData/VideoIds.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/YoutubeExplode.Tests/TestData/VideoIds.cs b/YoutubeExplode.Tests/TestData/VideoIds.cs index 20331d90..70e0efb5 100644 --- a/YoutubeExplode.Tests/TestData/VideoIds.cs +++ b/YoutubeExplode.Tests/TestData/VideoIds.cs @@ -13,7 +13,7 @@ internal static class VideoIds public const string ContentCheckSuicide = "4QXCPuwBz2E"; 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"; @@ -22,7 +22,7 @@ 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"; public const string AndroidVrBotCheck = "u9Dg-g7t2l4"; public const string MadeForKids = "nQuzyJ-C1Fc"; } From 3280bb4bff78d17368c7263e9b50e7752572d8bb Mon Sep 17 00:00:00 2001 From: Etienne Charland Date: Mon, 24 Aug 2026 03:12:18 -0500 Subject: [PATCH 3/3] Refactoring of code changes --- YoutubeExplode.Tests/StreamSpecs.cs | 21 +--- YoutubeExplode.Tests/TestData/VideoIds.cs | 3 +- YoutubeExplode.Tests/VideoSpecs.cs | 3 +- YoutubeExplode/Videos/VideoController.cs | 121 ++++++++++++++-------- 4 files changed, 81 insertions(+), 67 deletions(-) diff --git a/YoutubeExplode.Tests/StreamSpecs.cs b/YoutubeExplode.Tests/StreamSpecs.cs index 44f8275a..5d21cc6d 100644 --- a/YoutubeExplode.Tests/StreamSpecs.cs +++ b/YoutubeExplode.Tests/StreamSpecs.cs @@ -138,8 +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.AndroidVrBotCheck)] - [InlineData(VideoIds.MadeForKids)] + [InlineData(VideoIds.ForKids)] public async Task I_can_get_the_list_of_available_streams_of_any_playable_video(string videoId) { // Arrange @@ -235,8 +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.AndroidVrBotCheck)] - [InlineData(VideoIds.MadeForKids)] + [InlineData(VideoIds.ForKids)] public async Task I_can_download_a_specific_stream_of_a_video(string videoId) { // Arrange @@ -255,21 +253,6 @@ public async Task I_can_download_a_specific_stream_of_a_video(string videoId) fileInfo.Length.Should().Be(streamInfo.Size.Bytes); } - [Fact] - public async Task I_can_get_the_list_of_available_streams_of_a_video_blocked_by_android_vr() - { - // Arrange - using var youtube = new YoutubeClient(); - - // Act - var manifest = await youtube.Videos.Streams.GetManifestAsync(VideoIds.AndroidVrBotCheck); - - // Assert - manifest.Streams.Should().NotBeEmpty(); - manifest.GetAudioStreams().Should().NotBeEmpty(); - manifest.GetVideoStreams().Should().NotBeEmpty(); - } - [Fact] public async Task I_can_download_the_highest_bitrate_stream_of_a_video() { diff --git a/YoutubeExplode.Tests/TestData/VideoIds.cs b/YoutubeExplode.Tests/TestData/VideoIds.cs index 70e0efb5..18a0ad3f 100644 --- a/YoutubeExplode.Tests/TestData/VideoIds.cs +++ b/YoutubeExplode.Tests/TestData/VideoIds.cs @@ -11,6 +11,7 @@ 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 = "4xDzrJKXOOY"; @@ -23,6 +24,4 @@ internal static class VideoIds public const string WithBrokenClosedCaptions = "1VKIIw05JnE"; public const string WithMultipleAudioLanguages = "ngqcjXfggHQ"; public const string WithUpscaledStreams = "0N1_0SUGlDQ"; - public const string AndroidVrBotCheck = "u9Dg-g7t2l4"; - public const string MadeForKids = "nQuzyJ-C1Fc"; } diff --git a/YoutubeExplode.Tests/VideoSpecs.cs b/YoutubeExplode.Tests/VideoSpecs.cs index dfe52482..8af9f885 100644 --- a/YoutubeExplode.Tests/VideoSpecs.cs +++ b/YoutubeExplode.Tests/VideoSpecs.cs @@ -93,8 +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.AndroidVrBotCheck)] - [InlineData(VideoIds.MadeForKids)] + [InlineData(VideoIds.ForKids)] public async Task I_can_get_the_metadata_of_any_available_video(string videoId) { // Arrange diff --git a/YoutubeExplode/Videos/VideoController.cs b/YoutubeExplode/Videos/VideoController.cs index 4013df15..d0e1dcbb 100644 --- a/YoutubeExplode/Videos/VideoController.cs +++ b/YoutubeExplode/Videos/VideoController.cs @@ -85,38 +85,72 @@ await Http.GetStringAsync( } } - private async ValueTask SendPlayerRequestAsync( - string content, - string userAgent, + public async ValueTask GetPlayerResponseAsync( + VideoId videoId, CancellationToken cancellationToken = default ) { - using var request = new HttpRequestMessage( - HttpMethod.Post, - "https://www.youtube.com/youtubei/v1/player" + var visitorData = await ResolveVisitorDataAsync(cancellationToken); + + // 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; - request.Content = new StringContent(content); - request.Headers.Add("User-Agent", userAgent); + // "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; - using var response = await Http.SendAsync(request, cancellationToken); - response.EnsureSuccessStatusCode(); - return PlayerResponse.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + if (!visionResponse.IsAvailable && !androidResponse.IsAvailable) + throw new VideoUnavailableException($"Video '{videoId}' is not available."); + + return visionResponse.IsAvailable ? visionResponse : androidResponse; } public async ValueTask GetPlayerResponseAsync( VideoId videoId, + string? signatureTimestamp, CancellationToken cancellationToken = default ) { var visitorData = await ResolveVisitorDataAsync(cancellationToken); - // 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 playerResponse = await SendPlayerRequestAsync( + // 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 + ); + + if (!playerResponse.IsAvailable) + throw new VideoUnavailableException($"Video '{videoId}' is not available."); + + return playerResponse; + } + + private ValueTask GetPlayerResponseForVisionOsAsync( + VideoId videoId, + string visitorData, + CancellationToken cancellationToken = default + ) => + SendPlayerRequestAsync( // lang=json $$""" { @@ -143,12 +177,12 @@ public async ValueTask GetPlayerResponseAsync( cancellationToken ); - if (playerResponse.IsPlayable) - return playerResponse; - - // "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 SendPlayerRequestAsync( + private ValueTask GetPlayerResponseForAndroidAsync( + VideoId videoId, + string visitorData, + CancellationToken cancellationToken = default + ) => + SendPlayerRequestAsync( // lang=json $$""" { @@ -174,27 +208,13 @@ public async ValueTask GetPlayerResponseAsync( cancellationToken ); - if (androidResponse.IsPlayable) - return androidResponse; - - if (!playerResponse.IsAvailable && !androidResponse.IsAvailable) - throw new VideoUnavailableException($"Video '{videoId}' is not available."); - - return playerResponse.IsAvailable ? playerResponse : androidResponse; - } - - public async ValueTask GetPlayerResponseAsync( + private ValueTask 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. - var playerResponse = await SendPlayerRequestAsync( + ) => + SendPlayerRequestAsync( // lang=json $$""" { @@ -223,9 +243,22 @@ public async ValueTask GetPlayerResponseAsync( cancellationToken ); - if (!playerResponse.IsAvailable) - throw new VideoUnavailableException($"Video '{videoId}' is not available."); + private async ValueTask SendPlayerRequestAsync( + string content, + string userAgent, + CancellationToken cancellationToken = default + ) + { + using var request = new HttpRequestMessage( + HttpMethod.Post, + "https://www.youtube.com/youtubei/v1/player" + ); - return playerResponse; + request.Content = new StringContent(content); + request.Headers.Add("User-Agent", userAgent); + + using var response = await Http.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + return PlayerResponse.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); } }