Version 1.10.0

Code refactoring
Fix #144
Bumped min sdk version to 2.13.0
Changed comments api interface (still experimental)
This commit is contained in:
Mattia 2021-07-22 15:03:07 +02:00
parent ec80924a28
commit 73504b2a44
18 changed files with 250 additions and 294 deletions

View File

@ -1,3 +1,8 @@
## 1.10.0
- Fix issue #144: get_video_info was removed from yt.
- Min sdk version now is 2.13.0
- BREAKING CHANGE: New comments API implementation.
## 1.9.10 ## 1.9.10
- Close #139: Implement Channel.subscribersCount. - Close #139: Implement Channel.subscribersCount.

View File

@ -5,10 +5,9 @@ import 'channel_id.dart';
part 'channel.freezed.dart'; part 'channel.freezed.dart';
/// YouTube channel metadata. /// YouTube channel metadata.
@Freezed() @freezed
class Channel with _$Channel { class Channel with _$Channel {
const Channel._(); ///
const factory Channel( const factory Channel(
/// Channel ID. /// Channel ID.
ChannelId id, ChannelId id,
@ -25,4 +24,6 @@ class Channel with _$Channel {
/// Channel URL. /// Channel URL.
String get url => 'https://www.youtube.com/channel/$id'; String get url => 'https://www.youtube.com/channel/$id';
const Channel._();
} }

View File

@ -1,6 +1,6 @@
import 'package:youtube_explode_dart/src/channels/channel_uploads_list.dart'; import 'package:youtube_explode_dart/src/channels/channel_uploads_list.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/pages/channel_page.dart'; import 'package:youtube_explode_dart/src/reverse_engineering/pages/channel_page.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/responses/video_info_response.dart'; import 'package:youtube_explode_dart/src/reverse_engineering/pages/watch_page.dart';
import '../common/common.dart'; import '../common/common.dart';
import '../extensions/helpers_extension.dart'; import '../extensions/helpers_extension.dart';
@ -54,7 +54,7 @@ class ChannelClient {
channelId = ChannelId.fromString(channelId); channelId = ChannelId.fromString(channelId);
final aboutPage = await ChannelAboutPage.get(_httpClient, channelId.value); final aboutPage = await ChannelAboutPage.get(_httpClient, channelId.value);
final id = aboutPage.initialData;
return ChannelAbout( return ChannelAbout(
aboutPage.description, aboutPage.description,
aboutPage.viewCount, aboutPage.viewCount,
@ -76,6 +76,8 @@ class ChannelClient {
var channelAboutPage = var channelAboutPage =
await ChannelAboutPage.getByUsername(_httpClient, username.value); await ChannelAboutPage.getByUsername(_httpClient, username.value);
// TODO: Expose metadata from the [ChannelAboutPage] class.
var id = channelAboutPage.initialData; var id = channelAboutPage.initialData;
return ChannelAbout( return ChannelAbout(
id.description, id.description,
@ -94,9 +96,8 @@ class ChannelClient {
/// that uploaded the specified video. /// that uploaded the specified video.
Future<Channel> getByVideo(dynamic videoId) async { Future<Channel> getByVideo(dynamic videoId) async {
videoId = VideoId.fromString(videoId); videoId = VideoId.fromString(videoId);
var videoInfoResponse = var videoInfoResponse = await WatchPage.get(_httpClient, videoId.value);
await VideoInfoResponse.get(_httpClient, videoId.value); var playerResponse = videoInfoResponse.playerResponse!;
var playerResponse = videoInfoResponse.playerResponse;
var channelId = playerResponse.videoChannelId; var channelId = playerResponse.videoChannelId;
return get(ChannelId(channelId)); return get(ChannelId(channelId));

View File

@ -80,10 +80,44 @@ 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)');
/// 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.
int? parseInt() => int.tryParse(this?.stripNonDigits() ?? ''); int? parseInt() => int.tryParse(this?.stripNonDigits() ?? '');
int? parseIntWithUnits() {
if (this == null) {
return null;
}
final match = _unitSplit.firstMatch(this!.trim());
if (match == null) {
return null;
}
if (match.groupCount != 2) {
return null;
}
final count = double.tryParse(match.group(1) ?? '');
if (count == null) {
return null;
}
final multiplierText = match.group(2);
if (multiplierText == null) {
return null;
}
var multiplier = 1;
if (multiplierText == 'K') {
multiplier = 1000;
} else if (multiplierText == 'M') {
multiplier = 1000000;
}
return (count * multiplier).toInt();
}
/// Returns true if the string is null or empty. /// Returns true if the string is null or empty.
bool get isNullOrWhiteSpace { bool get isNullOrWhiteSpace {
if (this == null) { if (this == null) {
@ -235,19 +269,18 @@ extension RunsParser on List<dynamic> {
} }
extension GenericExtract on List<String> { extension GenericExtract on List<String> {
/// Used to extract initial data that start with `var ytInitialData = ` or 'window["ytInitialData"] ='. /// Used to extract initial data.
T extractGenericData<T>( T extractGenericData<T>(List<String> match,
T Function(Map<String, dynamic>) builder, Exception Function() orThrow) { T Function(Map<String, dynamic>) builder, Exception Function() orThrow) {
var initialData = JsonMap? initialData;
firstWhereOrNull((e) => e.contains('var ytInitialData = '))
?.extractJson('var ytInitialData = ');
initialData ??=
firstWhereOrNull((e) => e.contains('window["ytInitialData"] ='))
?.extractJson('window["ytInitialData"] =');
if (initialData != null) { for (final m in match) {
return builder(initialData); initialData = firstWhereOrNull((e) => e.contains(m))?.extractJson(m);
if (initialData != null) {
return builder(initialData);
}
} }
throw orThrow(); throw orThrow();
} }
} }

View File

@ -23,6 +23,7 @@ abstract class YoutubePage<T extends InitialData> {
.map((e) => e.text) .map((e) => e.text)
.toList(growable: false); .toList(growable: false);
return scriptText.extractGenericData( return scriptText.extractGenericData(
['var ytInitialData = ', 'window["ytInitialData"] ='],
initialDataBuilder!, initialDataBuilder!,
() => TransientFailureException( () => TransientFailureException(
'Failed to retrieve initial data from $runtimeType, please report this to the project GitHub page.')); 'Failed to retrieve initial data from $runtimeType, please report this to the project GitHub page.'));

View File

@ -93,6 +93,8 @@ class WatchPage extends YoutubePage<_InitialData> {
.nullIfWhitespace ?? .nullIfWhitespace ??
'0'); '0');
String? get commentsContinuation => initialData.commentsContinuation;
static final _playerConfigExp = RegExp(r'ytplayer\.config\s*=\s*(\{.*\})'); static final _playerConfigExp = RegExp(r'ytplayer\.config\s*=\s*(\{.*\})');
late final WatchPlayerConfig? playerConfig = getPlayerConfig(); late final WatchPlayerConfig? playerConfig = getPlayerConfig();
@ -111,18 +113,16 @@ class WatchPage extends YoutubePage<_InitialData> {
return WatchPlayerConfig(jsonMap); return WatchPlayerConfig(jsonMap);
} }
///
PlayerResponse? getPlayerResponse() { PlayerResponse? getPlayerResponse() {
final val = root final scriptText = root
.querySelectorAll('script') .querySelectorAll('script')
.map((e) => e.text) .map((e) => e.text)
.map((e) => _playerResponseExp.firstMatch(e)?.group(1)) .toList(growable: false);
.firstWhereOrNull((e) => !e.isNullOrWhiteSpace) return scriptText.extractGenericData(
?.extractJson(); ['var ytInitialPlayerResponse = '],
if (val == null) { (root) => PlayerResponse(root),
return null; () => TransientFailureException(
} 'Failed to retrieve initial player response, please report this to the project GitHub page.'));
return PlayerResponse(val);
} }
/// ///
@ -183,16 +183,15 @@ class _InitialData extends InitialData {
?.getList('contents') ?.getList('contents')
?.firstWhere((e) => e['itemSectionRenderer'] != null) ?.firstWhere((e) => e['itemSectionRenderer'] != null)
.get('itemSectionRenderer') .get('itemSectionRenderer')
?.getList('continuations') ?.getList('contents')
?.firstOrNull ?.firstOrNull
?.get('nextContinuationData'); ?.get('continuationItemRenderer')
?.get('continuationEndpoint')
?.get('continuationCommand');
} }
return null; return null;
} }
late final String continuation = late final String commentsContinuation =
getContinuationContext()?.getT<String>('continuation') ?? ''; getContinuationContext()?.getT<String>('token') ?? '';
late final String clickTrackingParams =
getContinuationContext()?.getT<String>('clickTrackingParams') ?? '';
} }

View File

@ -5,7 +5,7 @@ import '../../retry.dart';
import '../youtube_http_client.dart'; import '../youtube_http_client.dart';
/// ///
class ClosedCaptionTrackResponse { class ClosedCaptionClient {
final xml.XmlDocument root; final xml.XmlDocument root;
/// ///
@ -13,19 +13,19 @@ class ClosedCaptionTrackResponse {
root.findAllElements('p').map((e) => ClosedCaption._(e)); root.findAllElements('p').map((e) => ClosedCaption._(e));
/// ///
ClosedCaptionTrackResponse(this.root); ClosedCaptionClient(this.root);
/// ///
// ignore: deprecated_member_use // ignore: deprecated_member_use
ClosedCaptionTrackResponse.parse(String raw) : root = xml.parse(raw); ClosedCaptionClient.parse(String raw) : root = xml.parse(raw);
/// ///
static Future<ClosedCaptionTrackResponse> get( static Future<ClosedCaptionClient> get(
YoutubeHttpClient httpClient, Uri url) { YoutubeHttpClient httpClient, Uri url) {
var formatUrl = url.replaceQueryParameters({'fmt': 'srv3'}); final formatUrl = url.replaceQueryParameters({'fmt': 'srv3'});
return retry(() async { return retry(() async {
var raw = await httpClient.getString(formatUrl); var raw = await httpClient.getString(formatUrl);
return ClosedCaptionTrackResponse.parse(raw); return ClosedCaptionClient.parse(raw);
}); });
} }
} }

View File

@ -0,0 +1,117 @@
import 'package:collection/collection.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/pages/watch_page.dart';
import '../../../youtube_explode_dart.dart';
import '../../extensions/helpers_extension.dart';
import '../../retry.dart';
import '../youtube_http_client.dart';
class CommentsClient {
final JsonMap root;
late final List<JsonMap> _commentRenderers = _getCommentRenderers();
late final List<_Comment> comments =
_commentRenderers.map((e) => _Comment(e)).toList(growable: false);
CommentsClient(this.root);
///
static Future<CommentsClient?> get(
YoutubeHttpClient httpClient, Video video) async {
final watchPage = video.watchPage ??
await retry<WatchPage>(
() async => WatchPage.get(httpClient, video.id.value));
final continuation = watchPage.commentsContinuation;
if (continuation == null) {
return null;
}
final data = await httpClient.sendPost('next', continuation);
return CommentsClient(data);
}
List<JsonMap> _getCommentRenderers() {
return root
.getList('onResponseReceivedEndpoints')![1]
.get('reloadContinuationItemsCommand')!
.getList('continuationItems')!
.where((e) => e['commentThreadRenderer'] != null)
.map((e) => e.get('commentThreadRenderer')!)
.toList(growable: false);
}
}
class _Comment {
final JsonMap root;
late final JsonMap _commentRenderer =
root.get('comment')!.get('commentRenderer')!;
late final JsonMap? _commentRepliesRenderer =
root.get('replies')?.get('commentRepliesRenderer');
/// Used to get replies
late final String? continuation = _commentRepliesRenderer
?.getList('contents')
?.firstOrNull
?.get('continuationItemRenderer')
?.get('continuationEndpoint')
?.get('continuationCommand')
?.getT<String>('token');
late final int? repliesCount = _commentRepliesRenderer
?.get('viewReplies')
?.get('buttonRenderer')
?.get('text')
?.getList('runs')
?.elementAtSafe(2)
?.getT<String>('text')
?.parseIntWithUnits();
late final String author =
_commentRenderer.get('authorText')!.getT<String>('simpleText')!;
late final String channelThumbnail = _commentRenderer
.get('authorThumbnail')!
.getList('thumbnails')!
.last
.getT<String>('url')!;
late final String channelId = _commentRenderer
.get('authorEndpoint')!
.get('browseEndpoint')!
.getT<String>('browseId')!;
late final String text = _commentRenderer
.get('contentText')!
.getT<List<dynamic>>('runs')!
.parseRuns();
late final String publishTime = _commentRenderer
.get('publishedTimeText')!
.getList('runs')!
.first
.getT<String>('text')!;
/// Needs to be parsed as an int current is like: 1.2K
late final int? likeCount = _commentRenderer
.get('actionButtons')
?.get('commentActionButtonsRenderer')
?.get('likeButton')
?.get('toggleButtonRenderer')
?.get('defaultServiceEndpoint')
?.get('performCommentActionEndpoint')
?.getList('clientActions')
?.first
.get('updateCommentVoteAction')
?.get('voteCount')
?.getT<String>('simpleText')
?.parseIntWithUnits();
_Comment(this.root);
@override
String toString() => '$author: $text';
}

View File

@ -8,7 +8,9 @@ import '../player/player_response.dart';
import '../models/stream_info_provider.dart'; import '../models/stream_info_provider.dart';
/// ///
class VideoInfoResponse { ///
@deprecated
class VideoInfoClient {
final Map<String, String> root; final Map<String, String> root;
/// ///
@ -43,13 +45,13 @@ class VideoInfoResponse {
]; ];
/// ///
VideoInfoResponse(this.root); VideoInfoClient(this.root);
/// ///
VideoInfoResponse.parse(String raw) : root = Uri.splitQueryString(raw); VideoInfoClient.parse(String raw) : root = Uri.splitQueryString(raw);
/// ///
static Future<VideoInfoResponse> get( static Future<VideoInfoClient> get(
YoutubeHttpClient httpClient, String videoId, YoutubeHttpClient httpClient, String videoId,
[String? sts]) { [String? sts]) {
var eurl = Uri.encodeFull('https://youtube.googleapis.com/v/$videoId'); var eurl = Uri.encodeFull('https://youtube.googleapis.com/v/$videoId');
@ -71,7 +73,7 @@ class VideoInfoResponse {
return retry(() async { return retry(() async {
var raw = await httpClient.getString(url); var raw = await httpClient.getString(url);
var result = VideoInfoResponse.parse(raw); var result = VideoInfoClient.parse(raw);
if (!result.isVideoAvailable || !result.playerResponse.isVideoAvailable) { if (!result.isVideoAvailable || !result.playerResponse.isVideoAvailable) {
throw VideoUnplayableException(videoId); throw VideoUnplayableException(videoId);

View File

@ -1,7 +1,7 @@
import '../../extensions/helpers_extension.dart'; import '../../extensions/helpers_extension.dart';
import '../../reverse_engineering/responses/closed_caption_track_response.dart' import '../../reverse_engineering/responses/closed_caption_client.dart' as re
show ClosedCaptionTrackResponse; show ClosedCaptionClient;
import '../../reverse_engineering/responses/video_info_response.dart'; import '../../reverse_engineering/responses/video_info_client.dart';
import '../../reverse_engineering/youtube_http_client.dart'; import '../../reverse_engineering/youtube_http_client.dart';
import '../videos.dart'; import '../videos.dart';
import 'closed_caption.dart'; import 'closed_caption.dart';
@ -35,7 +35,7 @@ class ClosedCaptionClient {
videoId = VideoId.fromString(videoId); videoId = VideoId.fromString(videoId);
var tracks = <ClosedCaptionTrackInfo>{}; var tracks = <ClosedCaptionTrackInfo>{};
var videoInfoResponse = var videoInfoResponse =
await VideoInfoResponse.get(_httpClient, videoId.value); await VideoInfoClient.get(_httpClient, videoId.value);
var playerResponse = videoInfoResponse.playerResponse; var playerResponse = videoInfoResponse.playerResponse;
for (final track in playerResponse.closedCaptionTrack) { for (final track in playerResponse.closedCaptionTrack) {
@ -54,8 +54,7 @@ class ClosedCaptionClient {
/// Gets the actual closed caption track which is /// Gets the actual closed caption track which is
/// identified by the specified metadata. /// identified by the specified metadata.
Future<ClosedCaptionTrack> get(ClosedCaptionTrackInfo trackInfo) async { Future<ClosedCaptionTrack> get(ClosedCaptionTrackInfo trackInfo) async {
var response = var response = await re.ClosedCaptionClient.get(_httpClient, trackInfo.url);
await ClosedCaptionTrackResponse.get(_httpClient, trackInfo.url);
var captions = response.closedCaptions var captions = response.closedCaptions
.where((e) => !e.text.isNullOrWhiteSpace) .where((e) => !e.text.isNullOrWhiteSpace)

View File

@ -9,9 +9,6 @@ part 'comment.freezed.dart';
class Comment with _$Comment { class Comment with _$Comment {
/// Initializes an instance of [Comment] /// Initializes an instance of [Comment]
const factory Comment( const factory Comment(
/// Comment id.
String commentId,
/// Comment author name. /// Comment author name.
String author, String author,
@ -33,9 +30,5 @@ class Comment with _$Comment {
/// Used internally. /// Used internally.
/// Shouldn't be used in the code. /// Shouldn't be used in the code.
@internal String? continuation, @internal String? continuation,
/// Used internally.
/// Shouldn't be used in the code.
@internal String? clicktrackingParams,
) = _Comment; ) = _Comment;
} }

View File

@ -16,18 +16,9 @@ final _privateConstructorUsedError = UnsupportedError(
class _$CommentTearOff { class _$CommentTearOff {
const _$CommentTearOff(); const _$CommentTearOff();
_Comment call( _Comment call(String author, ChannelId channelId, String text, int likeCount,
String commentId, String publishedTime, int replyCount, @internal String? continuation) {
String author,
ChannelId channelId,
String text,
int likeCount,
String publishedTime,
int replyCount,
@internal String? continuation,
@internal String? clicktrackingParams) {
return _Comment( return _Comment(
commentId,
author, author,
channelId, channelId,
text, text,
@ -35,7 +26,6 @@ class _$CommentTearOff {
publishedTime, publishedTime,
replyCount, replyCount,
continuation, continuation,
clicktrackingParams,
); );
} }
} }
@ -45,9 +35,6 @@ const $Comment = _$CommentTearOff();
/// @nodoc /// @nodoc
mixin _$Comment { mixin _$Comment {
/// Comment id.
String get commentId => throw _privateConstructorUsedError;
/// Comment author name. /// Comment author name.
String get author => throw _privateConstructorUsedError; String get author => throw _privateConstructorUsedError;
@ -71,11 +58,6 @@ mixin _$Comment {
@internal @internal
String? get continuation => throw _privateConstructorUsedError; String? get continuation => throw _privateConstructorUsedError;
/// Used internally.
/// Shouldn't be used in the code.
@internal
String? get clicktrackingParams => throw _privateConstructorUsedError;
@JsonKey(ignore: true) @JsonKey(ignore: true)
$CommentCopyWith<Comment> get copyWith => throw _privateConstructorUsedError; $CommentCopyWith<Comment> get copyWith => throw _privateConstructorUsedError;
} }
@ -85,15 +67,13 @@ abstract class $CommentCopyWith<$Res> {
factory $CommentCopyWith(Comment value, $Res Function(Comment) then) = factory $CommentCopyWith(Comment value, $Res Function(Comment) then) =
_$CommentCopyWithImpl<$Res>; _$CommentCopyWithImpl<$Res>;
$Res call( $Res call(
{String commentId, {String author,
String author,
ChannelId channelId, ChannelId channelId,
String text, String text,
int likeCount, int likeCount,
String publishedTime, String publishedTime,
int replyCount, int replyCount,
@internal String? continuation, @internal String? continuation});
@internal String? clicktrackingParams});
$ChannelIdCopyWith<$Res> get channelId; $ChannelIdCopyWith<$Res> get channelId;
} }
@ -108,7 +88,6 @@ class _$CommentCopyWithImpl<$Res> implements $CommentCopyWith<$Res> {
@override @override
$Res call({ $Res call({
Object? commentId = freezed,
Object? author = freezed, Object? author = freezed,
Object? channelId = freezed, Object? channelId = freezed,
Object? text = freezed, Object? text = freezed,
@ -116,13 +95,8 @@ class _$CommentCopyWithImpl<$Res> implements $CommentCopyWith<$Res> {
Object? publishedTime = freezed, Object? publishedTime = freezed,
Object? replyCount = freezed, Object? replyCount = freezed,
Object? continuation = freezed, Object? continuation = freezed,
Object? clicktrackingParams = freezed,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
commentId: commentId == freezed
? _value.commentId
: commentId // ignore: cast_nullable_to_non_nullable
as String,
author: author == freezed author: author == freezed
? _value.author ? _value.author
: author // ignore: cast_nullable_to_non_nullable : author // ignore: cast_nullable_to_non_nullable
@ -151,10 +125,6 @@ class _$CommentCopyWithImpl<$Res> implements $CommentCopyWith<$Res> {
? _value.continuation ? _value.continuation
: continuation // ignore: cast_nullable_to_non_nullable : continuation // ignore: cast_nullable_to_non_nullable
as String?, as String?,
clicktrackingParams: clicktrackingParams == freezed
? _value.clicktrackingParams
: clicktrackingParams // ignore: cast_nullable_to_non_nullable
as String?,
)); ));
} }
@ -172,15 +142,13 @@ abstract class _$CommentCopyWith<$Res> implements $CommentCopyWith<$Res> {
__$CommentCopyWithImpl<$Res>; __$CommentCopyWithImpl<$Res>;
@override @override
$Res call( $Res call(
{String commentId, {String author,
String author,
ChannelId channelId, ChannelId channelId,
String text, String text,
int likeCount, int likeCount,
String publishedTime, String publishedTime,
int replyCount, int replyCount,
@internal String? continuation, @internal String? continuation});
@internal String? clicktrackingParams});
@override @override
$ChannelIdCopyWith<$Res> get channelId; $ChannelIdCopyWith<$Res> get channelId;
@ -197,7 +165,6 @@ class __$CommentCopyWithImpl<$Res> extends _$CommentCopyWithImpl<$Res>
@override @override
$Res call({ $Res call({
Object? commentId = freezed,
Object? author = freezed, Object? author = freezed,
Object? channelId = freezed, Object? channelId = freezed,
Object? text = freezed, Object? text = freezed,
@ -205,13 +172,8 @@ class __$CommentCopyWithImpl<$Res> extends _$CommentCopyWithImpl<$Res>
Object? publishedTime = freezed, Object? publishedTime = freezed,
Object? replyCount = freezed, Object? replyCount = freezed,
Object? continuation = freezed, Object? continuation = freezed,
Object? clicktrackingParams = freezed,
}) { }) {
return _then(_Comment( return _then(_Comment(
commentId == freezed
? _value.commentId
: commentId // ignore: cast_nullable_to_non_nullable
as String,
author == freezed author == freezed
? _value.author ? _value.author
: author // ignore: cast_nullable_to_non_nullable : author // ignore: cast_nullable_to_non_nullable
@ -240,10 +202,6 @@ class __$CommentCopyWithImpl<$Res> extends _$CommentCopyWithImpl<$Res>
? _value.continuation ? _value.continuation
: continuation // ignore: cast_nullable_to_non_nullable : continuation // ignore: cast_nullable_to_non_nullable
as String?, as String?,
clicktrackingParams == freezed
? _value.clicktrackingParams
: clicktrackingParams // ignore: cast_nullable_to_non_nullable
as String?,
)); ));
} }
} }
@ -251,23 +209,11 @@ class __$CommentCopyWithImpl<$Res> extends _$CommentCopyWithImpl<$Res>
/// @nodoc /// @nodoc
class _$_Comment implements _Comment { class _$_Comment implements _Comment {
const _$_Comment( const _$_Comment(this.author, this.channelId, this.text, this.likeCount,
this.commentId, this.publishedTime, this.replyCount, @internal this.continuation);
this.author,
this.channelId,
this.text,
this.likeCount,
this.publishedTime,
this.replyCount,
@internal this.continuation,
@internal this.clicktrackingParams);
@override @override
/// Comment id.
final String commentId;
@override
/// Comment author name. /// Comment author name.
final String author; final String author;
@override @override
@ -296,25 +242,16 @@ class _$_Comment implements _Comment {
/// Shouldn't be used in the code. /// Shouldn't be used in the code.
@internal @internal
final String? continuation; final String? continuation;
@override
/// Used internally.
/// Shouldn't be used in the code.
@internal
final String? clicktrackingParams;
@override @override
String toString() { String toString() {
return 'Comment(commentId: $commentId, author: $author, channelId: $channelId, text: $text, likeCount: $likeCount, publishedTime: $publishedTime, replyCount: $replyCount, continuation: $continuation, clicktrackingParams: $clicktrackingParams)'; return 'Comment(author: $author, channelId: $channelId, text: $text, likeCount: $likeCount, publishedTime: $publishedTime, replyCount: $replyCount, continuation: $continuation)';
} }
@override @override
bool operator ==(dynamic other) { bool operator ==(dynamic other) {
return identical(this, other) || return identical(this, other) ||
(other is _Comment && (other is _Comment &&
(identical(other.commentId, commentId) ||
const DeepCollectionEquality()
.equals(other.commentId, commentId)) &&
(identical(other.author, author) || (identical(other.author, author) ||
const DeepCollectionEquality().equals(other.author, author)) && const DeepCollectionEquality().equals(other.author, author)) &&
(identical(other.channelId, channelId) || (identical(other.channelId, channelId) ||
@ -333,24 +270,19 @@ class _$_Comment implements _Comment {
.equals(other.replyCount, replyCount)) && .equals(other.replyCount, replyCount)) &&
(identical(other.continuation, continuation) || (identical(other.continuation, continuation) ||
const DeepCollectionEquality() const DeepCollectionEquality()
.equals(other.continuation, continuation)) && .equals(other.continuation, continuation)));
(identical(other.clicktrackingParams, clicktrackingParams) ||
const DeepCollectionEquality()
.equals(other.clicktrackingParams, clicktrackingParams)));
} }
@override @override
int get hashCode => int get hashCode =>
runtimeType.hashCode ^ runtimeType.hashCode ^
const DeepCollectionEquality().hash(commentId) ^
const DeepCollectionEquality().hash(author) ^ const DeepCollectionEquality().hash(author) ^
const DeepCollectionEquality().hash(channelId) ^ const DeepCollectionEquality().hash(channelId) ^
const DeepCollectionEquality().hash(text) ^ const DeepCollectionEquality().hash(text) ^
const DeepCollectionEquality().hash(likeCount) ^ const DeepCollectionEquality().hash(likeCount) ^
const DeepCollectionEquality().hash(publishedTime) ^ const DeepCollectionEquality().hash(publishedTime) ^
const DeepCollectionEquality().hash(replyCount) ^ const DeepCollectionEquality().hash(replyCount) ^
const DeepCollectionEquality().hash(continuation) ^ const DeepCollectionEquality().hash(continuation);
const DeepCollectionEquality().hash(clicktrackingParams);
@JsonKey(ignore: true) @JsonKey(ignore: true)
@override @override
@ -360,22 +292,16 @@ class _$_Comment implements _Comment {
abstract class _Comment implements Comment { abstract class _Comment implements Comment {
const factory _Comment( const factory _Comment(
String commentId,
String author, String author,
ChannelId channelId, ChannelId channelId,
String text, String text,
int likeCount, int likeCount,
String publishedTime, String publishedTime,
int replyCount, int replyCount,
@internal String? continuation, @internal String? continuation) = _$_Comment;
@internal String? clicktrackingParams) = _$_Comment;
@override @override
/// Comment id.
String get commentId => throw _privateConstructorUsedError;
@override
/// Comment author name. /// Comment author name.
String get author => throw _privateConstructorUsedError; String get author => throw _privateConstructorUsedError;
@override @override
@ -405,12 +331,6 @@ abstract class _Comment implements Comment {
@internal @internal
String? get continuation => throw _privateConstructorUsedError; String? get continuation => throw _privateConstructorUsedError;
@override @override
/// Used internally.
/// Shouldn't be used in the code.
@internal
String? get clicktrackingParams => throw _privateConstructorUsedError;
@override
@JsonKey(ignore: true) @JsonKey(ignore: true)
_$CommentCopyWith<_Comment> get copyWith => _$CommentCopyWith<_Comment> get copyWith =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;

View File

@ -1,47 +1,20 @@
import 'dart:convert'; import 'package:freezed_annotation/freezed_annotation.dart';
import '../../channels/channel_id.dart'; import '../../channels/channel_id.dart';
import '../../extensions/helpers_extension.dart'; import '../../extensions/helpers_extension.dart';
import '../../retry.dart'; 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';
/// 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 the json parsed comments map.
Future<Map<String, dynamic>> _getCommentJson(
String continuation,
String clickTrackingParams,
String xsfrToken,
String visitorInfoLive,
String ysc) async {
final url = Uri(
scheme: 'https',
host: 'www.youtube.com',
path: '/next',
queryParameters: {
'key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
});
return retry(() async {
var raw = await _httpClient.postString(url, headers: {
'x-youtube-client-name': '1',
'x-youtube-client-version': '2.20210622.10.00',
'cookie':
'YSC=$ysc; CONSENT=YES+cb; GPS=1; VISITOR_INFO1_LIVE=$visitorInfoLive',
}, body: {
'session_token': xsfrToken
});
return json.decode(raw);
});
}
/// Returns a stream emitting all the [video]'s comment. /// Returns a stream emitting all the [video]'s comment.
/// A request is page for every comment page, /// A request is page for every comment page,
/// a page contains at most 20 comments, use .take if you want to limit /// a page contains at most 20 comments, use .take if you want to limit
@ -50,98 +23,23 @@ class CommentsClient {
/// The streams doesn't emit any data if [Video.hasWatchPage] is false. /// The streams doesn't emit any data if [Video.hasWatchPage] is false.
/// Use `videos.get(videoId, forceWatchPage: true)` to assure that the /// Use `videos.get(videoId, forceWatchPage: true)` to assure that the
/// WatchPage is fetched. /// WatchPage is fetched.
Stream<Comment> getComments(Video video) async* { Future<List<Comment>> getComments(Video video) async {
if (video.watchPage == null) { if (video.watchPage == null) {
return; return const [];
} }
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, final page = await re.CommentsClient.get(_httpClient, video);
String xsfrToken, String visitorInfoLive, String ysc) async* {
// contents.twoColumnWatchNextResults.results.results.contents[2](firstWhere itemSectionRenderer != null).itemSectionRenderer.contents[0].continuationItemRenderer
var data = await _getCommentJson(
continuation, clickTrackingParams, xsfrToken, visitorInfoLive, ysc);
var contentRoot = data
.get('response')
?.get('continuationContents')
?.get('itemSectionContinuation')
?.getT<List<dynamic>>('contents')
?.map((e) => e['commentThreadRenderer'])
.toList()
.cast<Map<String, dynamic>>();
if (contentRoot == null) {
return;
}
for (final content in contentRoot) {
var commentRaw = content.get('comment')!.get('commentRenderer')!;
String? continuation;
String? clickTrackingParams;
final replies = content.get('replies');
if (replies != null) {
final continuationData = replies
.get('commentRepliesRenderer')!
.getList('continuations')!
.first
.get('nextContinuationData')!;
continuation = continuationData.getT<String>('continuation'); return page?.comments
clickTrackingParams = .map((e) => Comment(
continuationData.getT<String>('clickTrackingParams'); e.author,
} ChannelId(e.channelId),
yield Comment( e.text,
commentRaw.getT<String>('commentId')!, e.likeCount ?? 0,
commentRaw.get('authorText')!.getT<String>('simpleText')!, e.publishTime,
ChannelId(commentRaw e.repliesCount ?? 0,
.get('authorEndpoint')! e.continuation))
.get('browseEndpoint')! .toList(growable: false) ??
.getT<String>('browseId')!), const [];
commentRaw
.get('contentText')!
.getT<List<dynamic>>('runs')!
.parseRuns(),
commentRaw.get('voteCount')?.getT<String>('simpleText')?.parseInt() ??
commentRaw
.get('voteCount')
?.getT<List<dynamic>>('runs')
?.parseRuns()
.parseInt() ??
0,
commentRaw
.get('publishedTimeText')!
.getT<List<dynamic>>('runs')!
.parseRuns(),
commentRaw.getT<int>('replyCount') ?? 0,
continuation,
clickTrackingParams);
}
var continuationRoot = (data
.get('response')
?.get('continuationContents')
?.get('itemSectionContinuation')
?.getT<List<dynamic>>('continuations')
?.first)
?.get('nextContinuationData');
if (continuationRoot != null) {
yield* _getComments(
continuationRoot['continuation'],
continuationRoot['clickTrackingParams'],
xsfrToken,
visitorInfoLive,
ysc);
}
}
Stream<Comment> getReplies(Video video, Comment comment) async* {
if (video.watchPage == null ||
comment.continuation == null ||
comment.clicktrackingParams == null) {
return;
}
} }
} }

View File

@ -1,13 +1,13 @@
import '../../exceptions/exceptions.dart'; import '../../exceptions/exceptions.dart';
import '../../extensions/helpers_extension.dart'; import '../../extensions/helpers_extension.dart';
import '../../reverse_engineering/cipher/cipher_operations.dart'; import '../../reverse_engineering/cipher/cipher_operations.dart';
import '../../reverse_engineering/dash_manifest.dart';
import '../../reverse_engineering/heuristics.dart'; import '../../reverse_engineering/heuristics.dart';
import '../../reverse_engineering/models/stream_info_provider.dart';
import '../../reverse_engineering/pages/embed_page.dart'; import '../../reverse_engineering/pages/embed_page.dart';
import '../../reverse_engineering/pages/watch_page.dart'; import '../../reverse_engineering/pages/watch_page.dart';
import '../../reverse_engineering/dash_manifest.dart';
import '../../reverse_engineering/player/player_source.dart'; import '../../reverse_engineering/player/player_source.dart';
import '../../reverse_engineering/models/stream_info_provider.dart'; import '../../reverse_engineering/responses/video_info_client.dart';
import '../../reverse_engineering/responses/video_info_response.dart';
import '../../reverse_engineering/youtube_http_client.dart'; import '../../reverse_engineering/youtube_http_client.dart';
import '../video_id.dart'; import '../video_id.dart';
import 'bitrate.dart'; import 'bitrate.dart';
@ -48,7 +48,7 @@ class StreamsClient {
_httpClient, embedPage.sourceUrl ?? playerConfig.sourceUrl); _httpClient, embedPage.sourceUrl ?? playerConfig.sourceUrl);
var cipherOperations = playerSource.getCipherOperations(); var cipherOperations = playerSource.getCipherOperations();
var videoInfoResponse = await VideoInfoResponse.get( var videoInfoResponse = await VideoInfoClient.get(
_httpClient, videoId.toString(), playerSource.sts); _httpClient, videoId.toString(), playerSource.sts);
var playerResponse = videoInfoResponse.playerResponse; var playerResponse = videoInfoResponse.playerResponse;
@ -224,21 +224,9 @@ class StreamsClient {
/// about available streams in the specified video. /// about available streams in the specified video.
Future<StreamManifest> getManifest(dynamic videoId) async { Future<StreamManifest> getManifest(dynamic videoId) async {
videoId = VideoId.fromString(videoId); videoId = VideoId.fromString(videoId);
// We can try to extract the manifest from two sources:
// get_video_info and the video watch page.
// In some cases one works, in some cases another does.
try { var context = await _getStreamContextFromWatchPage(videoId);
var context = await _getStreamContextFromVideoInfo(videoId); return _getManifest(context);
return _getManifest(context);
} on YoutubeExplodeException catch (e) {
try {
var context = await _getStreamContextFromWatchPage(videoId);
return _getManifest(context);
} on YoutubeExplodeException catch (e1) {
throw e..combine(e1);
}
}
} }
/// Gets the HTTP Live Stream (HLS) manifest URL /// Gets the HTTP Live Stream (HLS) manifest URL

View File

@ -1,8 +1,9 @@
import 'package:youtube_explode_dart/src/reverse_engineering/player/player_response.dart';
import '../channels/channel_id.dart'; import '../channels/channel_id.dart';
import '../common/common.dart'; import '../common/common.dart';
import '../extensions/helpers_extension.dart'; import '../extensions/helpers_extension.dart';
import '../reverse_engineering/pages/watch_page.dart'; import '../reverse_engineering/pages/watch_page.dart';
import '../reverse_engineering/responses/video_info_response.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 'comments/comments_client.dart';
@ -29,11 +30,9 @@ class VideoClient {
/// Gets the metadata associated with the specified video. /// Gets the metadata associated with the specified video.
Future<Video> _getVideoFromWatchPage(VideoId videoId) async { Future<Video> _getVideoFromWatchPage(VideoId videoId) async {
var videoInfoResponse =
await VideoInfoResponse.get(_httpClient, videoId.value);
var playerResponse = videoInfoResponse.playerResponse;
var watchPage = await WatchPage.get(_httpClient, videoId.value); var watchPage = await WatchPage.get(_httpClient, videoId.value);
final playerResponse = watchPage.playerResponse!;
return Video( return Video(
videoId, videoId,
playerResponse.videoTitle, playerResponse.videoTitle,

View File

@ -1,6 +1,6 @@
name: youtube_explode_dart name: youtube_explode_dart
description: A port in dart of the youtube explode library. Supports several API functions without the need of Youtube API Key. description: A port in dart of the youtube explode library. Supports several API functions without the need of Youtube API Key.
version: 1.9.10 version: 1.10.0
homepage: https://github.com/Hexer10/youtube_explode_dart homepage: https://github.com/Hexer10/youtube_explode_dart

View File

@ -14,7 +14,7 @@ void main() {
test('Get comments of a video', () async { test('Get comments of a video', () async {
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).toList(); var comments = await yt!.videos.commentsClient.getComments(video);
expect(comments.length, greaterThanOrEqualTo(1)); expect(comments.length, greaterThanOrEqualTo(1));
}); });
} }