commit
61bf4b638b
|
@ -26,7 +26,8 @@ Future<T> retry<T>(FutureOr<T> function()) async {
|
||||||
|
|
||||||
/// Get "retry" cost of each YoutubeExplode exception.
|
/// Get "retry" cost of each YoutubeExplode exception.
|
||||||
int getExceptionCost(Exception e) {
|
int getExceptionCost(Exception e) {
|
||||||
if (e is TransientFailureException) {
|
if (e is TransientFailureException || e is FormatException) {
|
||||||
|
print('Ripperoni!');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
if (e is RequestLimitExceededException) {
|
if (e is RequestLimitExceededException) {
|
||||||
|
|
|
@ -13,13 +13,36 @@ import 'player_response.dart';
|
||||||
import 'stream_info_provider.dart';
|
import 'stream_info_provider.dart';
|
||||||
|
|
||||||
class WatchPage {
|
class WatchPage {
|
||||||
final RegExp _videoLikeExp = RegExp(r'"label"\s*:\s*"([\d,\.]+) likes"');
|
static final RegExp _videoLikeExp =
|
||||||
final RegExp _videoDislikeExp =
|
RegExp(r'"label"\s*:\s*"([\d,\.]+) likes"');
|
||||||
|
static final RegExp _videoDislikeExp =
|
||||||
RegExp(r'"label"\s*:\s*"([\d,\.]+) dislikes"');
|
RegExp(r'"label"\s*:\s*"([\d,\.]+) dislikes"');
|
||||||
|
static final RegExp _visitorInfoLiveExp =
|
||||||
|
RegExp('VISITOR_INFO1_LIVE=([^;]+)');
|
||||||
|
static final RegExp _yscExp = RegExp('YSC=([^;]+)');
|
||||||
|
static final _xsfrTokenExp = RegExp(r'"XSRF_TOKEN"\s*:\s*"(.+?)"');
|
||||||
|
|
||||||
final Document _root;
|
final Document _root;
|
||||||
|
final String visitorInfoLive;
|
||||||
|
final String ysc;
|
||||||
|
|
||||||
WatchPage(this._root);
|
WatchPage(this._root, this.visitorInfoLive, this.ysc);
|
||||||
|
|
||||||
|
_InitialData get initialData =>
|
||||||
|
_InitialData(json.decode(_matchJson(_extractJson(
|
||||||
|
_root
|
||||||
|
.querySelectorAll('script')
|
||||||
|
.map((e) => e.text)
|
||||||
|
.toList()
|
||||||
|
.firstWhere((e) => e.contains('window["ytInitialData"] =')),
|
||||||
|
'window["ytInitialData"] ='))));
|
||||||
|
|
||||||
|
String get xsfrToken => _xsfrTokenExp
|
||||||
|
.firstMatch(_root
|
||||||
|
.querySelectorAll('script')
|
||||||
|
.firstWhere((e) => _xsfrTokenExp.hasMatch(e.text))
|
||||||
|
.text)
|
||||||
|
.group(1);
|
||||||
|
|
||||||
bool get isOk => _root.body.querySelector('#player') != null;
|
bool get isOk => _root.body.querySelector('#player') != null;
|
||||||
|
|
||||||
|
@ -78,14 +101,18 @@ class WatchPage {
|
||||||
return str.substring(0, lastI + 1);
|
return str.substring(0, lastI + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
WatchPage.parse(String raw) : _root = parser.parse(raw);
|
WatchPage.parse(String raw, this.visitorInfoLive, this.ysc)
|
||||||
|
: _root = parser.parse(raw);
|
||||||
|
|
||||||
static Future<WatchPage> get(YoutubeHttpClient httpClient, String videoId) {
|
static Future<WatchPage> get(YoutubeHttpClient httpClient, String videoId) {
|
||||||
final url = 'https://youtube.com/watch?v=$videoId&bpctr=9999999999&hl=en';
|
final url = 'https://youtube.com/watch?v=$videoId&bpctr=9999999999&hl=en';
|
||||||
return retry(() async {
|
return retry(() async {
|
||||||
var raw = await httpClient.getString(url);
|
var req = await httpClient.get(url, validate: true);
|
||||||
|
|
||||||
var result = WatchPage.parse(raw);
|
var cookies = req.headers['set-cookie'];
|
||||||
|
var visitorInfoLive = _visitorInfoLiveExp.firstMatch(cookies).group(1);
|
||||||
|
var ysc = _yscExp.firstMatch(cookies).group(1);
|
||||||
|
var result = WatchPage.parse(req.body, visitorInfoLive, ysc);
|
||||||
|
|
||||||
if (!result.isOk) {
|
if (!result.isOk) {
|
||||||
throw TransientFailureException("Video watch page is broken.");
|
throw TransientFailureException("Video watch page is broken.");
|
||||||
|
@ -188,3 +215,38 @@ class _PlayerConfig {
|
||||||
|
|
||||||
List<_StreamInfo> get streams => [...muxedStreams, ...adaptiveStreams];
|
List<_StreamInfo> get streams => [...muxedStreams, ...adaptiveStreams];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _InitialData {
|
||||||
|
// Json parsed map
|
||||||
|
final Map<String, dynamic> _root;
|
||||||
|
|
||||||
|
_InitialData(this._root);
|
||||||
|
|
||||||
|
/* Cache results */
|
||||||
|
|
||||||
|
String _continuation;
|
||||||
|
String _clickTrackingParams;
|
||||||
|
|
||||||
|
Map<String, dynamic> getContinuationContext(Map<String, dynamic> root) {
|
||||||
|
if (_root['contents'] != null) {
|
||||||
|
return (_root['contents']['twoColumnWatchNextResults']['results']
|
||||||
|
['results']['contents'] as List<dynamic>)
|
||||||
|
?.firstWhere((e) => e.containsKey('itemSectionRenderer'))[
|
||||||
|
'itemSectionRenderer']['continuations']
|
||||||
|
?.first['nextContinuationData']
|
||||||
|
?.cast<String, dynamic>();
|
||||||
|
}
|
||||||
|
if (_root['response'] != null) {
|
||||||
|
return _root['response']['itemSectionContinuation']['continuations']
|
||||||
|
?.first['nextContinuationData']
|
||||||
|
?.cast<String, dynamic>();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String get continuation => _continuation ??=
|
||||||
|
getContinuationContext(_root)?.getValue('continuation') ?? '';
|
||||||
|
|
||||||
|
String get clickTrackingParams => _clickTrackingParams ??=
|
||||||
|
getContinuationContext(_root)?.getValue('clickTrackingParams') ?? '';
|
||||||
|
}
|
||||||
|
|
|
@ -12,6 +12,12 @@ class YoutubeHttpClient extends http.BaseClient {
|
||||||
'accept-language': 'en-US,en;q=1.0',
|
'accept-language': 'en-US,en;q=1.0',
|
||||||
'x-youtube-client-name': '1',
|
'x-youtube-client-name': '1',
|
||||||
'x-youtube-client-version': '2.20200609.04.02',
|
'x-youtube-client-version': '2.20200609.04.02',
|
||||||
|
'x-spf-previous': 'https://www.youtube.com/',
|
||||||
|
'x-spf-referer': 'https://www.youtube.com/',
|
||||||
|
'x-youtube-device':
|
||||||
|
'cbr=Chrome&cbrver=81.0.4044.138&ceng=WebKit&cengver=537.36'
|
||||||
|
'&cos=Windows&cosver=10.0',
|
||||||
|
'x-youtube-page-label': 'youtube.ytfe.desktop_20200617_1_RC1'
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Throws if something is wrong with the response.
|
/// Throws if something is wrong with the response.
|
||||||
|
@ -37,8 +43,8 @@ class YoutubeHttpClient extends http.BaseClient {
|
||||||
|
|
||||||
Future<String> getString(dynamic url,
|
Future<String> getString(dynamic url,
|
||||||
{Map<String, String> headers, bool validate = true}) async {
|
{Map<String, String> headers, bool validate = true}) async {
|
||||||
var response = await get(url, headers: {...?headers, ..._defaultHeaders});
|
var response = await get(url, headers: headers);
|
||||||
|
|
||||||
if (validate) {
|
if (validate) {
|
||||||
_validateResponse(response, response.statusCode);
|
_validateResponse(response, response.statusCode);
|
||||||
}
|
}
|
||||||
|
@ -46,6 +52,16 @@ class YoutubeHttpClient extends http.BaseClient {
|
||||||
return response.body;
|
return response.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> get(dynamic url,
|
||||||
|
{Map<String, String> headers, bool validate = false}) async {
|
||||||
|
var response = await super.get(url, headers: headers);
|
||||||
|
if (validate) {
|
||||||
|
_validateResponse(response, response.statusCode);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> postString(dynamic url,
|
Future<String> postString(dynamic url,
|
||||||
{Map<String, String> body,
|
{Map<String, String> body,
|
||||||
Map<String, String> headers,
|
Map<String, String> headers,
|
||||||
|
@ -63,8 +79,8 @@ class YoutubeHttpClient extends http.BaseClient {
|
||||||
Stream<List<int>> getStream(StreamInfo streamInfo,
|
Stream<List<int>> getStream(StreamInfo streamInfo,
|
||||||
{Map<String, String> headers, bool validate = true}) async* {
|
{Map<String, String> headers, bool validate = true}) async* {
|
||||||
var url = streamInfo.url;
|
var url = streamInfo.url;
|
||||||
// if (streamInfo.isRateLimited()) {
|
// if (!streamInfo.isRateLimited()) {
|
||||||
// var request = Request('get', url);
|
// var request = http.Request('get', url);
|
||||||
// request.headers.addAll(_defaultHeaders);
|
// request.headers.addAll(_defaultHeaders);
|
||||||
// var response = await request.send();
|
// var response = await request.send();
|
||||||
// if (validate) {
|
// if (validate) {
|
||||||
|
@ -100,7 +116,11 @@ class YoutubeHttpClient extends http.BaseClient {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||||
request.headers.addAll(_defaultHeaders);
|
_defaultHeaders.forEach((key, value) {
|
||||||
|
if (request.headers[key] == null) {
|
||||||
|
request.headers[key] = _defaultHeaders[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
return _httpClient.send(request);
|
return _httpClient.send(request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:meta/meta.dart';
|
||||||
|
|
||||||
|
import '../../channels/channel_id.dart';
|
||||||
|
|
||||||
|
/// YouTube comment metadata.
|
||||||
|
class Comment with EquatableMixin {
|
||||||
|
/// Comment id.
|
||||||
|
final String commentId;
|
||||||
|
|
||||||
|
/// Comment author name.
|
||||||
|
final String author;
|
||||||
|
|
||||||
|
/// Comment author channel id.
|
||||||
|
final ChannelId channelId;
|
||||||
|
|
||||||
|
/// Comment text.
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
/// Comment likes count.
|
||||||
|
final int likeCount;
|
||||||
|
|
||||||
|
/// Published time as string. (For example: "2 years ago")
|
||||||
|
final String publishedTime;
|
||||||
|
|
||||||
|
/// Comment reply count.
|
||||||
|
final int replyCount;
|
||||||
|
|
||||||
|
/// Used internally.
|
||||||
|
@protected
|
||||||
|
final String continuation;
|
||||||
|
|
||||||
|
/// Used internally.
|
||||||
|
@protected
|
||||||
|
final String clicktrackingParams;
|
||||||
|
|
||||||
|
/// Initializes an instance of [Comment]
|
||||||
|
Comment(
|
||||||
|
this.commentId,
|
||||||
|
this.author,
|
||||||
|
this.channelId,
|
||||||
|
this.text,
|
||||||
|
this.likeCount,
|
||||||
|
this.publishedTime,
|
||||||
|
this.replyCount,
|
||||||
|
this.continuation,
|
||||||
|
this.clicktrackingParams);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'Comment($author): $text';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [commentId];
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export 'comment.dart';
|
|
@ -0,0 +1,138 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import '../../channels/channel_id.dart';
|
||||||
|
import '../../extensions/helpers_extension.dart';
|
||||||
|
import '../../retry.dart';
|
||||||
|
import '../../reverse_engineering/youtube_http_client.dart';
|
||||||
|
import '../videos.dart';
|
||||||
|
import 'comment.dart';
|
||||||
|
|
||||||
|
/// Queries related to comments of YouTube videos.
|
||||||
|
class CommentsClient {
|
||||||
|
final YoutubeHttpClient _httpClient;
|
||||||
|
|
||||||
|
/// Initializes an instance of [CommentsClient]
|
||||||
|
CommentsClient(this._httpClient);
|
||||||
|
|
||||||
|
/// Returns the json parsed comments map.
|
||||||
|
Future<Map<String, dynamic>> _getCommentJson(
|
||||||
|
String service,
|
||||||
|
String continuation,
|
||||||
|
String clickTrackingParams,
|
||||||
|
String xsfrToken,
|
||||||
|
String visitorInfoLive,
|
||||||
|
String ysc) async {
|
||||||
|
var url = 'https://www.youtube.com/comment_service_ajax?'
|
||||||
|
'$service=1&'
|
||||||
|
'pbj=1&'
|
||||||
|
'ctoken=$continuation&'
|
||||||
|
'continuation=$continuation&'
|
||||||
|
'itct=$clickTrackingParams';
|
||||||
|
return retry(() async {
|
||||||
|
var raw = await _httpClient.postString(url, headers: {
|
||||||
|
'cookie': 'YSC=$ysc; GPS=1; VISITOR_INFO1_LIVE=$visitorInfoLive;'
|
||||||
|
' CONSENT=WP.288163; PREF=f4=4000000',
|
||||||
|
}, body: {
|
||||||
|
'session_token': xsfrToken
|
||||||
|
});
|
||||||
|
return json.decode(raw);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a stream emitting all the [video]'s comment.
|
||||||
|
/// A request is page for every comment page,
|
||||||
|
/// a page contains at most 20 comments, use .take if you want to limit
|
||||||
|
/// the results.
|
||||||
|
///
|
||||||
|
/// Throws an exception if the given video has not a watch page available.
|
||||||
|
/// this happens for the videos from playlist or search queries.
|
||||||
|
Stream<Comment> getComments(Video video) async* {
|
||||||
|
if (video.watchPage == null) {
|
||||||
|
//TODO: Implement custom exception.
|
||||||
|
throw Exception('Watch page not available for this video');
|
||||||
|
}
|
||||||
|
yield* _getComments(
|
||||||
|
video.watchPage.initialData.continuation,
|
||||||
|
video.watchPage.initialData.clickTrackingParams,
|
||||||
|
video.watchPage.xsfrToken,
|
||||||
|
video.watchPage.visitorInfoLive,
|
||||||
|
video.watchPage.ysc);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<Comment> _getComments(String continuation, String clickTrackingParams,
|
||||||
|
String xsfrToken, String visitorInfoLive, String ysc) async* {
|
||||||
|
var data = await _getCommentJson('action_get_comments', continuation,
|
||||||
|
clickTrackingParams, xsfrToken, visitorInfoLive, ysc);
|
||||||
|
var contentRoot = data['response']['continuationContents']
|
||||||
|
['itemSectionContinuation']['contents']
|
||||||
|
?.map((e) => e['commentThreadRenderer'])
|
||||||
|
?.toList()
|
||||||
|
?.cast<Map<String, dynamic>>() as List<Map<String, dynamic>>;
|
||||||
|
if (contentRoot == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (var content in contentRoot) {
|
||||||
|
var commentRaw = content['comment']['commentRenderer'];
|
||||||
|
String continuation;
|
||||||
|
String clickTrackingParams;
|
||||||
|
if (content['replies'] != null) {
|
||||||
|
continuation = content['replies']['commentRepliesRenderer']
|
||||||
|
['continuations']
|
||||||
|
.first['nextContinuationData']['continuation'];
|
||||||
|
clickTrackingParams = content['replies']['commentRepliesRenderer']
|
||||||
|
['continuations']
|
||||||
|
.first['nextContinuationData']['clickTrackingParams'];
|
||||||
|
}
|
||||||
|
var comment = Comment(
|
||||||
|
commentRaw['commentId'],
|
||||||
|
commentRaw['authorText']['simpleText'],
|
||||||
|
ChannelId(commentRaw['authorEndpoint']['browseEndpoint']['browseId']),
|
||||||
|
_parseRuns(commentRaw['contentText']),
|
||||||
|
commentRaw['likeCount'] ?? 0,
|
||||||
|
_parseRuns(commentRaw['publishedTimeText']),
|
||||||
|
commentRaw['replyCount'],
|
||||||
|
continuation,
|
||||||
|
clickTrackingParams);
|
||||||
|
yield comment;
|
||||||
|
}
|
||||||
|
var continuationRoot = (data
|
||||||
|
?.get('response')
|
||||||
|
?.get('continuationContents')
|
||||||
|
?.get('itemSectionContinuation')
|
||||||
|
?.getValue('continuations')
|
||||||
|
?.first as Map<String, dynamic>)
|
||||||
|
?.get('nextContinuationData');
|
||||||
|
if (continuationRoot != null) {
|
||||||
|
yield* _getComments(
|
||||||
|
continuationRoot['continuation'],
|
||||||
|
continuationRoot['clickTrackingParams'],
|
||||||
|
xsfrToken,
|
||||||
|
visitorInfoLive,
|
||||||
|
ysc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _parseRuns(Map<dynamic, dynamic> runs) =>
|
||||||
|
runs?.getValue('runs')?.map((e) => e['text'])?.join() ?? '';
|
||||||
|
|
||||||
|
//TODO: Implement replies
|
||||||
|
/* Stream<Comment> getReplies(Video video, Comment comment) async* {
|
||||||
|
if (video.watchPage == null || comment.continuation == null
|
||||||
|
|| comment.clicktrackingParams == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield* _getReplies(
|
||||||
|
video.watchPage.initialData.continuation,
|
||||||
|
video.watchPage.initialData.clickTrackingParams,
|
||||||
|
video.watchPage.xsfrToken,
|
||||||
|
video.watchPage.visitorInfoLive,
|
||||||
|
video.watchPage.ysc);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<Comment> _getReplies(String continuation, String clickTrackingParams,
|
||||||
|
String xsfrToken, String visitorInfoLive, String ysc) async* {
|
||||||
|
var data = await _getCommentJson('action_get_comment_replies', continuation,
|
||||||
|
clickTrackingParams, xsfrToken, visitorInfoLive, ysc);
|
||||||
|
print(data);
|
||||||
|
}*/
|
||||||
|
}
|
|
@ -1,8 +1,10 @@
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:meta/meta.dart';
|
||||||
|
|
||||||
import '../common/common.dart';
|
import '../common/common.dart';
|
||||||
|
import '../reverse_engineering/responses/responses.dart';
|
||||||
import 'video_id.dart';
|
import 'video_id.dart';
|
||||||
|
|
||||||
/// YouTube video metadata.
|
/// YouTube video metadata.
|
||||||
|
@ -37,6 +39,10 @@ class Video with EquatableMixin {
|
||||||
/// Engagement statistics for this video.
|
/// Engagement statistics for this video.
|
||||||
final Engagement engagement;
|
final Engagement engagement;
|
||||||
|
|
||||||
|
/// Used internally.
|
||||||
|
@protected
|
||||||
|
final WatchPage watchPage;
|
||||||
|
|
||||||
/// Initializes an instance of [Video]
|
/// Initializes an instance of [Video]
|
||||||
Video(
|
Video(
|
||||||
this.id,
|
this.id,
|
||||||
|
@ -47,7 +53,8 @@ class Video with EquatableMixin {
|
||||||
this.duration,
|
this.duration,
|
||||||
this.thumbnails,
|
this.thumbnails,
|
||||||
Iterable<String> keywords,
|
Iterable<String> keywords,
|
||||||
this.engagement)
|
this.engagement,
|
||||||
|
[this.watchPage])
|
||||||
: keywords = UnmodifiableListView(keywords);
|
: keywords = UnmodifiableListView(keywords);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -2,6 +2,7 @@ import '../common/common.dart';
|
||||||
import '../reverse_engineering/responses/responses.dart';
|
import '../reverse_engineering/responses/responses.dart';
|
||||||
import '../reverse_engineering/youtube_http_client.dart';
|
import '../reverse_engineering/youtube_http_client.dart';
|
||||||
import 'closed_captions/closed_caption_client.dart';
|
import 'closed_captions/closed_caption_client.dart';
|
||||||
|
import 'comments/comments_client.dart';
|
||||||
import 'videos.dart';
|
import 'videos.dart';
|
||||||
|
|
||||||
/// Queries related to YouTube videos.
|
/// Queries related to YouTube videos.
|
||||||
|
@ -14,10 +15,14 @@ class VideoClient {
|
||||||
/// Queries related to closed captions of YouTube videos.
|
/// Queries related to closed captions of YouTube videos.
|
||||||
final ClosedCaptionClient closedCaptions;
|
final ClosedCaptionClient closedCaptions;
|
||||||
|
|
||||||
|
/// Queries related to a YouTube video.
|
||||||
|
final CommentsClient commentsClient;
|
||||||
|
|
||||||
/// Initializes an instance of [VideoClient].
|
/// Initializes an instance of [VideoClient].
|
||||||
VideoClient(this._httpClient)
|
VideoClient(this._httpClient)
|
||||||
: streamsClient = StreamsClient(_httpClient),
|
: streamsClient = StreamsClient(_httpClient),
|
||||||
closedCaptions = ClosedCaptionClient(_httpClient);
|
closedCaptions = ClosedCaptionClient(_httpClient),
|
||||||
|
commentsClient = CommentsClient(_httpClient);
|
||||||
|
|
||||||
/// Gets the metadata associated with the specified video.
|
/// Gets the metadata associated with the specified video.
|
||||||
Future<Video> get(dynamic videoId) async {
|
Future<Video> get(dynamic videoId) async {
|
||||||
|
@ -37,6 +42,7 @@ class VideoClient {
|
||||||
ThumbnailSet(videoId.value),
|
ThumbnailSet(videoId.value),
|
||||||
playerResponse.videoKeywords,
|
playerResponse.videoKeywords,
|
||||||
Engagement(playerResponse.videoViewCount ?? 0, watchPage.videoLikeCount,
|
Engagement(playerResponse.videoViewCount ?? 0, watchPage.videoLikeCount,
|
||||||
watchPage.videoDislikeCount));
|
watchPage.videoDislikeCount),
|
||||||
|
watchPage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
library youtube_explode.videos;
|
||||||
|
|
||||||
|
export 'comments/comments.dart';
|
||||||
export 'streams/streams.dart';
|
export 'streams/streams.dart';
|
||||||
export 'video.dart';
|
export 'video.dart';
|
||||||
export 'video_client.dart';
|
export 'video_client.dart';
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Comments', () {
|
||||||
|
YoutubeExplode yt;
|
||||||
|
setUp(() {
|
||||||
|
yt = YoutubeExplode();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
yt.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GetCommentOfVideo', () async {
|
||||||
|
var videoUrl = 'https://www.youtube.com/watch?v=AI7ULzgf8RU';
|
||||||
|
var video = await yt.videos.get(VideoId(videoUrl));
|
||||||
|
var comments = await yt.videos.commentsClient.getComments(video).toList();
|
||||||
|
expect(comments.length, greaterThanOrEqualTo(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue