From 4a2714e2e2f81c01eff55d6153e8874329a075fb Mon Sep 17 00:00:00 2001 From: fharlup Date: Sun, 18 Oct 2026 15:06:32 +0700 Subject: [PATCH] fix(youtube): resolve HTTP 403 playback errors using InnerTube VISIONOS client --- lib/extensions/dio.dart | 27 ++- lib/provider/server/routes/playback.dart | 60 +++--- lib/services/sourced_track/sourced_track.dart | 9 +- .../youtube_engine/newpipe_engine.dart | 40 ++-- .../youtube_engine/visionos_client.dart | 197 ++++++++++++++++++ .../youtube_explode_engine.dart | 12 ++ 6 files changed, 286 insertions(+), 59 deletions(-) create mode 100644 lib/services/youtube_engine/visionos_client.dart diff --git a/lib/extensions/dio.dart b/lib/extensions/dio.dart index 81bb1e70..86c584bb 100644 --- a/lib/extensions/dio.dart +++ b/lib/extensions/dio.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:spotube/services/youtube_engine/visionos_client.dart'; extension ChunkDownloaderDioExtension on Dio { Future chunkDownload( @@ -32,6 +33,7 @@ extension ChunkDownloaderDioExtension on Dio { try { int? totalLength; bool supportsRange = false; + final ua = YouTubeVisionOsClient.userAgentForUrl(urlPath); Response? headResp; try { @@ -39,7 +41,10 @@ extension ChunkDownloaderDioExtension on Dio { urlPath, queryParameters: queryParameters, options: Options( - headers: {'Range': 'bytes=0-0'}, + headers: { + 'Range': 'bytes=0-0', + 'User-Agent': ua, + }, followRedirects: true, ), ); @@ -62,6 +67,7 @@ extension ChunkDownloaderDioExtension on Dio { final resp = await get( urlPath, options: Options( + headers: {'User-Agent': ua}, responseType: ResponseType.stream, ), queryParameters: queryParameters, @@ -78,7 +84,12 @@ extension ChunkDownloaderDioExtension on Dio { queryParameters: queryParameters, cancelToken: cancelToken, deleteOnError: deleteOnError, - options: options, + options: (options ?? Options()).copyWith( + headers: { + ...?options?.headers, + 'User-Agent': ua, + }, + ), data: data, ); } @@ -97,7 +108,12 @@ extension ChunkDownloaderDioExtension on Dio { queryParameters: queryParameters, cancelToken: cancelToken, deleteOnError: deleteOnError, - options: options, + options: (options ?? Options()).copyWith( + headers: { + ...?options?.headers, + 'User-Agent': ua, + }, + ), data: data, ); } @@ -119,7 +135,10 @@ extension ChunkDownloaderDioExtension on Dio { urlPath, options: Options( responseType: ResponseType.stream, - headers: {'Range': 'bytes=$start-$end'}, + headers: { + 'Range': 'bytes=$start-$end', + 'User-Agent': ua, + }, ), queryParameters: queryParameters, cancelToken: cancelToken, diff --git a/lib/provider/server/routes/playback.dart b/lib/provider/server/routes/playback.dart index db6bf8f5..7704b6b9 100644 --- a/lib/provider/server/routes/playback.dart +++ b/lib/provider/server/routes/playback.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import 'dart:math'; import 'package:dio/dio.dart' hide Response; import 'package:dio/dio.dart' as dio_lib; @@ -20,21 +19,8 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:spotube/services/youtube_engine/visionos_client.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; - -final _deviceClients = Set.unmodifiable({ - YoutubeApiClient.ios, - YoutubeApiClient.android, - YoutubeApiClient.mweb, - YoutubeApiClient.safari, -}); - -String? get _randomUserAgent => _deviceClients - .elementAt( - Random().nextInt(_deviceClients.length), - ) - .payload["context"]["client"]["userAgent"]; class ServerPlaybackRoutes { final Ref ref; @@ -111,7 +97,7 @@ class ServerPlaybackRoutes { final options = Options( headers: { - "user-agent": _randomUserAgent, + "user-agent": YouTubeVisionOsClient.userAgentForUrl(url), "Cache-Control": "max-age=3600", "Connection": "keep-alive", "host": Uri.parse(url).host, @@ -162,22 +148,35 @@ class ServerPlaybackRoutes { .swapWithNextSibling() .then((track) => track.url!); - final options = Options( - headers: { - ...headers, - "user-agent": _randomUserAgent, - "Cache-Control": "max-age=3600", - "Connection": "keep-alive", - "host": Uri.parse(url).host, - }, - responseType: ResponseType.stream, - validateStatus: (status) => status! < 400, - ); + final rangeHeader = headers["range"] ?? headers["Range"]; + + Options buildStreamOptions(String targetUrl) => Options( + headers: { + if (rangeHeader != null) "range": rangeHeader, + "user-agent": YouTubeVisionOsClient.userAgentForUrl(targetUrl), + "Cache-Control": "max-age=3600", + "Connection": "keep-alive", + "host": Uri.parse(targetUrl).host, + }, + responseType: ResponseType.stream, + validateStatus: (status) => status! < 400, + ); + + Options buildHeadOptions(String targetUrl) => Options( + headers: { + "user-agent": YouTubeVisionOsClient.userAgentForUrl(targetUrl), + "Cache-Control": "max-age=3600", + "Connection": "keep-alive", + "host": Uri.parse(targetUrl).host, + }, + responseType: ResponseType.bytes, + validateStatus: (status) => status! < 400, + ); final contentLengthRes = await Future.value( dio.head( url, - options: options.copyWith(responseType: ResponseType.bytes), + options: buildHeadOptions(url), ), ).catchError((e, stack) async { AppLogger.reportError(e, stack); @@ -188,7 +187,7 @@ class ServerPlaybackRoutes { url = sourcedTrack.url!; - return dio.head(url, options: options); + return dio.head(url, options: buildHeadOptions(url)); }); // Redirect to m3u8 link directly as it handles range requests internally @@ -206,7 +205,8 @@ class ServerPlaybackRoutes { ); } - final res = await dio.get(url, options: options); + final res = + await dio.get(url, options: buildStreamOptions(url)); AppLogger.log.i( "Response for track: ${track.query.name}\n" diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index 385e5be6..d0b8be0d 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -15,6 +15,7 @@ import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/metadata/errors/exceptions.dart'; import 'package:spotube/services/sourced_track/exceptions.dart'; +import 'package:spotube/services/youtube_engine/visionos_client.dart'; import 'package:spotube/utils/service_utils.dart'; final officialMusicRegex = RegExp( @@ -265,8 +266,12 @@ class SourcedTrack extends BasicSourcedTrack { for (final source in sources) { final res = await globalDio.head( source.url, - options: - Options(validateStatus: (status) => status != null && status < 500), + options: Options( + headers: { + "user-agent": YouTubeVisionOsClient.userAgentForUrl(source.url), + }, + validateStatus: (status) => status != null && status < 500, + ), ); stringBuffer.writeln( diff --git a/lib/services/youtube_engine/newpipe_engine.dart b/lib/services/youtube_engine/newpipe_engine.dart index c865b66c..b5394e68 100644 --- a/lib/services/youtube_engine/newpipe_engine.dart +++ b/lib/services/youtube_engine/newpipe_engine.dart @@ -1,5 +1,6 @@ import 'package:flutter_new_pipe_extractor/flutter_new_pipe_extractor.dart' hide Engagement; +import 'package:spotube/services/youtube_engine/visionos_client.dart'; import 'package:spotube/services/youtube_engine/youtube_engine.dart'; import 'package:spotube/utils/platform.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; @@ -91,20 +92,24 @@ class NewPipeEngine implements YouTubeEngine { @override Future getStreamManifest(String videoId) async { - final video = await NewPipeExtractor.getVideoInfo(videoId); + try { + return await YouTubeVisionOsClient.getStreamManifest(videoId); + } catch (_) { + final video = await NewPipeExtractor.getVideoInfo(videoId); - final streams = - video.audioStreams.map((stream) => _parseAudioStream(stream, videoId)); + final streams = video.audioStreams + .map((stream) => _parseAudioStream(stream, videoId)); - if (streams.isEmpty) { - final videoStreams = video.videoStreams - .map((stream) => _parseVideoStream(stream, videoId)); - if (videoStreams.isNotEmpty) { - return StreamManifest(videoStreams); + if (streams.isEmpty) { + final videoStreams = video.videoStreams + .map((stream) => _parseVideoStream(stream, videoId)); + if (videoStreams.isNotEmpty) { + return StreamManifest(videoStreams); + } } - } - return StreamManifest(streams); + return StreamManifest(streams); + } } @override @@ -117,19 +122,8 @@ class NewPipeEngine implements YouTubeEngine { @override Future<(Video, StreamManifest)> getVideoWithStreamInfo(String videoId) async { final video = await NewPipeExtractor.getVideoInfo(videoId); - - final streams = - video.audioStreams.map((stream) => _parseAudioStream(stream, videoId)); - - if (streams.isEmpty) { - final videoStreams = video.videoStreams - .map((stream) => _parseVideoStream(stream, videoId)); - if (videoStreams.isNotEmpty) { - return (_parseVideo(video), StreamManifest(videoStreams)); - } - } - - return (_parseVideo(video), StreamManifest(streams)); + final manifest = await getStreamManifest(videoId); + return (_parseVideo(video), manifest); } @override diff --git a/lib/services/youtube_engine/visionos_client.dart b/lib/services/youtube_engine/visionos_client.dart new file mode 100644 index 00000000..1ae8305e --- /dev/null +++ b/lib/services/youtube_engine/visionos_client.dart @@ -0,0 +1,197 @@ +import 'dart:math'; + +import 'package:dio/dio.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +class YouTubeVisionOsClient { + static const String visionOsUserAgent = + 'com.google.visionos.youtube/1.04(RealityDevice17,1; U; CPU visionOS 26_6_0 like Mac OS X; US)'; + static const String androidUserAgent = + 'com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip'; + static const String iosUserAgent = + 'com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)'; + + static const String _cpnAlphabet = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + + static final Random _random = Random(); + static final Dio _dio = Dio(); + static String? _cachedVisitorData; + static DateTime? _visitorDataExpiry; + + static String userAgentForUrl(String url) { + final client = Uri.tryParse(url)?.queryParameters['c']; + return switch (client) { + 'VISIONOS' => visionOsUserAgent, + 'ANDROID' => androidUserAgent, + 'IOS' => iosUserAgent, + _ => visionOsUserAgent, + }; + } + + static String _generateRandomString(int length) { + return List.generate( + length, + (_) => _cpnAlphabet[_random.nextInt(_cpnAlphabet.length)], + ).join(); + } + + static Map get _baseClient => const { + 'clientName': 'VISIONOS', + 'clientVersion': '1.04', + 'clientScreen': 'WATCH', + 'platform': 'MOBILE', + 'deviceMake': 'Apple', + 'deviceModel': 'RealityDevice17,1', + 'osName': 'visionOS', + 'osVersion': '26.6.0.23O770', + 'hl': 'en-GB', + 'gl': 'US', + 'utcOffsetMinutes': 0, + }; + + static Future _getVisitorData() async { + final now = DateTime.now(); + if (_cachedVisitorData != null && + _visitorDataExpiry != null && + now.isBefore(_visitorDataExpiry!)) { + return _cachedVisitorData!; + } + + final res = await _dio.post( + 'https://www.youtube.com/youtubei/v1/visitor_id?prettyPrint=false', + data: { + 'context': { + 'client': _baseClient, + 'request': { + 'internalExperimentFlags': const [], + 'useSsl': true, + }, + 'user': { + 'lockedSafetyMode': false, + }, + }, + }, + options: Options( + headers: const { + 'User-Agent': visionOsUserAgent, + 'X-Goog-Api-Format-Version': '2', + 'Content-Type': 'application/json', + }, + ), + ); + + final visitorData = res.data?['responseContext']?['visitorData'] as String?; + if (visitorData == null || visitorData.isEmpty) { + throw Exception('Failed to obtain YouTube visitorData'); + } + + _cachedVisitorData = visitorData; + _visitorDataExpiry = now.add(const Duration(minutes: 30)); + return visitorData; + } + + static Future getStreamManifest(String videoId) async { + final visitorData = await _getVisitorData(); + final cpn = _generateRandomString(16); + final tParam = _generateRandomString(12); + + final playerRes = await _dio.post( + 'https://youtubei.googleapis.com/youtubei/v1/player?prettyPrint=false&t=$tParam&id=$videoId', + data: { + 'videoId': videoId, + 'cpn': cpn, + 'contentCheckOk': true, + 'racyCheckOk': true, + 'context': { + 'client': { + ..._baseClient, + 'visitorData': visitorData, + }, + 'request': { + 'internalExperimentFlags': const [], + 'useSsl': true, + }, + 'user': { + 'lockedSafetyMode': false, + }, + }, + }, + options: Options( + headers: const { + 'User-Agent': visionOsUserAgent, + 'X-Goog-Api-Format-Version': '2', + 'Content-Type': 'application/json', + }, + ), + ); + + final adaptiveFormats = + playerRes.data?['streamingData']?['adaptiveFormats'] as List?; + if (adaptiveFormats == null || adaptiveFormats.isEmpty) { + _cachedVisitorData = null; + throw Exception( + 'No adaptiveFormats in VISIONOS response for video $videoId: ' + '${playerRes.data?["playabilityStatus"]}', + ); + } + + final streams = []; + for (final raw in adaptiveFormats) { + if (raw is! Map) continue; + final mimeTypeStr = raw['mimeType'] as String?; + final rawUrl = raw['url'] as String?; + final itag = raw['itag'] as int?; + final bitrateVal = (raw['bitrate'] ?? raw['averageBitrate']) as int?; + + if (mimeTypeStr == null || + !mimeTypeStr.startsWith('audio/') || + rawUrl == null || + itag == null || + bitrateVal == null) { + continue; + } + + final audioTrack = raw['audioTrack']; + if (audioTrack is Map && audioTrack['audioIsDefault'] == false) { + continue; + } + + final mediaType = MediaType.parse(mimeTypeStr); + final container = StreamContainer.parse(mediaType.subtype); + final contentLength = + int.tryParse(raw['contentLength']?.toString() ?? '') ?? 0; + final fileSize = + contentLength > 0 ? FileSize(contentLength) : FileSize.unknown; + final codecStr = mediaType.parameters['codecs'] ?? mediaType.subtype; + final urlWithCpn = rawUrl.contains('cpn=') ? rawUrl : '$rawUrl&cpn=$cpn'; + + streams.add( + AudioOnlyStreamInfo( + VideoId(videoId), + itag, + Uri.parse(urlWithCpn), + container, + fileSize, + Bitrate(bitrateVal), + codecStr, + switch (bitrateVal) { + > 130 * 1024 => 'high', + > 64 * 1024 => 'medium', + _ => 'low', + }, + const [], + mediaType, + null, + ), + ); + } + + if (streams.isEmpty) { + throw Exception('No audio streams parsed from VISIONOS for $videoId'); + } + + return StreamManifest(streams); + } +} diff --git a/lib/services/youtube_engine/youtube_explode_engine.dart b/lib/services/youtube_engine/youtube_explode_engine.dart index f8587ca6..31350628 100644 --- a/lib/services/youtube_engine/youtube_explode_engine.dart +++ b/lib/services/youtube_engine/youtube_explode_engine.dart @@ -1,6 +1,7 @@ import 'dart:isolate'; import 'package:flutter/foundation.dart'; +import 'package:spotube/services/youtube_engine/visionos_client.dart'; import 'package:spotube/services/youtube_engine/youtube_engine.dart'; // import 'package:youtube_explode_dart/solvers.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; @@ -157,12 +158,23 @@ class YouTubeExplodeEngine implements YouTubeEngine { @override Future getStreamManifest(String videoId) async { + try { + final manifest = await YouTubeVisionOsClient.getStreamManifest(videoId); + final filtered = manifest.audioOnly.where( + (stream) => stream.bitrate.bitsPerSecond >= 40960, + ); + if (filtered.isNotEmpty) { + return StreamManifest(filtered); + } + } catch (_) {} + await IsolatedYoutubeExplode.initialize(); final streamManifest = await _youtubeExplode.manifest( videoId, requireWatchPage: false, ytClients: [ + YoutubeApiClient.androidSdkless, YoutubeApiClient.ios, YoutubeApiClient.androidVr, YoutubeApiClient.android,