mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
fix(youtube): resolve HTTP 403 playback errors using InnerTube VISIONOS client
This commit is contained in:
parent
69a310c78f
commit
4a2714e2e2
@ -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<Response> 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<ResponseBody>(
|
||||
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,
|
||||
|
||||
@ -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<dio_lib.Response?>.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<ResponseBody>(url, options: options);
|
||||
final res =
|
||||
await dio.get<ResponseBody>(url, options: buildStreamOptions(url));
|
||||
|
||||
AppLogger.log.i(
|
||||
"Response for track: ${track.query.name}\n"
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<StreamManifest> 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
|
||||
|
||||
197
lib/services/youtube_engine/visionos_client.dart
Normal file
197
lib/services/youtube_engine/visionos_client.dart
Normal file
@ -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<String, dynamic> 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<String> _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<StreamManifest> 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 = <AudioOnlyStreamInfo>[];
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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<StreamManifest> 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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user