diff --git a/lib/src/reverse_engineering/models/stream_info_provider.dart b/lib/src/reverse_engineering/models/stream_info_provider.dart index f1b506e..06be47e 100644 --- a/lib/src/reverse_engineering/models/stream_info_provider.dart +++ b/lib/src/reverse_engineering/models/stream_info_provider.dart @@ -63,4 +63,4 @@ abstract class StreamInfoProvider { /// AudioTrack? get audioTrack => null; -} \ No newline at end of file +} diff --git a/lib/src/reverse_engineering/player/player_response.dart b/lib/src/reverse_engineering/player/player_response.dart index a0dbd35..9f784f1 100644 --- a/lib/src/reverse_engineering/player/player_response.dart +++ b/lib/src/reverse_engineering/player/player_response.dart @@ -96,8 +96,8 @@ class PlayerResponse { // extract the preview video ID using regex. ?.replaceAll('-', '+') .replaceAll('_', '/') - .pipe(base64.decode) - .pipe(utf8.decode) + .pipe((e) => base64.decode(e)) + .pipe((e) => utf8.decode(e, allowMalformed: true)) .pipe( (value) => RegExp('video_id=(.{11})').firstMatch(value)?.group(1), ) diff --git a/lib/src/reverse_engineering/youtube_http_client.dart b/lib/src/reverse_engineering/youtube_http_client.dart index 7611343..75ca5c3 100644 --- a/lib/src/reverse_engineering/youtube_http_client.dart +++ b/lib/src/reverse_engineering/youtube_http_client.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:http/http.dart' as http; import '../exceptions/exceptions.dart'; @@ -142,6 +143,7 @@ class YoutubeHttpClient extends http.BaseClient { bool validate = true, int start = 0, int errorCount = 0, + required StreamClient streamClient, }) { if (streamInfo.fragments.isNotEmpty) { // DASH(fragmented) stream @@ -156,6 +158,7 @@ class YoutubeHttpClient extends http.BaseClient { // Normal stream return _getStream( streamInfo, + streamClient: streamClient, headers: headers, validate: validate, start: start, @@ -187,24 +190,45 @@ class YoutubeHttpClient extends http.BaseClient { bool validate = true, int start = 0, int errorCount = 0, + required StreamClient streamClient, }) async* { - final url = streamInfo.url; + var url = streamInfo.url; var bytesCount = start; - while (!_closed && bytesCount != streamInfo.size.totalBytes) { try { - final response = await retry(this, () { + final response = await retry(this, () async { final from = bytesCount; final to = (streamInfo.isThrottled - ? (bytesCount + 9898989) + ? (bytesCount + 10379935) : streamInfo.size.totalBytes) - 1; - final request = - http.Request('get', url.setQueryParam('range', '$from-$to')); + + late final http.Request request; + if (url.queryParameters['c'] == 'ANDROID') { + request = http.Request('get', url); + request.headers['Range'] = 'bytes=$from-$to'; + } else { + request = + http.Request('get', url.setQueryParam('range', '$from-$to')); + } return send(request); }); if (validate) { - _validateResponse(response, response.statusCode); + try { + _validateResponse(response, response.statusCode); + } on FatalFailureException { + final newManifest = + await streamClient.getManifest(streamInfo.videoId); + final stream = newManifest.streams + .firstWhereOrNull((e) => e.tag == streamInfo.tag); + if (stream == null) { + print( + 'Error: Could not find the stream in the new manifest (due to Youtube error)'); + rethrow; + } + url = stream.url; + continue; + } } final stream = StreamController>(); response.stream.listen( @@ -227,6 +251,7 @@ class YoutubeHttpClient extends http.BaseClient { await Future.delayed(const Duration(milliseconds: 500)); yield* _getStream( streamInfo, + streamClient: streamClient, headers: headers, validate: validate, start: bytesCount, @@ -316,7 +341,7 @@ class YoutubeHttpClient extends http.BaseClient { } }); - //print(request); + // print(request); // print(StackTrace.current); return _httpClient.send(request); } diff --git a/lib/src/videos/streams/mixins/audio_stream_info.dart b/lib/src/videos/streams/mixins/audio_stream_info.dart index 0d45315..f618113 100644 --- a/lib/src/videos/streams/mixins/audio_stream_info.dart +++ b/lib/src/videos/streams/mixins/audio_stream_info.dart @@ -7,6 +7,4 @@ mixin AudioStreamInfo on StreamInfo { /// Audio track which describes the language of the audio. AudioTrack? get audioTrack; - - } diff --git a/lib/src/videos/streams/mixins/stream_info.dart b/lib/src/videos/streams/mixins/stream_info.dart index b5e6f95..719afda 100644 --- a/lib/src/videos/streams/mixins/stream_info.dart +++ b/lib/src/videos/streams/mixins/stream_info.dart @@ -6,6 +6,9 @@ import '../models/audio_track.dart'; /// Generic YouTube media stream. mixin StreamInfo { + /// The video id of the video this stream belongs to. + VideoId get videoId; + /// Whether the stream is throttled or not. bool get isThrottled => url.queryParameters['ratebypass']?.toLowerCase() != 'yes'; @@ -42,7 +45,18 @@ mixin StreamInfo { /// Extension for Iterables of StreamInfo. extension StreamInfoIterableExt on Iterable { /// Gets the stream with highest bitrate. - T withHighestBitrate() => sortByBitrate().first; + T withHighestBitrate({String? language}) { + return where((stream) { + if (stream is AudioStreamInfo) { + if (language == null && + (stream.audioTrack == null || stream.audioTrack!.audioIsDefault)) { + return true; + } + return stream.audioTrack?.id == language; + } + return true; + }).sortByBitrate().first; + } /// Gets the video streams sorted by bitrate in ascending order. /// This returns new list without editing the original list. @@ -64,7 +78,8 @@ extension StreamInfoIterableExt on Iterable { if (e is VideoOnlyStreamInfo) 'video only', if (e is MuxedStreamInfo) 'muxed', e.size, - if (e case AudioStreamInfo(:AudioTrack audioTrack)) audioTrack.displayName, + if (e case AudioStreamInfo(:AudioTrack audioTrack)) + audioTrack.displayName, ]); } return column.toString(); diff --git a/lib/src/videos/streams/models/audio_track.dart b/lib/src/videos/streams/models/audio_track.dart index 14ce6f8..170f67d 100644 --- a/lib/src/videos/streams/models/audio_track.dart +++ b/lib/src/videos/streams/models/audio_track.dart @@ -11,6 +11,6 @@ class AudioTrack with _$AudioTrack { required String id, required bool audioIsDefault}) = _AudioTrack; - factory AudioTrack.fromJson(Map json) - => _$AudioTrackFromJson(json); + factory AudioTrack.fromJson(Map json) => + _$AudioTrackFromJson(json); } diff --git a/lib/src/videos/streams/stream_client.dart b/lib/src/videos/streams/stream_client.dart index bcccda2..a37e8a9 100644 --- a/lib/src/videos/streams/stream_client.dart +++ b/lib/src/videos/streams/stream_client.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import '../../exceptions/exceptions.dart'; import '../../extensions/helpers_extension.dart'; import '../../retry.dart'; @@ -6,6 +8,7 @@ import '../../reverse_engineering/heuristics.dart'; import '../../reverse_engineering/models/stream_info_provider.dart'; import '../../reverse_engineering/pages/watch_page.dart'; import '../../reverse_engineering/youtube_http_client.dart'; +import '../video_controller.dart'; import '../video_id.dart'; import 'stream_controller.dart'; import 'streams.dart'; @@ -20,6 +23,84 @@ class StreamClient { CipherManifest? _cipherManifest; + /// Gets the manifest that contains information + /// about available streams in the specified video. + /// + /// If [fullManifest] is set to `true`, more streams types will be fetched + /// and track of different languages (if present) will be included. + Future getManifest(dynamic videoId, + {bool fullManifest = false}) { + videoId = VideoId.fromString(videoId); + + return retry(_httpClient, () async { + final streams = + await _getStreams(videoId, fullManifest: fullManifest).toList(); + if (streams.isEmpty) { + throw VideoUnavailableException( + 'Video "$videoId" does not contain any playable streams.', + ); + } + + final response = await _httpClient.head(streams.first.url); + if (response.statusCode == 403) { + throw YoutubeExplodeException( + 'Video $videoId returned 403 (stream: ${streams.first.tag}', + ); + } + + // Remove duplicate streams (must have same tag and audioTrack (if it's an audio stream)) + final uniqueStreams = LinkedHashSet( + equals: (a, b) { + if (a.runtimeType != b.runtimeType) return false; + if (a is AudioStreamInfo && b is AudioStreamInfo) { + return a.tag == b.tag && a.audioTrack == b.audioTrack; + } + return a.tag == b.tag; + }, + hashCode: (e) { + if (e is AudioStreamInfo) { + return e.tag.hashCode ^ e.audioTrack.hashCode; + } + return e.tag.hashCode; + }, + ); + uniqueStreams.addAll(streams); + + return StreamManifest(uniqueStreams.toList()); + }); + } + + /// Gets the HTTP Live Stream (HLS) manifest URL + /// for the specified video (if it's a live video stream). + Future getHttpLiveStreamUrl(VideoId videoId) async { + final watchPage = await WatchPage.get(_httpClient, videoId.value); + + final playerResponse = watchPage.playerResponse; + + if (playerResponse == null) { + throw TransientFailureException( + "Couldn't extract the playerResponse from the Watch Page!", + ); + } + + if (!playerResponse.isVideoPlayable) { + throw VideoUnplayableException.unplayable( + videoId, + reason: playerResponse.videoPlayabilityError ?? '', + ); + } + + final hlsManifest = playerResponse.hlsManifestUrl; + if (hlsManifest == null) { + throw VideoUnplayableException.notLiveStream(videoId); + } + return hlsManifest; + } + + /// Gets the actual stream which is identified by the specified metadata. + Stream> get(StreamInfo streamInfo) => + _httpClient.getStream(streamInfo, streamClient: this); + Future _getCipherManifest() async { if (_cipherManifest != null) { return _cipherManifest!; @@ -35,9 +116,67 @@ class StreamClient { return _cipherManifest = manifest; } + Stream _getStreams(VideoId videoId, + {required bool fullManifest}) async* { + try { + yield* _getStream(videoId, VideoController.androidTestSuiteClient); + if (fullManifest) { + yield* _getStream(videoId, VideoController.androidClient); + } + } on VideoUnplayableException { + yield* _getCipherStream(videoId); + } + } + + Stream _getStream(VideoId videoId, + Map>> ytClient) async* { + final playerResponse = + await _controller.getPlayerResponse(videoId, ytClient); + if (!playerResponse.previewVideoId.isNullOrWhiteSpace) { + throw VideoRequiresPurchaseException.preview( + videoId, + VideoId(playerResponse.previewVideoId!), + ); + } + + if (playerResponse.videoPlayabilityError?.contains('payment') ?? false) { + throw VideoRequiresPurchaseException(videoId); + } + + if (!playerResponse.isVideoPlayable) { + throw VideoUnplayableException.unplayable( + videoId, + reason: playerResponse.videoPlayabilityError ?? '', + ); + } + yield* _parseStreamInfo(playerResponse.streams, videoId); + + if (!playerResponse.dashManifestUrl.isNullOrWhiteSpace) { + final dashManifest = + await _controller.getDashManifest(playerResponse.dashManifestUrl!); + yield* _parseStreamInfo(dashManifest.streams, videoId); + } + } + + Stream _getCipherStream(VideoId videoId) async* { + final cipherManifest = await _getCipherManifest(); + final playerResponse = await _controller.getPlayerResponseWithSignature( + videoId, + cipherManifest.signatureTimestamp, + ); + + if (!playerResponse.isVideoPlayable) { + throw VideoUnplayableException.unplayable( + videoId, + reason: playerResponse.videoPlayabilityError ?? '', + ); + } + + yield* _parseStreamInfo(playerResponse.streams, videoId); + } + Stream _parseStreamInfo( - Iterable streams, - ) async* { + Iterable streams, VideoId videoId) async* { for (final stream in streams) { final itag = stream.tag; var url = Uri.parse(stream.url); @@ -79,8 +218,9 @@ class StreamClient { // Muxed if (!audioCodec.isNullOrWhiteSpace && stream.source != StreamSource.adaptive) { - assert(stream.audioTrack== null); + assert(stream.audioTrack == null); yield MuxedStreamInfo( + videoId, itag, url, container, @@ -99,6 +239,7 @@ class StreamClient { // Video only yield VideoOnlyStreamInfo( + videoId, itag, url, container, @@ -116,111 +257,20 @@ class StreamClient { // Audio-only } else if (!audioCodec.isNullOrWhiteSpace) { yield AudioOnlyStreamInfo( - itag, - url, - container, - fileSize, - bitrate, - audioCodec!, - stream.qualityLabel, - stream.fragments ?? const [], - stream.codec, - stream.audioTrack - ); + videoId, + itag, + url, + container, + fileSize, + bitrate, + audioCodec!, + stream.qualityLabel, + stream.fragments ?? const [], + stream.codec, + stream.audioTrack); } else { throw YoutubeExplodeException('Could not extract stream codec'); } } } - - Stream _getStreams(VideoId videoId) async* { - var playerResponse = await _controller.getPlayerResponse(videoId); - if (!playerResponse.previewVideoId.isNullOrWhiteSpace) { - throw VideoRequiresPurchaseException.preview( - videoId, - VideoId(playerResponse.previewVideoId!), - ); - } - - if (playerResponse.videoPlayabilityError?.contains('payment') ?? false) { - throw VideoRequiresPurchaseException(videoId); - } - if (!playerResponse.isVideoPlayable) { - final cipherManifest = await _getCipherManifest(); - playerResponse = await _controller.getPlayerResponseWithSignature( - videoId, - cipherManifest.signatureTimestamp, - ); - } - - if (!playerResponse.isVideoPlayable) { - throw VideoUnplayableException.unplayable( - videoId, - reason: playerResponse.videoPlayabilityError ?? '', - ); - } - - yield* _parseStreamInfo(playerResponse.streams); - - if (!playerResponse.dashManifestUrl.isNullOrWhiteSpace) { - final dashManifest = - await _controller.getDashManifest(playerResponse.dashManifestUrl!); - yield* _parseStreamInfo(dashManifest.streams); - } - } - - /// Gets the manifest that contains information - /// about available streams in the specified video. - Future getManifest(dynamic videoId) { - videoId = VideoId.fromString(videoId); - - return retry(_httpClient, () async { - final streams = await _getStreams(videoId).toList(); - if (streams.isEmpty) { - throw VideoUnavailableException( - 'Video "$videoId" does not contain any playable streams.', - ); - } - - final response = await _httpClient.head(streams.first.url); - if (response.statusCode == 403) { - throw YoutubeExplodeException( - 'Video $videoId returned 403 (stream: ${streams.first.tag}', - ); - } - - return StreamManifest(streams); - }); - } - - /// Gets the HTTP Live Stream (HLS) manifest URL - /// for the specified video (if it's a live video stream). - Future getHttpLiveStreamUrl(VideoId videoId) async { - final watchPage = await WatchPage.get(_httpClient, videoId.value); - - final playerResponse = watchPage.playerResponse; - - if (playerResponse == null) { - throw TransientFailureException( - "Couldn't extract the playerResponse from the Watch Page!", - ); - } - - if (!playerResponse.isVideoPlayable) { - throw VideoUnplayableException.unplayable( - videoId, - reason: playerResponse.videoPlayabilityError ?? '', - ); - } - - final hlsManifest = playerResponse.hlsManifestUrl; - if (hlsManifest == null) { - throw VideoUnplayableException.notLiveStream(videoId); - } - return hlsManifest; - } - - /// Gets the actual stream which is identified by the specified metadata. - Stream> get(StreamInfo streamInfo) => - _httpClient.getStream(streamInfo); } diff --git a/lib/src/videos/streams/types/audio_only_stream_info.dart b/lib/src/videos/streams/types/audio_only_stream_info.dart index e86f049..a198a70 100644 --- a/lib/src/videos/streams/types/audio_only_stream_info.dart +++ b/lib/src/videos/streams/types/audio_only_stream_info.dart @@ -2,6 +2,7 @@ import 'package:http_parser/http_parser.dart'; import 'package:json_annotation/json_annotation.dart'; import '../../../reverse_engineering/models/fragment.dart'; +import '../../video_id.dart'; import '../mixins/stream_info.dart'; import '../models/audio_track.dart'; import '../streams.dart'; @@ -11,6 +12,9 @@ part 'audio_only_stream_info.g.dart'; /// YouTube media stream that only contains audio. @JsonSerializable() class AudioOnlyStreamInfo with StreamInfo, AudioStreamInfo { + @override + final VideoId videoId; + @override final int tag; @@ -42,7 +46,9 @@ class AudioOnlyStreamInfo with StreamInfo, AudioStreamInfo { @override final AudioTrack? audioTrack; - AudioOnlyStreamInfo(this.tag, + AudioOnlyStreamInfo( + this.videoId, + this.tag, this.url, this.container, this.size, @@ -54,7 +60,8 @@ class AudioOnlyStreamInfo with StreamInfo, AudioStreamInfo { this.audioTrack); @override - String toString() => 'Audio-only ($tag | $container)'; + String toString() => + 'Audio-only ($tag | $container | ${audioTrack?.displayName})'; factory AudioOnlyStreamInfo.fromJson(Map json) => _$AudioOnlyStreamInfoFromJson(json); diff --git a/lib/src/videos/streams/types/audio_only_stream_info.g.dart b/lib/src/videos/streams/types/audio_only_stream_info.g.dart index 4400bb8..dd6e8da 100644 --- a/lib/src/videos/streams/types/audio_only_stream_info.g.dart +++ b/lib/src/videos/streams/types/audio_only_stream_info.g.dart @@ -10,6 +10,7 @@ part of 'audio_only_stream_info.dart'; AudioOnlyStreamInfo _$AudioOnlyStreamInfoFromJson(Map json) => AudioOnlyStreamInfo( + VideoId.fromJson(json['videoId'] as Map), json['tag'] as int, Uri.parse(json['url'] as String), StreamContainer.fromJson(json['container'] as Map), @@ -29,6 +30,7 @@ AudioOnlyStreamInfo _$AudioOnlyStreamInfoFromJson(Map json) => Map _$AudioOnlyStreamInfoToJson( AudioOnlyStreamInfo instance) => { + 'videoId': instance.videoId, 'tag': instance.tag, 'url': instance.url.toString(), 'container': instance.container, diff --git a/lib/src/videos/streams/types/muxed_stream_info.dart b/lib/src/videos/streams/types/muxed_stream_info.dart index ade6daa..62e8db8 100644 --- a/lib/src/videos/streams/types/muxed_stream_info.dart +++ b/lib/src/videos/streams/types/muxed_stream_info.dart @@ -2,6 +2,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:http_parser/http_parser.dart'; import '../../../reverse_engineering/models/fragment.dart'; +import '../../video_id.dart'; import '../models/audio_track.dart'; import '../streams.dart'; import '../mixins/stream_info.dart'; @@ -11,6 +12,9 @@ part 'muxed_stream_info.g.dart'; /// YouTube media stream that contains both audio and video. @JsonSerializable() class MuxedStreamInfo with StreamInfo, AudioStreamInfo, VideoStreamInfo { + @override + final VideoId videoId; + @override final int tag; @@ -64,6 +68,7 @@ class MuxedStreamInfo with StreamInfo, AudioStreamInfo, VideoStreamInfo { /// Initializes an instance of [MuxedStreamInfo] MuxedStreamInfo( + this.videoId, this.tag, this.url, this.container, diff --git a/lib/src/videos/streams/types/muxed_stream_info.g.dart b/lib/src/videos/streams/types/muxed_stream_info.g.dart index cc6b839..2f000a5 100644 --- a/lib/src/videos/streams/types/muxed_stream_info.g.dart +++ b/lib/src/videos/streams/types/muxed_stream_info.g.dart @@ -10,6 +10,7 @@ part of 'muxed_stream_info.dart'; MuxedStreamInfo _$MuxedStreamInfoFromJson(Map json) => MuxedStreamInfo( + VideoId.fromJson(json['videoId'] as Map), json['tag'] as int, Uri.parse(json['url'] as String), StreamContainer.fromJson(json['container'] as Map), @@ -26,6 +27,7 @@ MuxedStreamInfo _$MuxedStreamInfoFromJson(Map json) => Map _$MuxedStreamInfoToJson(MuxedStreamInfo instance) => { + 'videoId': instance.videoId, 'tag': instance.tag, 'url': instance.url.toString(), 'container': instance.container, diff --git a/lib/src/videos/streams/types/video_only_stream_info.dart b/lib/src/videos/streams/types/video_only_stream_info.dart index eae8889..423f748 100644 --- a/lib/src/videos/streams/types/video_only_stream_info.dart +++ b/lib/src/videos/streams/types/video_only_stream_info.dart @@ -10,6 +10,9 @@ part 'video_only_stream_info.g.dart'; /// YouTube media stream that only contains video. @JsonSerializable() class VideoOnlyStreamInfo with StreamInfo, VideoStreamInfo { + @override + final VideoId videoId; + @override final int tag; @@ -51,6 +54,7 @@ class VideoOnlyStreamInfo with StreamInfo, VideoStreamInfo { final MediaType codec; VideoOnlyStreamInfo( + this.videoId, this.tag, this.url, this.container, diff --git a/lib/src/videos/streams/types/video_only_stream_info.g.dart b/lib/src/videos/streams/types/video_only_stream_info.g.dart index 8431dc2..87d36ea 100644 --- a/lib/src/videos/streams/types/video_only_stream_info.g.dart +++ b/lib/src/videos/streams/types/video_only_stream_info.g.dart @@ -10,6 +10,7 @@ part of 'video_only_stream_info.dart'; VideoOnlyStreamInfo _$VideoOnlyStreamInfoFromJson(Map json) => VideoOnlyStreamInfo( + VideoId.fromJson(json['videoId'] as Map), json['tag'] as int, Uri.parse(json['url'] as String), StreamContainer.fromJson(json['container'] as Map), @@ -29,6 +30,7 @@ VideoOnlyStreamInfo _$VideoOnlyStreamInfoFromJson(Map json) => Map _$VideoOnlyStreamInfoToJson( VideoOnlyStreamInfo instance) => { + 'videoId': instance.videoId, 'tag': instance.tag, 'url': instance.url.toString(), 'container': instance.container, diff --git a/lib/src/videos/video_controller.dart b/lib/src/videos/video_controller.dart index 84c821e..1933fca 100644 --- a/lib/src/videos/video_controller.dart +++ b/lib/src/videos/video_controller.dart @@ -6,7 +6,8 @@ import '../reverse_engineering/player/player_response.dart'; @internal class VideoController { - static const _androidSuiteClient = { + /// Used to fetch streams without signature deciphering, but has limited streams. + static const androidTestSuiteClient = { 'context': { 'client': { 'clientName': 'ANDROID_TESTSUITE', @@ -19,7 +20,24 @@ class VideoController { }, }; - static const _tvClient = { + /// Used to to fetch all the video streams (but has signature deciphering). + static const androidClient = { + 'context': { + 'client': { + 'clientName': 'ANDROID', + 'clientVersion': '19.09.37', + 'androidSdkVersion': 30, + 'userAgent': + 'com.google.android.youtube/19.09.37 (Linux; U; Android 11) gzip', + 'hl': 'en', + 'timeZone': 'UTC', + 'utcOffsetMinutes': 0, + }, + }, + }; + + /// Used to fetch streams for age-restricted videos without authentication. + static const tvClient = { 'context': { 'client': { 'clientName': 'TVHTML5_SIMPLY_EMBEDDED_PLAYER', @@ -43,24 +61,29 @@ class VideoController { return WatchPage.get(httpClient, videoId.value); } - Future getPlayerResponse(VideoId videoId) async { - /// From https://github.com/Tyrrrz/YoutubeExplode: - /// The most optimal client to impersonate is the Android client, because - /// it doesn't require signature deciphering (for both normal and n-parameter signatures). - /// However, the regular Android client has a limitation, preventing it from downloading - /// multiple streams from the same manifest (or the same stream multiple times). - /// As a workaround, we're using ANDROID_TESTSUITE which appears to offer the same - /// functionality, but doesn't impose the aforementioned limitation. - /// https://github.com/Tyrrrz/YoutubeExplode/issues/705 + Future getPlayerResponse(VideoId videoId, + Map>> client) async { + assert(client['context'] != null, 'client must contain a context'); + assert(client['context']!['client'] != null, + 'client must contain a context.client'); + + final userAgent = client['context']!['client']!['userAgent'] as String?; + // From https://github.com/Tyrrrz/YoutubeExplode: + // The most optimal client to impersonate is the Android client, because + // it doesn't require signature deciphering (for both normal and n-parameter signatures). + // However, the regular Android client has a limitation, preventing it from downloading + // multiple streams from the same manifest (or the same stream multiple times). + // As a workaround, we're using ANDROID_TESTSUITE which appears to offer the same + // functionality, but doesn't impose the aforementioned limitation. + // https://github.com/Tyrrrz/YoutubeExplode/issues/705 final content = await httpClient.postString( 'https://www.youtube.com/youtubei/v1/player?key=AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w&prettyPrint=false', body: { - ..._androidSuiteClient, + ...client, 'videoId': videoId.value, }, headers: { - 'User-Agent': - 'com.google.android.youtube/17.36.4 (Linux; U; Android 12; GB) gzip', + if (userAgent != null) 'User-Agent': userAgent, }, ); return PlayerResponse.parse(content); @@ -76,7 +99,7 @@ class VideoController { final content = await httpClient.postString( 'https://www.youtube.com/youtubei/v1/player?key=AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w&prettyPrint=false', body: { - ..._tvClient, + ...tvClient, 'videoId': videoId.value, 'playbackContext': { 'contentPlaybackContext': { diff --git a/lib/src/videos/video_id.dart b/lib/src/videos/video_id.dart index 9f7d6ca..73513ca 100644 --- a/lib/src/videos/video_id.dart +++ b/lib/src/videos/video_id.dart @@ -3,6 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import '../extensions/helpers_extension.dart'; part 'video_id.freezed.dart'; +part 'video_id.g.dart'; /// Encapsulates a valid YouTube video ID. @freezed @@ -20,7 +21,7 @@ class VideoId with _$VideoId { if (id == null) { throw ArgumentError.value( idOrUrl, - 'urlOrUrl', + 'idOrUrl', 'Invalid YouTube video ID or URL', ); } @@ -43,6 +44,9 @@ class VideoId with _$VideoId { return VideoId(obj.toString()); } + factory VideoId.fromJson(Map json) => + _$VideoIdFromJson(json); + @override String toString() => value; diff --git a/lib/src/videos/video_id.freezed.dart b/lib/src/videos/video_id.freezed.dart index 7f13ced..46827ee 100644 --- a/lib/src/videos/video_id.freezed.dart +++ b/lib/src/videos/video_id.freezed.dart @@ -14,11 +14,16 @@ T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); +VideoId _$VideoIdFromJson(Map json) { + return _VideoId.fromJson(json); +} + /// @nodoc mixin _$VideoId { /// ID as string. String get value => throw _privateConstructorUsedError; + Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) $VideoIdCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -88,10 +93,13 @@ class __$$VideoIdImplCopyWithImpl<$Res> } /// @nodoc - +@JsonSerializable() class _$VideoIdImpl extends _VideoId { const _$VideoIdImpl(this.value) : super._(); + factory _$VideoIdImpl.fromJson(Map json) => + _$$VideoIdImplFromJson(json); + /// ID as string. @override final String value; @@ -104,6 +112,7 @@ class _$VideoIdImpl extends _VideoId { (identical(other.value, value) || other.value == value)); } + @JsonKey(ignore: true) @override int get hashCode => Object.hash(runtimeType, value); @@ -112,12 +121,21 @@ class _$VideoIdImpl extends _VideoId { @pragma('vm:prefer-inline') _$$VideoIdImplCopyWith<_$VideoIdImpl> get copyWith => __$$VideoIdImplCopyWithImpl<_$VideoIdImpl>(this, _$identity); + + @override + Map toJson() { + return _$$VideoIdImplToJson( + this, + ); + } } abstract class _VideoId extends VideoId { const factory _VideoId(final String value) = _$VideoIdImpl; const _VideoId._() : super._(); + factory _VideoId.fromJson(Map json) = _$VideoIdImpl.fromJson; + @override /// ID as string. diff --git a/lib/src/videos/video_id.g.dart b/lib/src/videos/video_id.g.dart new file mode 100644 index 0000000..9590c06 --- /dev/null +++ b/lib/src/videos/video_id.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: non_constant_identifier_names, require_trailing_commas + +part of 'video_id.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$VideoIdImpl _$$VideoIdImplFromJson(Map json) => + _$VideoIdImpl( + json['value'] as String, + ); + +Map _$$VideoIdImplToJson(_$VideoIdImpl instance) => + { + 'value': instance.value, + }; diff --git a/test/streams_test.dart b/test/streams_test.dart index 50f4f92..83223d2 100644 --- a/test/streams_test.dart +++ b/test/streams_test.dart @@ -1,3 +1,4 @@ +import 'package:test/expect.dart'; import 'package:test/test.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; @@ -28,6 +29,12 @@ void main() { } }); + test('Get full manifest of a video', () async { + final manifest = await yt!.videos.streamsClient + .getManifest(VideoIdData.normal.id, fullManifest: true); + expect(manifest.streams.length, greaterThan(50)); + }); + test('Stream of paid videos throw VideoRequiresPurchaseException', () { expect( yt!.videos.streamsClient.getManifest(VideoIdData.requiresPurchase.id),