From af147ce1ec0ae10d0f5178ea36d3135cbdd609fc Mon Sep 17 00:00:00 2001 From: Hexah Date: Wed, 17 Jun 2020 22:14:27 +0200 Subject: [PATCH 1/3] First implementation --- .../responses/watch_page.dart | 123 ++++++++++++++++++ lib/src/videos/video.dart | 5 +- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/lib/src/reverse_engineering/responses/watch_page.dart b/lib/src/reverse_engineering/responses/watch_page.dart index 9717937..423e3df 100644 --- a/lib/src/reverse_engineering/responses/watch_page.dart +++ b/lib/src/reverse_engineering/responses/watch_page.dart @@ -188,3 +188,126 @@ class _PlayerConfig { List<_StreamInfo> get streams => [...muxedStreams, ...adaptiveStreams]; } + +class _InitialData { + // Json parsed map + final Map _root; + + _InitialData(this._root); + + /* Cache results */ + + List _searchContent; + List _relatedVideos; + List _relatedQueries; + String _continuation; + String _clickTrackingParams; + + List> getContentContext(Map root) { + if (root['contents'] != null) { + return _root['contents']['twoColumnSearchResultsRenderer'] + ['primaryContents']['sectionListRenderer']['contents'] + .first['itemSectionRenderer']['contents'] + .cast>(); + } + if (root['response'] != null) { + return _root['response']['continuationContents'] + ['itemSectionContinuation']['contents'] + .cast>(); + } + throw Exception('Couldn\'t find the content data'); + } + + Map getContinuationContext(Map root) { + if (_root['contents'] != null) { + return (_root['contents']['twoColumnSearchResultsRenderer'] + ['primaryContents']['sectionListRenderer']['contents'] + ?.first['itemSectionRenderer']['continuations'] + ?.first as Map) + ?.getValue('nextContinuationData') + ?.cast(); + } + if (_root['response'] != null) { + return _root['response']['continuationContents'] + ['itemSectionContinuation']['continuations'] + ?.first['nextContinuationData'] + ?.cast(); + } + return null; + } + + // Contains only [SearchVideo] or [SearchPlaylist] + List get searchContent => _searchContent ??= getContentContext(_root) + .map(_parseContent) + .where((e) => e != null) + .toList(); + + List get relatedQueries => + (_relatedQueries ??= getContentContext(_root) + ?.where((e) => e.containsKey('horizontalCardListRenderer')) + ?.map((e) => e['horizontalCardListRenderer']['cards']) + ?.firstOrNull + ?.map((e) => e['searchRefinementCardRenderer']) + ?.map((e) => RelatedQuery( + e['searchEndpoint']['searchEndpoint']['query'], + VideoId(Uri.parse(e['thumbnail']['thumbnails'].first['url']) + .pathSegments[1]))) + ?.toList() + ?.cast()) ?? + const []; + + List get relatedVideos => + (_relatedVideos ??= getContentContext(_root) + ?.where((e) => e.containsKey('shelfRenderer')) + ?.map((e) => + e['shelfRenderer']['content']['verticalListRenderer']['items']) + ?.firstOrNull + ?.map(_parseContent) + ?.toList()) ?? + const []; + + String get continuation => _continuation ??= + getContinuationContext(_root)?.getValue('continuation') ?? ''; + + String get clickTrackingParams => _clickTrackingParams ??= + getContinuationContext(_root)?.getValue('clickTrackingParams') ?? ''; + + int get estimatedResults => int.parse(_root['estimatedResults'] ?? 0); + + dynamic _parseContent(dynamic content) { + if (content == null) { + return null; + } + if (content.containsKey('videoRenderer')) { + Map renderer = content['videoRenderer']; + //TODO: Add if it's a live + return SearchVideo( + VideoId(renderer['videoId']), + _parseRuns(renderer['title']), + _parseRuns(renderer['ownerText']), + _parseRuns(renderer['descriptionSnippet']), + renderer.get('lengthText')?.getValue('simpleText') ?? '', + int.parse(renderer['viewCountText']['simpleText'] + .toString() + .stripNonDigits() + .nullIfWhitespace ?? + '0')); + } + if (content.containsKey('radioRenderer')) { + var renderer = content['radioRenderer']; + + return SearchPlaylist( + PlaylistId(renderer['playlistId']), + renderer['title']['simpleText'], + int.parse(_parseRuns(renderer['videoCountText']) + .stripNonDigits() + .nullIfWhitespace ?? + 0)); + } + // Here ignore 'horizontalCardListRenderer' & 'shelfRenderer' + return null; + } + + String _parseRuns(Map runs) => + runs?.getValue('runs')?.map((e) => e['text'])?.join() ?? ''; +} diff --git a/lib/src/videos/video.dart b/lib/src/videos/video.dart index 67b63cc..6885d08 100644 --- a/lib/src/videos/video.dart +++ b/lib/src/videos/video.dart @@ -37,6 +37,9 @@ class Video with EquatableMixin { /// Engagement statistics for this video. final Engagement engagement; + /// Get the videos comments + final Function(int) getComments; + /// Initializes an instance of [Video] Video( this.id, @@ -47,7 +50,7 @@ class Video with EquatableMixin { this.duration, this.thumbnails, Iterable keywords, - this.engagement) + this.engagement, this.getComments) : keywords = UnmodifiableListView(keywords); @override From 6a1d1633bfcb8bd8a0115809c7e80a93c7345ecb Mon Sep 17 00:00:00 2001 From: Hexah Date: Sun, 21 Jun 2020 16:23:19 +0200 Subject: [PATCH 2/3] Fully implement comments api. Closes #39 --- lib/src/retry.dart | 3 +- .../responses/watch_page.dart | 139 +++++------------- .../youtube_http_client.dart | 84 ++++++----- lib/src/videos/comments/comment.dart | 54 +++++++ lib/src/videos/comments/comments.dart | 1 + lib/src/videos/comments/comments_client.dart | 138 +++++++++++++++++ lib/src/videos/video.dart | 10 +- lib/src/videos/video_client.dart | 10 +- lib/src/videos/videos.dart | 3 + test/comments_client_test.dart | 22 +++ test/streams_test.dart | 2 +- 11 files changed, 322 insertions(+), 144 deletions(-) create mode 100644 lib/src/videos/comments/comment.dart create mode 100644 lib/src/videos/comments/comments.dart create mode 100644 lib/src/videos/comments/comments_client.dart create mode 100644 test/comments_client_test.dart diff --git a/lib/src/retry.dart b/lib/src/retry.dart index 54f1ebc..ee49cb8 100644 --- a/lib/src/retry.dart +++ b/lib/src/retry.dart @@ -26,7 +26,8 @@ Future retry(FutureOr function()) async { /// Get "retry" cost of each YoutubeExplode exception. int getExceptionCost(Exception e) { - if (e is TransientFailureException) { + if (e is TransientFailureException || e is FormatException) { + print('Ripperoni!'); return 1; } if (e is RequestLimitExceededException) { diff --git a/lib/src/reverse_engineering/responses/watch_page.dart b/lib/src/reverse_engineering/responses/watch_page.dart index 423e3df..9fed9ee 100644 --- a/lib/src/reverse_engineering/responses/watch_page.dart +++ b/lib/src/reverse_engineering/responses/watch_page.dart @@ -13,13 +13,36 @@ import 'player_response.dart'; import 'stream_info_provider.dart'; class WatchPage { - final RegExp _videoLikeExp = RegExp(r'"label"\s*:\s*"([\d,\.]+) likes"'); - final RegExp _videoDislikeExp = + static final RegExp _videoLikeExp = + RegExp(r'"label"\s*:\s*"([\d,\.]+) likes"'); + static final RegExp _videoDislikeExp = 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 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; @@ -78,14 +101,18 @@ class WatchPage { 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 get(YoutubeHttpClient httpClient, String videoId) { final url = 'https://youtube.com/watch?v=$videoId&bpctr=9999999999&hl=en'; 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) { throw TransientFailureException("Video watch page is broken."); @@ -197,117 +224,29 @@ class _InitialData { /* Cache results */ - List _searchContent; - List _relatedVideos; - List _relatedQueries; String _continuation; String _clickTrackingParams; - List> getContentContext(Map root) { - if (root['contents'] != null) { - return _root['contents']['twoColumnSearchResultsRenderer'] - ['primaryContents']['sectionListRenderer']['contents'] - .first['itemSectionRenderer']['contents'] - .cast>(); - } - if (root['response'] != null) { - return _root['response']['continuationContents'] - ['itemSectionContinuation']['contents'] - .cast>(); - } - throw Exception('Couldn\'t find the content data'); - } - Map getContinuationContext(Map root) { if (_root['contents'] != null) { - return (_root['contents']['twoColumnSearchResultsRenderer'] - ['primaryContents']['sectionListRenderer']['contents'] - ?.first['itemSectionRenderer']['continuations'] - ?.first as Map) - ?.getValue('nextContinuationData') + return (_root['contents']['twoColumnWatchNextResults']['results'] + ['results']['contents'] as List) + ?.firstWhere((e) => e.containsKey('itemSectionRenderer'))[ + 'itemSectionRenderer']['continuations'] + ?.first['nextContinuationData'] ?.cast(); } if (_root['response'] != null) { - return _root['response']['continuationContents'] - ['itemSectionContinuation']['continuations'] + return _root['response']['itemSectionContinuation']['continuations'] ?.first['nextContinuationData'] ?.cast(); } return null; } - // Contains only [SearchVideo] or [SearchPlaylist] - List get searchContent => _searchContent ??= getContentContext(_root) - .map(_parseContent) - .where((e) => e != null) - .toList(); - - List get relatedQueries => - (_relatedQueries ??= getContentContext(_root) - ?.where((e) => e.containsKey('horizontalCardListRenderer')) - ?.map((e) => e['horizontalCardListRenderer']['cards']) - ?.firstOrNull - ?.map((e) => e['searchRefinementCardRenderer']) - ?.map((e) => RelatedQuery( - e['searchEndpoint']['searchEndpoint']['query'], - VideoId(Uri.parse(e['thumbnail']['thumbnails'].first['url']) - .pathSegments[1]))) - ?.toList() - ?.cast()) ?? - const []; - - List get relatedVideos => - (_relatedVideos ??= getContentContext(_root) - ?.where((e) => e.containsKey('shelfRenderer')) - ?.map((e) => - e['shelfRenderer']['content']['verticalListRenderer']['items']) - ?.firstOrNull - ?.map(_parseContent) - ?.toList()) ?? - const []; - String get continuation => _continuation ??= getContinuationContext(_root)?.getValue('continuation') ?? ''; String get clickTrackingParams => _clickTrackingParams ??= getContinuationContext(_root)?.getValue('clickTrackingParams') ?? ''; - - int get estimatedResults => int.parse(_root['estimatedResults'] ?? 0); - - dynamic _parseContent(dynamic content) { - if (content == null) { - return null; - } - if (content.containsKey('videoRenderer')) { - Map renderer = content['videoRenderer']; - //TODO: Add if it's a live - return SearchVideo( - VideoId(renderer['videoId']), - _parseRuns(renderer['title']), - _parseRuns(renderer['ownerText']), - _parseRuns(renderer['descriptionSnippet']), - renderer.get('lengthText')?.getValue('simpleText') ?? '', - int.parse(renderer['viewCountText']['simpleText'] - .toString() - .stripNonDigits() - .nullIfWhitespace ?? - '0')); - } - if (content.containsKey('radioRenderer')) { - var renderer = content['radioRenderer']; - - return SearchPlaylist( - PlaylistId(renderer['playlistId']), - renderer['title']['simpleText'], - int.parse(_parseRuns(renderer['videoCountText']) - .stripNonDigits() - .nullIfWhitespace ?? - 0)); - } - // Here ignore 'horizontalCardListRenderer' & 'shelfRenderer' - return null; - } - - String _parseRuns(Map runs) => - runs?.getValue('runs')?.map((e) => e['text'])?.join() ?? ''; } diff --git a/lib/src/reverse_engineering/youtube_http_client.dart b/lib/src/reverse_engineering/youtube_http_client.dart index b8f3dc7..fcbc829 100644 --- a/lib/src/reverse_engineering/youtube_http_client.dart +++ b/lib/src/reverse_engineering/youtube_http_client.dart @@ -1,10 +1,10 @@ -import 'package:http/http.dart'; +import 'package:http/http.dart' as http; import '../exceptions/exceptions.dart'; import '../videos/streams/streams.dart'; -class YoutubeHttpClient { - final Client _httpClient = Client(); +class YoutubeHttpClient extends http.BaseClient { + final http.Client _httpClient = http.Client(); final Map _defaultHeaders = const { 'user-agent': @@ -12,10 +12,16 @@ class YoutubeHttpClient { 'accept-language': 'en-US,en;q=1.0', 'x-youtube-client-name': '1', '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. - void _validateResponse(BaseResponse response, int statusCode) { + void _validateResponse(http.BaseResponse response, int statusCode) { var request = response.request; if (request.url.host.endsWith('.google.com') && request.url.path.startsWith('/sorry/')) { @@ -35,22 +41,9 @@ class YoutubeHttpClient { } } - Future get(dynamic url, {Map headers}) { - return _httpClient.get(url, headers: {...?headers, ..._defaultHeaders}); - } - - Future post(dynamic url, {Map headers}) { - return _httpClient.post(url, headers: {...?headers, ..._defaultHeaders}); - } - - Future head(dynamic url, {Map headers}) { - return _httpClient.head(url, headers: {...?headers, ..._defaultHeaders}); - } - Future getString(dynamic url, {Map headers, bool validate = true}) async { - var response = - await _httpClient.get(url, headers: {...?headers, ..._defaultHeaders}); + var response = await get(url, headers: headers); if (validate) { _validateResponse(response, response.statusCode); @@ -59,12 +52,21 @@ class YoutubeHttpClient { return response.body; } + @override + Future get(dynamic url, + {Map headers, bool validate = false}) async { + var response = await super.get(url, headers: headers); + if (validate) { + _validateResponse(response, response.statusCode); + } + return response; + } + Future postString(dynamic url, {Map body, Map headers, bool validate = true}) async { - var response = await _httpClient.post(url, - headers: {...?headers, ..._defaultHeaders}, body: body); + var response = await post(url, headers: headers, body: body); if (validate) { _validateResponse(response, response.statusCode); @@ -76,26 +78,25 @@ class YoutubeHttpClient { Stream> getStream(StreamInfo streamInfo, {Map headers, bool validate = true}) async* { var url = streamInfo.url; - if (!streamInfo.isRateLimited()) { - var request = Request('get', url); - request.headers.addAll(_defaultHeaders); - var response = await request.send(); +// if (!streamInfo.isRateLimited()) { +// var request = http.Request('get', url); +// request.headers.addAll(_defaultHeaders); +// var response = await request.send(); +// if (validate) { +// _validateResponse(response, response.statusCode); +// } +// yield* response.stream; +// } else { + for (var i = 0; i < streamInfo.size.totalBytes; i += 9898989) { + var request = http.Request('get', url); + request.headers['range'] = 'bytes=$i-${i + 9898989}'; + var response = await send(request); if (validate) { _validateResponse(response, response.statusCode); } yield* response.stream; - } else { - for (var i = 0; i < streamInfo.size.totalBytes; i += 9898989) { - var request = Request('get', url); - request.headers['range'] = 'bytes=$i-${i + 9898989}'; - request.headers.addAll(_defaultHeaders); - var response = await request.send(); - if (validate) { - _validateResponse(response, response.statusCode); - } - yield* response.stream; - } } +// } } Future getContentLength(dynamic url, @@ -109,7 +110,16 @@ class YoutubeHttpClient { return int.tryParse(response.headers['content-length'] ?? ''); } - /// Closes the [Client] assigned to this [YoutubeHttpClient]. - /// Should be called after this is not used anymore. + @override void close() => _httpClient.close(); + + @override + Future send(http.BaseRequest request) { + _defaultHeaders.forEach((key, value) { + if (request.headers[key] == null) { + request.headers[key] = _defaultHeaders[key]; + } + }); + return _httpClient.send(request); + } } diff --git a/lib/src/videos/comments/comment.dart b/lib/src/videos/comments/comment.dart new file mode 100644 index 0000000..10de820 --- /dev/null +++ b/lib/src/videos/comments/comment.dart @@ -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 get props => [commentId]; +} diff --git a/lib/src/videos/comments/comments.dart b/lib/src/videos/comments/comments.dart new file mode 100644 index 0000000..0726ef9 --- /dev/null +++ b/lib/src/videos/comments/comments.dart @@ -0,0 +1 @@ +export 'comment.dart'; diff --git a/lib/src/videos/comments/comments_client.dart b/lib/src/videos/comments/comments_client.dart new file mode 100644 index 0000000..61cab20 --- /dev/null +++ b/lib/src/videos/comments/comments_client.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 _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 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 _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>() as List>; + 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) + ?.get('nextContinuationData'); + if (continuationRoot != null) { + yield* _getComments( + continuationRoot['continuation'], + continuationRoot['clickTrackingParams'], + xsfrToken, + visitorInfoLive, + ysc); + } + } + + String _parseRuns(Map runs) => + runs?.getValue('runs')?.map((e) => e['text'])?.join() ?? ''; + +//TODO: Implement replies +/* Stream 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 _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); + }*/ +} diff --git a/lib/src/videos/video.dart b/lib/src/videos/video.dart index 6885d08..218ff12 100644 --- a/lib/src/videos/video.dart +++ b/lib/src/videos/video.dart @@ -1,8 +1,10 @@ import 'dart:collection'; import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; import '../common/common.dart'; +import '../reverse_engineering/responses/responses.dart'; import 'video_id.dart'; /// YouTube video metadata. @@ -37,8 +39,9 @@ class Video with EquatableMixin { /// Engagement statistics for this video. final Engagement engagement; - /// Get the videos comments - final Function(int) getComments; + /// Used internally. + @protected + final WatchPage watchPage; /// Initializes an instance of [Video] Video( @@ -50,7 +53,8 @@ class Video with EquatableMixin { this.duration, this.thumbnails, Iterable keywords, - this.engagement, this.getComments) + this.engagement, + [this.watchPage]) : keywords = UnmodifiableListView(keywords); @override diff --git a/lib/src/videos/video_client.dart b/lib/src/videos/video_client.dart index a99781a..14571c3 100644 --- a/lib/src/videos/video_client.dart +++ b/lib/src/videos/video_client.dart @@ -2,6 +2,7 @@ import '../common/common.dart'; import '../reverse_engineering/responses/responses.dart'; import '../reverse_engineering/youtube_http_client.dart'; import 'closed_captions/closed_caption_client.dart'; +import 'comments/comments_client.dart'; import 'videos.dart'; /// Queries related to YouTube videos. @@ -14,10 +15,14 @@ class VideoClient { /// Queries related to closed captions of YouTube videos. final ClosedCaptionClient closedCaptions; + /// Queries related to a YouTube video. + final CommentsClient commentsClient; + /// Initializes an instance of [VideoClient]. VideoClient(this._httpClient) : streamsClient = StreamsClient(_httpClient), - closedCaptions = ClosedCaptionClient(_httpClient); + closedCaptions = ClosedCaptionClient(_httpClient), + commentsClient = CommentsClient(_httpClient); /// Gets the metadata associated with the specified video. Future