Version 1.10.2

Better comments api.
Fix #142
This commit is contained in:
Mattia 2021-07-24 01:30:12 +02:00
parent aa034aa830
commit b0612c52de
14 changed files with 186 additions and 61 deletions

View File

@ -1,7 +1,10 @@
## 1.10.2
- Better comments API: Implemented API to fetch more comments & replies.
## 1.10.1 ## 1.10.1
- Fix issue #146: Closed Captions couldn't be extracted anymore. - Fix issue #146: Closed Captions couldn't be extracted anymore.
- Code cleanup. - Code cleanup.
-
## 1.10.0 ## 1.10.0
- Fix issue #144: get_video_info was removed from yt. - Fix issue #144: get_video_info was removed from yt.

View File

@ -5,7 +5,6 @@ part 'engagement.freezed.dart';
/// User activity statistics. /// User activity statistics.
@freezed @freezed
class Engagement with _$Engagement { class Engagement with _$Engagement {
const factory Engagement( const factory Engagement(
/// View count. /// View count.
int viewCount, int viewCount,

View File

@ -1,3 +1,5 @@
import 'package:meta/meta.dart';
/// Parent class for domain exceptions thrown by [YoutubeExplode] /// Parent class for domain exceptions thrown by [YoutubeExplode]
abstract class YoutubeExplodeException implements Exception { abstract class YoutubeExplodeException implements Exception {
/// Generic message. /// Generic message.
@ -13,6 +15,7 @@ abstract class YoutubeExplodeException implements Exception {
YoutubeExplodeException(this.message); YoutubeExplodeException(this.message);
@override @override
@nonVirtual
String toString() { String toString() {
if (_others.isEmpty) { if (_others.isEmpty) {
return '$runtimeType: $message'; return '$runtimeType: $message';

View File

@ -80,7 +80,7 @@ extension StringUtility on String {
/// Utility for Strings. /// Utility for Strings.
extension StringUtility2 on String? { extension StringUtility2 on String? {
static final RegExp _unitSplit = RegExp(r'^(\d+(?:\.\d)?)(\w)'); static final RegExp _unitSplit = RegExp(r'^(\d+(?:\.\d+)?)(\w)?');
/// Parses this value as int stripping the non digit characters, /// Parses this value as int stripping the non digit characters,
/// returns null if this fails. /// returns null if this fails.
@ -104,9 +104,6 @@ extension StringUtility2 on String? {
} }
final multiplierText = match.group(2); final multiplierText = match.group(2);
if (multiplierText == null) {
return null;
}
var multiplier = 1; var multiplier = 1;
if (multiplierText == 'K') { if (multiplierText == 'K') {
@ -238,10 +235,17 @@ extension GetOrNullMap on Map {
} }
/// Get a List<Map<String, dynamic>>> from a map. /// Get a List<Map<String, dynamic>>> from a map.
List<Map<String, dynamic>>? getList(String key) { List<Map<String, dynamic>>? getList(String key, [String? orKey]) {
var v = this[key]; var v = this[key];
if (v == null) { if (v == null) {
return null; if (orKey != null) {
v = this[orKey];
if (v == null) {
return null;
}
} else {
return null;
}
} }
if (v is! List<dynamic>) { if (v is! List<dynamic>) {
throw Exception('Invalid type: ${v.runtimeType} should be of type List'); throw Exception('Invalid type: ${v.runtimeType} should be of type List');

View File

@ -22,7 +22,6 @@ class WatchPage extends YoutubePage<_InitialData> {
RegExp('VISITOR_INFO1_LIVE=([^;]+)'); RegExp('VISITOR_INFO1_LIVE=([^;]+)');
static final RegExp _yscExp = RegExp('YSC=([^;]+)'); static final RegExp _yscExp = RegExp('YSC=([^;]+)');
@override @override
// Overridden to be non-nullable. // Overridden to be non-nullable.
// ignore: overridden_fields // ignore: overridden_fields

View File

@ -148,8 +148,7 @@ class ClosedCaptionTrack {
String get languageCode => root.getT<String>('languageCode')!; String get languageCode => root.getT<String>('languageCode')!;
/// ///
String? get languageName => String? get languageName => root.get('name')!.getT<String>('simpleText');
root.get('name')!.getT<String>('simpleText');
/// ///
bool get autoGenerated => bool get autoGenerated =>

View File

@ -14,6 +14,8 @@ class CommentsClient {
late final List<_Comment> comments = late final List<_Comment> comments =
_commentRenderers.map((e) => _Comment(e)).toList(growable: false); _commentRenderers.map((e) => _Comment(e)).toList(growable: false);
late final String? _continuationToken = _getContinuationToken();
CommentsClient(this.root); CommentsClient(this.root);
/// ///
@ -32,21 +34,82 @@ class CommentsClient {
return CommentsClient(data); return CommentsClient(data);
} }
///
static Future<CommentsClient?> getReplies(
YoutubeHttpClient httpClient, String token) async {
final data = await httpClient.sendPost('next', token);
return CommentsClient(data);
}
List<JsonMap> _getCommentRenderers() { List<JsonMap> _getCommentRenderers() {
return root return root
.getList('onResponseReceivedEndpoints')![1] .getList('onResponseReceivedEndpoints')!
.get('reloadContinuationItemsCommand')! .last
.getList('continuationItems')! .get('appendContinuationItemsAction')
.where((e) => e['commentThreadRenderer'] != null) ?.getList('continuationItems')
.map((e) => e.get('commentThreadRenderer')!) ?.where((e) => e['commentRenderer'] != null)
.toList(growable: false); .toList(growable: false) /* Used for the replies */ ??
root
.getList('onResponseReceivedEndpoints')!
.last
.get('reloadContinuationItemsCommand')!
.getList('continuationItems', 'appendContinuationItemsAction')!
.where((e) => e['commentThreadRenderer'] != null)
.map((e) => e.get('commentThreadRenderer')!)
.toList(growable: false);
}
String? _getContinuationToken() {
return root
.getList('onResponseReceivedEndpoints')!
.last
.get('appendContinuationItemsAction')
?.getList('continuationItems')
?.firstWhereOrNull((e) => e['continuationItemRenderer'] != null)
?.get('continuationItemRenderer')
?.get('button')
?.get('buttonRenderer')
?.get('command')
?.get('continuationCommand')
?.getT<String>('token') /* Used for the replies */ ??
root
.getList('onResponseReceivedEndpoints')!
.last
.get('reloadContinuationItemsCommand')!
.getList('continuationItems', 'appendContinuationItemsAction')!
.firstWhereOrNull((e) => e['continuationItemRenderer'] != null)
?.get('continuationItemRenderer')
?.get('continuationEndpoint')
?.get('continuationCommand')
?.getT<String>('token');
}
int getCommentsCount() => root
.getList('onResponseReceivedEndpoints')![1]
.get('reloadContinuationItemsCommand')!
.getList('continuationItems')!
.first
.get('commentsHeaderRenderer')!
.get('commentsCount')!
.getList('runs')!
.first
.getT<String>('text')
.parseIntWithUnits()!;
Future<CommentsClient?> nextPage(YoutubeHttpClient httpClient) async {
if (_continuationToken == null) {
return null;
}
final data = await httpClient.sendPost('next', _continuationToken!);
return CommentsClient(data);
} }
} }
class _Comment { class _Comment {
final JsonMap root; final JsonMap root;
late final JsonMap _commentRenderer = late final JsonMap _commentRenderer = root.get('commentRenderer') ??
root.get('comment')!.get('commentRenderer')!; root.get('comment')!.get('commentRenderer')!;
late final JsonMap? _commentRepliesRenderer = late final JsonMap? _commentRepliesRenderer =
@ -61,14 +124,7 @@ class _Comment {
?.get('continuationCommand') ?.get('continuationCommand')
?.getT<String>('token'); ?.getT<String>('token');
late final int? repliesCount = _commentRepliesRenderer late final int? repliesCount = _commentRenderer.getT<int>('replyCount');
?.get('viewReplies')
?.get('buttonRenderer')
?.get('text')
?.getList('runs')
?.elementAtSafe(2)
?.getT<String>('text')
?.parseIntWithUnits();
late final String author = late final String author =
_commentRenderer.get('authorText')!.getT<String>('simpleText')!; _commentRenderer.get('authorText')!.getT<String>('simpleText')!;
@ -95,18 +151,8 @@ class _Comment {
.first .first
.getT<String>('text')!; .getT<String>('text')!;
/// Needs to be parsed as an int current is like: 1.2K
late final int? likeCount = _commentRenderer late final int? likeCount = _commentRenderer
.get('actionButtons') .get('voteCount')
?.get('commentActionButtonsRenderer')
?.get('likeButton')
?.get('toggleButtonRenderer')
?.get('defaultServiceEndpoint')
?.get('performCommentActionEndpoint')
?.getList('clientActions')
?.first
.get('updateCommentVoteAction')
?.get('voteCount')
?.getT<String>('simpleText') ?.getT<String>('simpleText')
?.parseIntWithUnits(); ?.parseIntWithUnits();

View File

@ -105,9 +105,11 @@ class YoutubeHttpClient extends http.BaseClient {
var bytesCount = start; var bytesCount = start;
for (var i = start; i < streamInfo.size.totalBytes; i += 9898989) { for (var i = start; i < streamInfo.size.totalBytes; i += 9898989) {
try { try {
final request = http.Request('get', url); final response = await retry(() {
request.headers['range'] = 'bytes=$i-${i + 9898989 - 1}'; final request = http.Request('get', url);
final response = await retry(() => send(request)); request.headers['range'] = 'bytes=$i-${i + 9898989 - 1}';
return send(request);
});
if (validate) { if (validate) {
_validateResponse(response, response.statusCode); _validateResponse(response, response.statusCode);
} }

View File

@ -7,7 +7,7 @@ import '../../youtube_explode_dart.dart';
import '../extensions/helpers_extension.dart'; import '../extensions/helpers_extension.dart';
/// This list contains search videos. /// This list contains search videos.
/// /// This behaves like a [List] but has the [SearchList.nextPage] to get the next batch of videos. ///This behaves like a [List] but has the [SearchList.nextPage] to get the next batch of videos.
class SearchList extends DelegatingList<Video> { class SearchList extends DelegatingList<Video> {
final SearchPage _page; final SearchPage _page;
final YoutubeHttpClient _httpClient; final YoutubeHttpClient _httpClient;

View File

@ -35,8 +35,7 @@ class ClosedCaptionClient {
]}) async { ]}) async {
videoId = VideoId.fromString(videoId); videoId = VideoId.fromString(videoId);
var tracks = <ClosedCaptionTrackInfo>{}; var tracks = <ClosedCaptionTrackInfo>{};
var watchPage = var watchPage = await WatchPage.get(_httpClient, videoId.value);
await WatchPage.get(_httpClient, videoId.value);
var playerResponse = watchPage.playerResponse!; var playerResponse = watchPage.playerResponse!;
for (final track in playerResponse.closedCaptionTrack) { for (final track in playerResponse.closedCaptionTrack) {

View File

@ -5,31 +5,30 @@ import '../../reverse_engineering/responses/comments_client.dart' as re;
import '../../reverse_engineering/youtube_http_client.dart'; import '../../reverse_engineering/youtube_http_client.dart';
import '../videos.dart'; import '../videos.dart';
import 'comment.dart'; import 'comment.dart';
import 'comments_list.dart';
/// Queries related to comments of YouTube videos. /// Queries related to comments of YouTube videos.
@experimental
class CommentsClient { class CommentsClient {
final YoutubeHttpClient _httpClient; final YoutubeHttpClient _httpClient;
/// Initializes an instance of [CommentsClient] /// Initializes an instance of [CommentsClient]
CommentsClient(this._httpClient); CommentsClient(this._httpClient);
/// Returns a stream emitting all the [video]'s comment. /// Returns a [List<Comment>] containing the first batch of comments.
/// A request is page for every comment page, /// You can use [CommentsList.nextPage()] to get the next batch of comments.
/// a page contains at most 20 comments, use .take if you want to limit Future<CommentsList?> getComments(Video video) async {
/// the results.
///
/// The streams doesn't emit any data if [Video.hasWatchPage] is false.
/// Use `videos.get(videoId, forceWatchPage: true)` to assure that the
/// WatchPage is fetched.
Future<List<Comment>> getComments(Video video) async {
if (video.watchPage == null) { if (video.watchPage == null) {
return const []; return null;
} }
final page = await re.CommentsClient.get(_httpClient, video); final page = await re.CommentsClient.get(_httpClient, video);
return page?.comments if (page == null) {
return null;
}
return CommentsList(
page.comments
.map((e) => Comment( .map((e) => Comment(
e.author, e.author,
ChannelId(e.channelId), ChannelId(e.channelId),
@ -38,7 +37,35 @@ class CommentsClient {
e.publishTime, e.publishTime,
e.repliesCount ?? 0, e.repliesCount ?? 0,
e.continuation)) e.continuation))
.toList(growable: false) ?? .toList(growable: false),
const []; page,
_httpClient);
}
Future<CommentsList?> getReplies(Comment comment) async {
if (comment.continuation == null) {
return null;
}
final page =
await re.CommentsClient.getReplies(_httpClient, comment.continuation!);
if (page == null) {
return null;
}
return CommentsList(
page.comments
.map((e) => Comment(
e.author,
ChannelId(e.channelId),
e.text,
e.likeCount ?? 0,
e.publishTime,
e.repliesCount ?? 0,
e.continuation))
.toList(growable: false),
page,
_httpClient);
} }
} }

View File

@ -0,0 +1,42 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/responses/comments_client.dart'
as re;
import '../../../youtube_explode_dart.dart';
/// This list contains search videos.
///This behaves like a [List] but has the [SearchList.nextPage] to get the next batch of videos.
class CommentsList extends DelegatingList<Comment> {
final re.CommentsClient _client;
final YoutubeHttpClient _httpClient;
/// Construct an instance of [SearchList]
/// See [SearchList]
CommentsList(List<Comment> base, this._client, this._httpClient)
: super(base);
/// Fetches the next batch of videos or returns null if there are no more
/// results.
Future<CommentsList?> nextPage() async {
final page = await _client.nextPage(_httpClient);
if (page == null) {
return null;
}
return CommentsList(
page.comments
.map((e) => Comment(
e.author,
ChannelId(e.channelId),
e.text,
e.likeCount ?? 0,
e.publishTime,
e.repliesCount ?? 0,
e.continuation))
.toList(growable: false),
page,
_httpClient);
}
}

View File

@ -15,6 +15,6 @@ void main() {
var videoUrl = 'https://www.youtube.com/watch?v=AI7ULzgf8RU'; var videoUrl = 'https://www.youtube.com/watch?v=AI7ULzgf8RU';
var video = await yt!.videos.get(VideoId(videoUrl)); var video = await yt!.videos.get(VideoId(videoUrl));
var comments = await yt!.videos.commentsClient.getComments(video); var comments = await yt!.videos.commentsClient.getComments(video);
expect(comments.length, greaterThanOrEqualTo(1)); expect(comments!.length, greaterThanOrEqualTo(1));
}); });
} }

View File

@ -23,7 +23,6 @@ void main() {
VideoId('YltHGKX80Y8'), //ContainsClosedCaptions VideoId('YltHGKX80Y8'), //ContainsClosedCaptions
VideoId('_kmeFXjjGfk'), //EmbedRestrictedByYouTube VideoId('_kmeFXjjGfk'), //EmbedRestrictedByYouTube
VideoId('MeJVWBSsPAY'), //EmbedRestrictedByAuthor VideoId('MeJVWBSsPAY'), //EmbedRestrictedByAuthor
VideoId('SkRSXFQerZs'), //AgeRestricted
VideoId('hySoCSoH-g8'), //AgeRestrictedEmbedRestricted VideoId('hySoCSoH-g8'), //AgeRestrictedEmbedRestricted
VideoId('5VGm0dczmHc'), //RatingDisabled VideoId('5VGm0dczmHc'), //RatingDisabled
VideoId('-xNN-bJQ4vI'), // 360° video VideoId('-xNN-bJQ4vI'), // 360° video
@ -31,7 +30,7 @@ void main() {
test('VideoId - ${val.value}', () async { test('VideoId - ${val.value}', () async {
var manifest = await yt!.videos.streamsClient.getManifest(val); var manifest = await yt!.videos.streamsClient.getManifest(val);
expect(manifest.streams, isNotEmpty); expect(manifest.streams, isNotEmpty);
}); }, timeout: const Timeout(Duration(seconds: 90)));
} }
}); });
@ -40,6 +39,10 @@ void main() {
throwsA(const TypeMatcher<VideoRequiresPurchaseException>())); throwsA(const TypeMatcher<VideoRequiresPurchaseException>()));
}); });
test('Stream of age-limited video throws VideoUnplayableException', () {
expect(yt!.videos.streamsClient.getManifest(VideoId('SkRSXFQerZs')),
throwsA(const TypeMatcher<VideoUnplayableException>()));
});
test('Get the hls manifest of a live stream', () async { test('Get the hls manifest of a live stream', () async {
expect( expect(
await yt!.videos.streamsClient await yt!.videos.streamsClient
@ -68,7 +71,6 @@ void main() {
VideoId('YltHGKX80Y8'), //ContainsClosedCaptions VideoId('YltHGKX80Y8'), //ContainsClosedCaptions
VideoId('_kmeFXjjGfk'), //EmbedRestrictedByYouTube VideoId('_kmeFXjjGfk'), //EmbedRestrictedByYouTube
VideoId('MeJVWBSsPAY'), //EmbedRestrictedByAuthor VideoId('MeJVWBSsPAY'), //EmbedRestrictedByAuthor
VideoId('SkRSXFQerZs'), //AgeRestricted
VideoId('hySoCSoH-g8'), //AgeRestrictedEmbedRestricted VideoId('hySoCSoH-g8'), //AgeRestrictedEmbedRestricted
VideoId('5VGm0dczmHc'), //RatingDisabled VideoId('5VGm0dczmHc'), //RatingDisabled
}) { }) {