commit
1b9901840e
|
@ -1,3 +1,12 @@
|
|||
## 1.9.0
|
||||
- Support nnbd (dart 1.12)
|
||||
- New api: `getQuerySuggestions`: Returns the suggestions youtube provides while making a video search.
|
||||
- Now playlists with more than 100 videos return all the videos. Thanks to @ATiltedTree.
|
||||
- Implemented `ChannelAboutPage`, check the tests their usage.
|
||||
- Implement filters for `search.getVideos`. See `filter` getter.
|
||||
- Now video's from search queries return the channel id.
|
||||
- Implemented publishDate for videos. Thanks to @mymikemiller , PR: #115.I t
|
||||
|
||||
## 1.8.0
|
||||
- Fixed playlist client.
|
||||
- Fixed search client.
|
||||
|
|
|
@ -1,69 +1,13 @@
|
|||
include: package:effective_dart/analysis_options.yaml
|
||||
include: package:lint/analysis_options.yaml
|
||||
|
||||
analyzer:
|
||||
exclude: #most likely not all of these are needed, but as it is now it works.
|
||||
- "**/*.g.dart"
|
||||
- /**/*.g.dart
|
||||
- \**\*.g.dart
|
||||
- "*.g.dart"
|
||||
- "**.g.dart"
|
||||
- example\**
|
||||
- lib\src\reverse_engineering\responses\generated\**
|
||||
strong-mode:
|
||||
implicit-casts: true
|
||||
implicit-dynamic: true
|
||||
|
||||
linter:
|
||||
rules:
|
||||
- valid_regexps
|
||||
- prefer_const_constructors
|
||||
- prefer_const_declarations
|
||||
- prefer_const_literals_to_create_immutables
|
||||
- prefer_constructors_over_static_methods
|
||||
- prefer_contains
|
||||
- annotate_overrides
|
||||
- await_only_futures
|
||||
- unawaited_futures
|
||||
- avoid_empty_else
|
||||
- avoid_returning_null_for_future
|
||||
- avoid_types_as_parameter_names
|
||||
- control_flow_in_finally
|
||||
- empty_statements
|
||||
- invariant_booleans
|
||||
- iterable_contains_unrelated_type
|
||||
- list_remove_unrelated_type
|
||||
- literal_only_boolean_expressions
|
||||
- no_adjacent_strings_in_list
|
||||
- no_duplicate_case_values
|
||||
- prefer_void_to_null
|
||||
- test_types_in_equals
|
||||
- throw_in_finally
|
||||
- unnecessary_statements
|
||||
- unrelated_type_equality_checks
|
||||
- always_declare_return_types
|
||||
- always_put_control_body_on_new_line
|
||||
- avoid_returning_null_for_void
|
||||
- avoid_setters_without_getters
|
||||
- avoid_shadowing_type_parameters
|
||||
- avoid_unnecessary_containers
|
||||
- avoid_void_async
|
||||
- empty_catches
|
||||
- null_closures
|
||||
- prefer_conditional_assignment
|
||||
- prefer_if_null_operators
|
||||
- prefer_is_empty
|
||||
- prefer_is_not_empty
|
||||
- prefer_is_not_operator
|
||||
- prefer_null_aware_operators
|
||||
- recursive_getters
|
||||
- unnecessary_await_in_return
|
||||
- unnecessary_null_aware_assignments
|
||||
- unnecessary_null_in_if_null_operators
|
||||
- unnecessary_overrides
|
||||
- unnecessary_parenthesis
|
||||
- unnecessary_raw_strings
|
||||
- unnecessary_string_escapes
|
||||
- unnecessary_string_interpolations
|
||||
- use_string_buffers
|
||||
- void_checks
|
||||
- package_names
|
||||
- prefer_single_quotes
|
||||
- use_function_type_syntax_for_parameters
|
||||
|
||||
prefer_final_locals: false
|
||||
parameter_assignments: false
|
||||
no_runtimetype_tostring: false
|
||||
avoid_escaping_inner_quotes: false
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
// ignore_for_file: avoid_print
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
import 'package:youtube_explode_dart/src/youtube_explode_base.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
var yt = YoutubeExplode();
|
||||
var video =
|
||||
await yt.videos.get('https://www.youtube.com/watch?v=AI7ULzgf8RU');
|
||||
|
||||
var manifest = await yt.videos.closedCaptions
|
||||
.getManifest('Pxgvgh9IFqA', autoGenerated: true);
|
||||
print(manifest.tracks);
|
||||
print('\n\n---------------------\n\n');
|
||||
print('Title: ${video.title}');
|
||||
|
||||
manifest = await yt.videos.closedCaptions
|
||||
.getManifest('Pxgvgh9IFqA', autoGenerated: false);
|
||||
print(manifest.tracks);
|
||||
// Close the YoutubeExplode's http client.
|
||||
yt.close();
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ final yt = YoutubeExplode();
|
|||
Future<void> main() async {
|
||||
stdout.writeln('Type the video id or url: ');
|
||||
|
||||
var url = stdin.readLineSync().trim();
|
||||
var url = stdin.readLineSync()!.trim();
|
||||
|
||||
// Save the video to the download directory.
|
||||
Directory('downloads').createSync();
|
||||
|
@ -66,7 +66,7 @@ Future<void> download(String id) async {
|
|||
|
||||
// Listen for data received.
|
||||
var progressBar = ProgressBar();
|
||||
await for (var data in audioStream) {
|
||||
await for (final data in audioStream) {
|
||||
// Keep track of the current downloaded data.
|
||||
count += data.length;
|
||||
|
||||
|
|
|
@ -46,12 +46,11 @@ class ChannelClient {
|
|||
/// Gets the info found on a YouTube Channel About page.
|
||||
/// [id] must be either a [ChannelId] or a string
|
||||
/// which is parsed to a [ChannelId]
|
||||
Future<ChannelAbout> getAboutPage(dynamic id) async {
|
||||
id = ChannelId.fromString(id);
|
||||
Future<ChannelAbout> getAboutPage(dynamic channelId) async {
|
||||
channelId = ChannelId.fromString(channelId);
|
||||
|
||||
var channelAboutPage = await ChannelAboutPage.get(_httpClient, id.value);
|
||||
var iData = channelAboutPage.initialData;
|
||||
assert(iData != null);
|
||||
final aboutPage = await ChannelAboutPage.get(_httpClient, channelId.value);
|
||||
final id = aboutPage.initialData;
|
||||
return ChannelAbout(
|
||||
id.description,
|
||||
id.viewCount,
|
||||
|
@ -59,7 +58,7 @@ class ChannelClient {
|
|||
id.title,
|
||||
[
|
||||
for (var e in id.avatar)
|
||||
Thumbnail(Uri.parse(e.url), e.height, e.width)
|
||||
Thumbnail(Uri.parse(e['url']), e['height'], e['width'])
|
||||
],
|
||||
id.country,
|
||||
id.channelLinks);
|
||||
|
@ -74,7 +73,6 @@ class ChannelClient {
|
|||
var channelAboutPage =
|
||||
await ChannelAboutPage.getByUsername(_httpClient, username.value);
|
||||
var id = channelAboutPage.initialData;
|
||||
assert(id != null);
|
||||
return ChannelAbout(
|
||||
id.description,
|
||||
id.viewCount,
|
||||
|
@ -82,7 +80,7 @@ class ChannelClient {
|
|||
id.title,
|
||||
[
|
||||
for (var e in id.avatar)
|
||||
Thumbnail(Uri.parse(e.url), e.height, e.width)
|
||||
Thumbnail(Uri.parse(e['url']), e['height'], e['width'])
|
||||
],
|
||||
id.country,
|
||||
id.channelLinks);
|
||||
|
@ -118,13 +116,13 @@ class ChannelClient {
|
|||
Stream<ChannelVideo> getUploadsFromPage(dynamic channelId,
|
||||
[VideoSorting videoSorting = VideoSorting.newest]) async* {
|
||||
channelId = ChannelId.fromString(channelId);
|
||||
var page = await ChannelUploadPage.get(
|
||||
_httpClient, channelId.value, videoSorting.code);
|
||||
ChannelUploadPage? page = await ChannelUploadPage.get(
|
||||
_httpClient, (channelId as ChannelId).value, videoSorting.code);
|
||||
yield* Stream.fromIterable(page.initialData.uploads);
|
||||
|
||||
// ignore: literal_only_boolean_expressions
|
||||
while (true) {
|
||||
page = await page.nextPage(_httpClient);
|
||||
page = await page!.nextPage(_httpClient);
|
||||
if (page == null) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -8,9 +8,9 @@ class ChannelId with EquatableMixin {
|
|||
final String value;
|
||||
|
||||
/// Initializes an instance of [ChannelId]
|
||||
ChannelId(String value) : value = parseChannelId(value) {
|
||||
if (this.value == null) {
|
||||
throw ArgumentError.value(value, 'value', 'Invalid channel id');
|
||||
ChannelId(String value) : value = parseChannelId(value) ?? '' {
|
||||
if (this.value.isEmpty) {
|
||||
throw ArgumentError.value(value);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -33,8 +33,8 @@ class ChannelId with EquatableMixin {
|
|||
|
||||
/// Parses a channel id from an url.
|
||||
/// Returns null if the username is not found.
|
||||
static String parseChannelId(String url) {
|
||||
if (url.isNullOrWhiteSpace) {
|
||||
static String? parseChannelId(String url) {
|
||||
if (url.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -45,7 +45,7 @@ class ChannelId with EquatableMixin {
|
|||
var regMatch = RegExp(r'youtube\..+?/channel/(.*?)(?:\?|&|/|$)')
|
||||
.firstMatch(url)
|
||||
?.group(1);
|
||||
if (!regMatch.isNullOrWhiteSpace && validateChannelId(regMatch)) {
|
||||
if (!regMatch.isNullOrWhiteSpace && validateChannelId(regMatch!)) {
|
||||
return regMatch;
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -6,8 +6,8 @@ class Username {
|
|||
final String value;
|
||||
|
||||
/// Initializes an instance of [Username].
|
||||
Username(String urlOrUsername) : value = parseUsername(urlOrUsername) {
|
||||
if (value == null) {
|
||||
Username(String urlOrUsername) : value = parseUsername(urlOrUsername) ?? '' {
|
||||
if (value.isEmpty) {
|
||||
throw ArgumentError.value(
|
||||
urlOrUsername, 'urlOrUsername', 'Invalid username');
|
||||
}
|
||||
|
@ -27,8 +27,8 @@ class Username {
|
|||
}
|
||||
|
||||
/// Parses a username from a url.
|
||||
static String parseUsername(String nameOrUrl) {
|
||||
if (nameOrUrl.isNullOrWhiteSpace) {
|
||||
static String? parseUsername(String nameOrUrl) {
|
||||
if (nameOrUrl.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -39,7 +39,7 @@ class Username {
|
|||
var regMatch = RegExp(r'youtube\..+?/user/(.*?)(?:\?|&|/|$)')
|
||||
.firstMatch(nameOrUrl)
|
||||
?.group(1);
|
||||
if (!regMatch.isNullOrWhiteSpace && validateUsername(regMatch)) {
|
||||
if (!regMatch.isNullOrWhiteSpace && validateUsername(regMatch!)) {
|
||||
return regMatch;
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -6,20 +6,24 @@ class Engagement extends Equatable {
|
|||
final int viewCount;
|
||||
|
||||
/// Like count.
|
||||
final int likeCount;
|
||||
final int? likeCount;
|
||||
|
||||
/// Dislike count.
|
||||
final int dislikeCount;
|
||||
final int? dislikeCount;
|
||||
|
||||
/// Initializes an instance of [Statistics]
|
||||
/// Initializes an instance of [Engagement]
|
||||
const Engagement(this.viewCount, this.likeCount, this.dislikeCount);
|
||||
|
||||
/// Average user rating in stars (1 star to 5 stars).
|
||||
/// Returns -1 if likeCount or dislikeCount is null.
|
||||
num get avgRating {
|
||||
if (likeCount + dislikeCount == 0) {
|
||||
if (likeCount == null || dislikeCount == null) {
|
||||
return -1;
|
||||
}
|
||||
if (likeCount! + dislikeCount! == 0) {
|
||||
return 0;
|
||||
}
|
||||
return 1 + 4.0 * likeCount / (likeCount + dislikeCount);
|
||||
return 1 + 4.0 * likeCount! / (likeCount! + dislikeCount!);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -27,5 +31,5 @@ class Engagement extends Equatable {
|
|||
'$viewCount views, $likeCount likes, $dislikeCount dislikes';
|
||||
|
||||
@override
|
||||
List<Object> get props => [viewCount, likeCount, dislikeCount];
|
||||
List<Object?> get props => [viewCount, likeCount, dislikeCount];
|
||||
}
|
||||
|
|
|
@ -22,5 +22,6 @@ Response: (${response.statusCode})
|
|||
''';
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message';
|
||||
String toString() =>
|
||||
'$runtimeType: $message'; // ignore: no_runtimetype_tostring
|
||||
}
|
||||
|
|
|
@ -23,5 +23,6 @@ Response: $response
|
|||
''';
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message';
|
||||
String toString() =>
|
||||
'$runtimeType: $message'; // ignore: no_runtimetype_tostring
|
||||
}
|
||||
|
|
|
@ -22,5 +22,6 @@ Response: $response
|
|||
''';
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message';
|
||||
String toString() =>
|
||||
'$runtimeType: $message'; // ignore: no_runtimetype_tostring
|
||||
}
|
||||
|
|
|
@ -12,10 +12,12 @@ class VideoRequiresPurchaseException implements VideoUnplayableException {
|
|||
|
||||
/// Initializes an instance of [VideoRequiresPurchaseException].
|
||||
VideoRequiresPurchaseException.preview(VideoId videoId, this.previewVideoId)
|
||||
: message = 'Video `$videoId` is unplayable because it requires purchase.'
|
||||
'Streams are not available for this video.'
|
||||
'There is a preview video available: `$previewVideoId`.';
|
||||
: message =
|
||||
'Video `$videoId` is unplayable because it requires purchase.\n'
|
||||
'Streams are not available for this video.\n'
|
||||
'There is a preview video available: `$previewVideoId`.';
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message';
|
||||
String toString() =>
|
||||
'$runtimeType: $message'; // ignore: no_runtimetype_tostring
|
||||
}
|
||||
|
|
|
@ -22,5 +22,6 @@ class VideoUnavailableException implements VideoUnplayableException {
|
|||
'Please report this issue on GitHub in that case.';
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message';
|
||||
String toString() =>
|
||||
'$runtimeType: $message'; // ignore: no_runtimetype_tostring
|
||||
}
|
||||
|
|
|
@ -30,5 +30,7 @@ class VideoUnplayableException implements YoutubeExplodeException {
|
|||
'Live stream manifest is not available for this video';
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message';
|
||||
// ignore:
|
||||
String toString() =>
|
||||
'$runtimeType: $message'; // ignore: no_runtimetype_tostring
|
||||
}
|
||||
|
|
|
@ -1,26 +1,14 @@
|
|||
library _youtube_explode.extensions;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../reverse_engineering/cipher/cipher_operations.dart';
|
||||
|
||||
/// Utility for Strings.
|
||||
extension StringUtility on String {
|
||||
/// Parses this value as int stripping the non digit characters,
|
||||
/// returns null if this fails.
|
||||
int parseInt() => int.tryParse(this?.stripNonDigits());
|
||||
|
||||
/// Returns null if this string is whitespace.
|
||||
String get nullIfWhitespace => trim().isEmpty ? null : this;
|
||||
|
||||
/// Returns true if the string is null or empty.
|
||||
bool get isNullOrWhiteSpace {
|
||||
if (this == null) {
|
||||
return true;
|
||||
}
|
||||
if (trim().isEmpty) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
String? get nullIfWhitespace => trim().isEmpty ? null : this;
|
||||
|
||||
/// Returns null if this string is a whitespace.
|
||||
String substringUntil(String separator) => substring(0, indexOf(separator));
|
||||
|
@ -34,28 +22,115 @@ extension StringUtility on String {
|
|||
/// Strips out all non digit characters.
|
||||
String stripNonDigits() => replaceAll(_exp, '');
|
||||
|
||||
///
|
||||
String extractJson() {
|
||||
var buffer = StringBuffer();
|
||||
var depth = 0;
|
||||
/// Extract and decode json from a string
|
||||
Map<String, dynamic>? extractJson([String separator = '']) {
|
||||
final index = indexOf(separator) + separator.length;
|
||||
if (index > length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (var i = 0; i < length; i++) {
|
||||
var ch = this[i];
|
||||
var chPrv = i > 0 ? this[i - 1] : '';
|
||||
final str = substring(index);
|
||||
|
||||
buffer.write(ch);
|
||||
final startIdx = str.indexOf('{');
|
||||
var endIdx = str.lastIndexOf('}');
|
||||
|
||||
if (ch == '{' && chPrv != '\\') {
|
||||
depth++;
|
||||
} else if (ch == '}' && chPrv != '\\') {
|
||||
depth--;
|
||||
}
|
||||
|
||||
if (depth == 0) {
|
||||
break;
|
||||
while (true) {
|
||||
try {
|
||||
return json.decode(str.substring(startIdx, endIdx + 1))
|
||||
as Map<String, dynamic>;
|
||||
} on FormatException {
|
||||
endIdx = str.lastIndexOf(str.substring(0, endIdx));
|
||||
if (endIdx == 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Format: HH:MM:SS
|
||||
Duration? toDuration() {
|
||||
if (/*string == null ||*/ trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = split(':');
|
||||
assert(parts.length <= 3);
|
||||
|
||||
if (parts.length == 1) {
|
||||
return Duration(seconds: int.parse(parts.first));
|
||||
}
|
||||
if (parts.length == 2) {
|
||||
return Duration(
|
||||
minutes: int.parse(parts[0]), seconds: int.parse(parts[1]));
|
||||
}
|
||||
if (parts.length == 3) {
|
||||
return Duration(
|
||||
hours: int.parse(parts[0]),
|
||||
minutes: int.parse(parts[1]),
|
||||
seconds: int.parse(parts[2]));
|
||||
}
|
||||
// Shouldn't reach here.
|
||||
throw Error();
|
||||
}
|
||||
|
||||
DateTime parseDateTime() => DateTime.parse(this);
|
||||
}
|
||||
|
||||
/// Utility for Strings.
|
||||
extension StringUtility2 on String? {
|
||||
/// Parses this value as int stripping the non digit characters,
|
||||
/// returns null if this fails.
|
||||
int? parseInt() => int.tryParse(this?.stripNonDigits() ?? '');
|
||||
|
||||
/// Returns true if the string is null or empty.
|
||||
bool get isNullOrWhiteSpace {
|
||||
if (this == null) {
|
||||
return true;
|
||||
}
|
||||
if (this!.trim().isEmpty) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Format: <quantity> <unit> ago (5 years ago)
|
||||
DateTime? toDateTime() {
|
||||
if (this == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = this!.split(' ');
|
||||
if (parts.length == 4) {
|
||||
// Streamed x y ago
|
||||
parts = parts.skip(1).toList();
|
||||
}
|
||||
assert(parts.length == 3);
|
||||
|
||||
var qty = int.parse(parts.first);
|
||||
|
||||
// Try to get the unit
|
||||
var unit = parts[1];
|
||||
Duration time;
|
||||
if (unit.startsWith('second')) {
|
||||
time = Duration(seconds: qty);
|
||||
} else if (unit.startsWith('minute')) {
|
||||
time = Duration(minutes: qty);
|
||||
} else if (unit.startsWith('hour')) {
|
||||
time = Duration(hours: qty);
|
||||
} else if (unit.startsWith('day')) {
|
||||
time = Duration(days: qty);
|
||||
} else if (unit.startsWith('week')) {
|
||||
time = Duration(days: qty * 7);
|
||||
} else if (unit.startsWith('month')) {
|
||||
time = Duration(days: qty * 30);
|
||||
} else if (unit.startsWith('year')) {
|
||||
time = Duration(days: qty * 365);
|
||||
} else {
|
||||
throw StateError('Couldn\'t parse $unit unit of time. '
|
||||
'Please report this to the project page!');
|
||||
}
|
||||
|
||||
return DateTime.now().subtract(time);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -63,7 +138,7 @@ extension StringUtility on String {
|
|||
extension ListDecipher on Iterable<CipherOperation> {
|
||||
/// Apply every CipherOperation on the [signature]
|
||||
String decipher(String signature) {
|
||||
for (var operation in this) {
|
||||
for (final operation in this) {
|
||||
signature = operation.decipher(signature);
|
||||
}
|
||||
|
||||
|
@ -73,17 +148,9 @@ extension ListDecipher on Iterable<CipherOperation> {
|
|||
|
||||
/// List Utility.
|
||||
extension ListUtil<E> on Iterable<E> {
|
||||
/// Returns the first element of a list or null if empty.
|
||||
E get firstOrNull {
|
||||
if (length == 0) {
|
||||
return null;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/// Same as [elementAt] but if the index is higher than the length returns
|
||||
/// null
|
||||
E elementAtSafe(int index) {
|
||||
E? elementAtSafe(int index) {
|
||||
if (index >= length) {
|
||||
return null;
|
||||
}
|
||||
|
@ -106,7 +173,7 @@ extension UriUtility on Uri {
|
|||
///
|
||||
extension GetOrNull<K, V> on Map<K, V> {
|
||||
/// Get a value from a map
|
||||
V getValue(K key) {
|
||||
V? getValue(K key) {
|
||||
var v = this[key];
|
||||
if (v == null) {
|
||||
return null;
|
||||
|
@ -118,7 +185,7 @@ extension GetOrNull<K, V> on Map<K, V> {
|
|||
///
|
||||
extension GetOrNullMap on Map {
|
||||
/// Get a map inside a map
|
||||
Map<String, dynamic> get(String key) {
|
||||
Map<String, dynamic>? get(String key) {
|
||||
var v = this[key];
|
||||
if (v == null) {
|
||||
return null;
|
||||
|
@ -128,7 +195,7 @@ extension GetOrNullMap on Map {
|
|||
|
||||
/// Get a value inside a map.
|
||||
/// If it is null this returns null, if of another type this throws.
|
||||
T getT<T>(String key) {
|
||||
T? getT<T>(String key) {
|
||||
var v = this[key];
|
||||
if (v == null) {
|
||||
return null;
|
||||
|
@ -140,7 +207,7 @@ extension GetOrNullMap on Map {
|
|||
}
|
||||
|
||||
/// Get a List<Map<String, dynamic>>> from a map.
|
||||
List<Map<String, dynamic>> getList(String key) {
|
||||
List<Map<String, dynamic>>? getList(String key) {
|
||||
var v = this[key];
|
||||
if (v == null) {
|
||||
return null;
|
||||
|
@ -149,7 +216,7 @@ extension GetOrNullMap on Map {
|
|||
throw Exception('Invalid type: ${v.runtimeType} should be of type List');
|
||||
}
|
||||
|
||||
return (v.toList() as List<dynamic>).cast<Map<String, dynamic>>();
|
||||
return (v.toList()).cast<Map<String, dynamic>>();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -164,7 +231,26 @@ extension UriUtils on Uri {
|
|||
}
|
||||
}
|
||||
|
||||
/// Parse properties with `runs` method.
|
||||
/// Parse properties with `text` method.
|
||||
extension RunsParser on List<dynamic> {
|
||||
String parseRuns() => this?.map((e) => e['text'])?.join() ?? '';
|
||||
///
|
||||
String parseRuns() => map((e) => e['text']).join();
|
||||
}
|
||||
|
||||
extension GenericExtract on List<String> {
|
||||
/// Used to extract initial data that start with `var ytInitialData = ` or 'window["ytInitialData"] ='.
|
||||
T extractGenericData<T>(
|
||||
T Function(Map<String, dynamic>) builder, Exception Function() orThrow) {
|
||||
var initialData =
|
||||
firstWhereOrNull((e) => e.contains('var ytInitialData = '))
|
||||
?.extractJson('var ytInitialData = ');
|
||||
initialData ??=
|
||||
firstWhereOrNull((e) => e.contains('window["ytInitialData"] ='))
|
||||
?.extractJson('window["ytInitialData"] =');
|
||||
|
||||
if (initialData != null) {
|
||||
return builder(initialData);
|
||||
}
|
||||
throw orThrow();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,9 +22,9 @@ class PlaylistClient {
|
|||
var response = await PlaylistPage.get(_httpClient, id.value);
|
||||
return Playlist(
|
||||
id,
|
||||
response.initialData.title,
|
||||
response.initialData.author,
|
||||
response.initialData.description,
|
||||
response.initialData.title ?? '',
|
||||
response.initialData.author ?? '',
|
||||
response.initialData.description ?? '',
|
||||
ThumbnailSet(id.value),
|
||||
Engagement(response.initialData.viewCount ?? 0, null, null));
|
||||
}
|
||||
|
@ -33,14 +33,14 @@ class PlaylistClient {
|
|||
Stream<Video> getVideos(dynamic id) async* {
|
||||
id = PlaylistId.fromString(id);
|
||||
var encounteredVideoIds = <String>{};
|
||||
var continuationToken = '';
|
||||
String? continuationToken = '';
|
||||
|
||||
// ignore: literal_only_boolean_expressions
|
||||
while (true) {
|
||||
var response = await PlaylistPage.get(_httpClient, id.value,
|
||||
token: continuationToken);
|
||||
|
||||
for (var video in response.initialData.playlistVideos) {
|
||||
for (final video in response.initialData.playlistVideos) {
|
||||
var videoId = video.id;
|
||||
|
||||
// Already added
|
||||
|
@ -58,12 +58,13 @@ class PlaylistClient {
|
|||
video.author,
|
||||
ChannelId(video.channelId),
|
||||
null,
|
||||
null,
|
||||
video.description,
|
||||
video.duration,
|
||||
ThumbnailSet(videoId),
|
||||
null,
|
||||
Engagement(video.viewCount, null, null),
|
||||
null);
|
||||
false);
|
||||
}
|
||||
continuationToken = response.initialData.continuationToken;
|
||||
if (response.initialData.continuationToken?.isEmpty ?? true) {
|
||||
|
|
|
@ -17,8 +17,8 @@ class PlaylistId with EquatableMixin {
|
|||
final String value;
|
||||
|
||||
/// Initializes an instance of [PlaylistId]
|
||||
PlaylistId(String idOrUrl) : value = parsePlaylistId(idOrUrl) {
|
||||
if (value == null) {
|
||||
PlaylistId(String idOrUrl) : value = parsePlaylistId(idOrUrl) ?? '' {
|
||||
if (value.isEmpty) {
|
||||
throw ArgumentError.value(idOrUrl, 'idOrUrl', 'Invalid url');
|
||||
}
|
||||
}
|
||||
|
@ -61,7 +61,7 @@ class PlaylistId with EquatableMixin {
|
|||
|
||||
/// Parses a playlist [url] returning its id.
|
||||
/// If the [url] is a valid it is returned itself.
|
||||
static String parsePlaylistId(String url) {
|
||||
static String? parsePlaylistId(String url) {
|
||||
if (url.isNullOrWhiteSpace) {
|
||||
return null;
|
||||
}
|
||||
|
@ -71,25 +71,25 @@ class PlaylistId with EquatableMixin {
|
|||
}
|
||||
|
||||
var regMatch = _regMatchExp.firstMatch(url)?.group(1);
|
||||
if (!regMatch.isNullOrWhiteSpace && validatePlaylistId(regMatch)) {
|
||||
if (!regMatch.isNullOrWhiteSpace && validatePlaylistId(regMatch!)) {
|
||||
return regMatch;
|
||||
}
|
||||
|
||||
var compositeMatch = _compositeMatchExp.firstMatch(url)?.group(1);
|
||||
if (!compositeMatch.isNullOrWhiteSpace &&
|
||||
validatePlaylistId(compositeMatch)) {
|
||||
validatePlaylistId(compositeMatch!)) {
|
||||
return compositeMatch;
|
||||
}
|
||||
|
||||
var shortCompositeMatch = _shortCompositeMatchExp.firstMatch(url)?.group(1);
|
||||
if (!shortCompositeMatch.isNullOrWhiteSpace &&
|
||||
validatePlaylistId(shortCompositeMatch)) {
|
||||
validatePlaylistId(shortCompositeMatch!)) {
|
||||
return shortCompositeMatch;
|
||||
}
|
||||
|
||||
var embedCompositeMatch = _embedCompositeMatchExp.firstMatch(url)?.group(1);
|
||||
if (!embedCompositeMatch.isNullOrWhiteSpace &&
|
||||
validatePlaylistId(embedCompositeMatch)) {
|
||||
validatePlaylistId(embedCompositeMatch!)) {
|
||||
return embedCompositeMatch;
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:html/parser.dart' as parser;
|
||||
|
||||
|
@ -6,74 +7,28 @@ import '../../exceptions/exceptions.dart';
|
|||
import '../../extensions/helpers_extension.dart';
|
||||
import '../../retry.dart';
|
||||
import '../youtube_http_client.dart';
|
||||
import 'generated/channel_about_page_id.g.dart';
|
||||
|
||||
///
|
||||
class ChannelAboutPage {
|
||||
final Document _root;
|
||||
|
||||
_InitialData _initialData;
|
||||
|
||||
///
|
||||
_InitialData get initialData {
|
||||
if (_initialData != null) {
|
||||
return _initialData;
|
||||
}
|
||||
late final _InitialData initialData = _getInitialData();
|
||||
|
||||
_InitialData _getInitialData() {
|
||||
final scriptText = _root
|
||||
.querySelectorAll('script')
|
||||
.map((e) => e.text)
|
||||
.toList(growable: false);
|
||||
|
||||
var initialDataText = scriptText.firstWhere(
|
||||
(e) => e.contains('window["ytInitialData"] ='),
|
||||
orElse: () => null);
|
||||
if (initialDataText != null) {
|
||||
return _initialData = _InitialData(ChannelAboutPageId.fromRawJson(
|
||||
_extractJson(initialDataText, 'window["ytInitialData"] =')));
|
||||
}
|
||||
|
||||
initialDataText = scriptText.firstWhere(
|
||||
(e) => e.contains('var ytInitialData = '),
|
||||
orElse: () => null);
|
||||
if (initialDataText != null) {
|
||||
return _initialData = _InitialData(ChannelAboutPageId.fromRawJson(
|
||||
_extractJson(initialDataText, 'var ytInitialData = ')));
|
||||
}
|
||||
|
||||
throw TransientFailureException(
|
||||
'Failed to retrieve initial data from the channel about page, please report this to the project GitHub page.'); // ignore: lines_longer_than_80_chars
|
||||
return scriptText.extractGenericData(
|
||||
(obj) => _InitialData(obj),
|
||||
() => TransientFailureException(
|
||||
'Failed to retrieve initial data from the channel about page, please report this to the project GitHub page.'));
|
||||
}
|
||||
|
||||
///
|
||||
bool get isOk => initialData != null;
|
||||
|
||||
///
|
||||
String get description => initialData.description;
|
||||
|
||||
String _extractJson(String html, String separator) {
|
||||
return _matchJson(
|
||||
html.substring(html.indexOf(separator) + separator.length));
|
||||
}
|
||||
|
||||
String _matchJson(String str) {
|
||||
var bracketCount = 0;
|
||||
int lastI;
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
lastI = i;
|
||||
if (str[i] == '{') {
|
||||
bracketCount++;
|
||||
} else if (str[i] == '}') {
|
||||
bracketCount--;
|
||||
} else if (str[i] == ';') {
|
||||
if (bracketCount == 0) {
|
||||
return str.substring(0, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return str.substring(0, lastI + 1);
|
||||
}
|
||||
|
||||
///
|
||||
ChannelAboutPage(this._root);
|
||||
|
||||
|
@ -88,9 +43,6 @@ class ChannelAboutPage {
|
|||
var raw = await httpClient.getString(url);
|
||||
var result = ChannelAboutPage.parse(raw);
|
||||
|
||||
if (!result.isOk) {
|
||||
throw TransientFailureException('Channel about page is broken');
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
@ -104,9 +56,6 @@ class ChannelAboutPage {
|
|||
var raw = await httpClient.getString(url);
|
||||
var result = ChannelAboutPage.parse(raw);
|
||||
|
||||
if (!result.isOk) {
|
||||
throw TransientFailureException('Channel about page is broken');
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
@ -115,59 +64,73 @@ class ChannelAboutPage {
|
|||
final _urlExp = RegExp(r'q=([^=]*)$');
|
||||
|
||||
class _InitialData {
|
||||
// Json parsed class
|
||||
final ChannelAboutPageId root;
|
||||
// Json parsed map
|
||||
final Map<String, dynamic> root;
|
||||
|
||||
_InitialData(this.root);
|
||||
|
||||
/* Cache results */
|
||||
ChannelAboutFullMetadataRenderer _content;
|
||||
late final Map<String, dynamic> content = _getContentContext();
|
||||
|
||||
ChannelAboutFullMetadataRenderer get content =>
|
||||
_content ??= getContentContext();
|
||||
|
||||
ChannelAboutFullMetadataRenderer getContentContext() {
|
||||
Map<String, dynamic> _getContentContext() {
|
||||
return root
|
||||
.contents
|
||||
.twoColumnBrowseResultsRenderer
|
||||
.tabs[5]
|
||||
.tabRenderer
|
||||
.content
|
||||
.sectionListRenderer
|
||||
.contents
|
||||
.first
|
||||
.itemSectionRenderer
|
||||
.contents
|
||||
.first
|
||||
.channelAboutFullMetadataRenderer;
|
||||
.get('contents')!
|
||||
.get('twoColumnBrowseResultsRenderer')!
|
||||
.getList('tabs')!
|
||||
.firstWhere((e) => e['tabRenderer']?['content'] != null)
|
||||
.get('tabRenderer')!
|
||||
.get('content')!
|
||||
.get('sectionListRenderer')!
|
||||
.getList('contents')!
|
||||
.firstOrNull!
|
||||
.get('itemSectionRenderer')!
|
||||
.getList('contents')!
|
||||
.firstOrNull!
|
||||
.get('channelAboutFullMetadataRenderer')!;
|
||||
}
|
||||
|
||||
String get description => content.description.simpleText;
|
||||
late final String description =
|
||||
content.get('description')!.getT<String>('simpleText')!;
|
||||
|
||||
List<ChannelLink> get channelLinks {
|
||||
return content.primaryLinks
|
||||
.map((e) => ChannelLink(
|
||||
e.title.simpleText,
|
||||
extractUrl(e.navigationEndpoint?.commandMetadata?.webCommandMetadata
|
||||
?.url ??
|
||||
e.navigationEndpoint.urlEndpoint.url),
|
||||
Uri.parse(e.icon.thumbnails.first.url)))
|
||||
.toList();
|
||||
}
|
||||
late final List<ChannelLink> channelLinks = content
|
||||
.getList('primaryLinks')!
|
||||
.map((e) => ChannelLink(
|
||||
e.get('title')?.getT<String>('simpleText') ?? '',
|
||||
extractUrl(e
|
||||
.get('navigationEndpoint')
|
||||
?.get('commandMetadata')
|
||||
?.get('webCommandMetadata')
|
||||
?.getT<String>('url') ??
|
||||
e
|
||||
.get('navigationEndpoint')
|
||||
?.get('urlEndpoint')
|
||||
?.getT<String>('url') ??
|
||||
''),
|
||||
Uri.parse(e
|
||||
.get('icon')
|
||||
?.getList('thumbnails')
|
||||
?.firstOrNull
|
||||
?.getT<String>('url') ??
|
||||
'')))
|
||||
.toList();
|
||||
|
||||
int get viewCount =>
|
||||
int.parse(content.viewCountText.simpleText.stripNonDigits());
|
||||
late final int viewCount = int.parse(content
|
||||
.get('viewCountText')!
|
||||
.getT<String>('simpleText')!
|
||||
.stripNonDigits());
|
||||
|
||||
String get joinDate => content.joinedDateText.runs[1].text;
|
||||
late final String joinDate =
|
||||
content.get('joinedDateText')!.getList('runs')![1].getT<String>('text')!;
|
||||
|
||||
String get title => content.title.simpleText;
|
||||
late final String title = content.get('title')!.getT<String>('simpleText')!;
|
||||
|
||||
List<AvatarThumbnail> get avatar => content.avatar.thumbnails;
|
||||
late final List<Map<String, dynamic>> avatar =
|
||||
content.get('avatar')!.getList('thumbnails')!;
|
||||
|
||||
String get country => content.country.simpleText;
|
||||
late final String country =
|
||||
content.get('country')!.getT<String>('simpleText')!;
|
||||
|
||||
String parseRuns(List<dynamic> runs) =>
|
||||
runs?.map((e) => e.text)?.join() ?? '';
|
||||
String parseRuns(List<dynamic>? runs) =>
|
||||
runs?.map((e) => e.text).join() ?? '';
|
||||
|
||||
Uri extractUrl(String text) =>
|
||||
Uri.parse(Uri.decodeFull(_urlExp.firstMatch(text)?.group(1) ?? ''));
|
||||
|
|
|
@ -15,18 +15,21 @@ class ChannelPage {
|
|||
|
||||
///
|
||||
String get channelUrl =>
|
||||
_root.querySelector('meta[property="og:url"]')?.attributes['content'];
|
||||
_root.querySelector('meta[property="og:url"]')?.attributes['content'] ??
|
||||
'';
|
||||
|
||||
///
|
||||
String get channelId => channelUrl.substringAfter('channel/');
|
||||
|
||||
///
|
||||
String get channelTitle =>
|
||||
_root.querySelector('meta[property="og:title"]')?.attributes['content'];
|
||||
_root.querySelector('meta[property="og:title"]')?.attributes['content'] ??
|
||||
'';
|
||||
|
||||
///
|
||||
String get channelLogoUrl =>
|
||||
_root.querySelector('meta[property="og:image"]')?.attributes['content'];
|
||||
_root.querySelector('meta[property="og:image"]')?.attributes['content'] ??
|
||||
'';
|
||||
|
||||
///
|
||||
ChannelPage(this._root);
|
||||
|
|
|
@ -1,83 +1,47 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:html/parser.dart' as parser;
|
||||
|
||||
import '../../channels/channel_video.dart';
|
||||
import '../../exceptions/exceptions.dart';
|
||||
import '../../extensions/helpers_extension.dart';
|
||||
import '../../retry.dart';
|
||||
import '../../videos/videos.dart';
|
||||
import '../youtube_http_client.dart';
|
||||
import 'generated/channel_upload_page_id.g.dart';
|
||||
|
||||
///
|
||||
class ChannelUploadPage {
|
||||
///
|
||||
final String channelId;
|
||||
final Document _root;
|
||||
final Document? _root;
|
||||
|
||||
_InitialData _initialData;
|
||||
late final _InitialData initialData = _getInitialData();
|
||||
_InitialData? _initialData;
|
||||
|
||||
///
|
||||
_InitialData get initialData {
|
||||
_InitialData _getInitialData() {
|
||||
if (_initialData != null) {
|
||||
return _initialData;
|
||||
return _initialData!;
|
||||
}
|
||||
|
||||
final scriptText = _root
|
||||
final scriptText = _root!
|
||||
.querySelectorAll('script')
|
||||
.map((e) => e.text)
|
||||
.toList(growable: false);
|
||||
|
||||
var initialDataText = scriptText.firstWhere(
|
||||
(e) => e.contains('window["ytInitialData"] ='),
|
||||
orElse: () => null);
|
||||
if (initialDataText != null) {
|
||||
return _initialData = _InitialData(ChannelUploadPageId.fromRawJson(
|
||||
_extractJson(initialDataText, 'window["ytInitialData"] =')));
|
||||
}
|
||||
|
||||
initialDataText = scriptText.firstWhere(
|
||||
(e) => e.contains('var ytInitialData = '),
|
||||
orElse: () => null);
|
||||
if (initialDataText != null) {
|
||||
return _initialData = _InitialData(ChannelUploadPageId.fromRawJson(
|
||||
_extractJson(initialDataText, 'var ytInitialData = ')));
|
||||
}
|
||||
|
||||
throw TransientFailureException(
|
||||
'Failed to retrieve initial data from the channel upload page, please report this to the project GitHub page.'); // ignore: lines_longer_than_80_chars
|
||||
}
|
||||
|
||||
String _extractJson(String html, String separator) {
|
||||
return _matchJson(
|
||||
html.substring(html.indexOf(separator) + separator.length));
|
||||
}
|
||||
|
||||
String _matchJson(String str) {
|
||||
var bracketCount = 0;
|
||||
int lastI;
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
lastI = i;
|
||||
if (str[i] == '{') {
|
||||
bracketCount++;
|
||||
} else if (str[i] == '}') {
|
||||
bracketCount--;
|
||||
} else if (str[i] == ';') {
|
||||
if (bracketCount == 0) {
|
||||
return str.substring(0, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return str.substring(0, lastI + 1);
|
||||
return scriptText.extractGenericData(
|
||||
(obj) => _InitialData(obj),
|
||||
() => TransientFailureException(
|
||||
'Failed to retrieve initial data from the channel upload page, please report this to the project GitHub page.'));
|
||||
}
|
||||
|
||||
///
|
||||
ChannelUploadPage(this._root, this.channelId, [_InitialData initialData])
|
||||
ChannelUploadPage(this._root, this.channelId, [_InitialData? initialData])
|
||||
: _initialData = initialData;
|
||||
|
||||
///
|
||||
Future<ChannelUploadPage> nextPage(YoutubeHttpClient httpClient) {
|
||||
Future<ChannelUploadPage?> nextPage(YoutubeHttpClient httpClient) {
|
||||
if (initialData.continuation.isEmpty) {
|
||||
return Future.value(null);
|
||||
}
|
||||
|
@ -85,15 +49,14 @@ class ChannelUploadPage {
|
|||
'https://www.youtube.com/browse_ajax?ctoken=${initialData.continuation}&continuation=${initialData.continuation}&itct=${initialData.clickTrackingParams}';
|
||||
return retry(() async {
|
||||
var raw = await httpClient.getString(url);
|
||||
return ChannelUploadPage(null, channelId,
|
||||
_InitialData(ChannelUploadPageId.fromJson(json.decode(raw)[1])));
|
||||
return ChannelUploadPage(
|
||||
null, channelId, _InitialData(json.decode(raw)[1]));
|
||||
});
|
||||
}
|
||||
|
||||
///
|
||||
static Future<ChannelUploadPage> get(
|
||||
YoutubeHttpClient httpClient, String channelId, String sorting) {
|
||||
assert(sorting != null);
|
||||
var url =
|
||||
'https://www.youtube.com/channel/$channelId/videos?view=0&sort=$sorting&flow=grid';
|
||||
return retry(() async {
|
||||
|
@ -109,81 +72,100 @@ class ChannelUploadPage {
|
|||
|
||||
class _InitialData {
|
||||
// Json parsed map
|
||||
final ChannelUploadPageId root;
|
||||
final Map<String, dynamic> root;
|
||||
|
||||
_InitialData(this.root);
|
||||
|
||||
/* Cache results */
|
||||
late final Map<String, dynamic>? continuationContext =
|
||||
getContinuationContext();
|
||||
|
||||
List<ChannelVideo> _uploads;
|
||||
String _continuation;
|
||||
String _clickTrackingParams;
|
||||
late final String clickTrackingParams =
|
||||
continuationContext?.getT<String>('continuationContext') ?? '';
|
||||
|
||||
List<GridRendererItem> getContentContext() {
|
||||
if (root.contents != null) {
|
||||
return root.contents.twoColumnBrowseResultsRenderer.tabs
|
||||
.map((e) => e.tabRenderer)
|
||||
.firstWhere((e) => e.selected)
|
||||
.content
|
||||
.sectionListRenderer
|
||||
.contents
|
||||
.first
|
||||
.itemSectionRenderer
|
||||
.contents
|
||||
.first
|
||||
.gridRenderer
|
||||
.items;
|
||||
late final List<ChannelVideo> uploads =
|
||||
getContentContext().map(_parseContent).whereNotNull().toList();
|
||||
|
||||
late final String continuation =
|
||||
continuationContext?.getT<String>('continuation') ?? '';
|
||||
|
||||
List<Map<String, dynamic>> getContentContext() {
|
||||
List<Map<String, dynamic>>? context;
|
||||
if (root.containsKey('contents')) {
|
||||
context = root
|
||||
.get('contents')
|
||||
?.get('twoColumnBrowseResultsRenderer')
|
||||
?.getList('tabs')
|
||||
?.map((e) => e['tabRenderer'])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.firstWhereOrNull((e) => e['selected'] as bool)
|
||||
?.get('content')
|
||||
?.get('sectionListRenderer')
|
||||
?.getList('contents')
|
||||
?.firstOrNull
|
||||
?.get('itemSectionRenderer')
|
||||
?.getList('contents')
|
||||
?.firstOrNull
|
||||
?.get('gridRenderer')
|
||||
?.getList('items')
|
||||
?.cast<Map<String, dynamic>>();
|
||||
}
|
||||
if (root.response != null) {
|
||||
return root.response.continuationContents.gridContinuation.items;
|
||||
if (context == null && root.containsKey('response')) {
|
||||
context = root
|
||||
.get('response')
|
||||
?.get('continuationContents')
|
||||
?.get('gridContinuation')
|
||||
?.getList('items')
|
||||
?.cast<Map<String, dynamic>>();
|
||||
}
|
||||
throw FatalFailureException('Failed to get initial data context.');
|
||||
if (context == null) {
|
||||
throw FatalFailureException('Failed to get initial data context.');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
NextContinuationData getContinuationContext() {
|
||||
if (root.contents != null) {
|
||||
return root.contents?.twoColumnBrowseResultsRenderer?.tabs
|
||||
?.map((e) => e.tabRenderer)
|
||||
?.firstWhere((e) => e.selected)
|
||||
?.content
|
||||
?.sectionListRenderer
|
||||
?.contents
|
||||
?.first
|
||||
?.itemSectionRenderer
|
||||
?.contents
|
||||
?.first
|
||||
?.gridRenderer
|
||||
?.continuations
|
||||
?.first
|
||||
?.nextContinuationData;
|
||||
Map<String, dynamic>? getContinuationContext() {
|
||||
if (root.containsKey('contents')) {
|
||||
return root
|
||||
.get('contents')
|
||||
?.get('twoColumnBrowseResultsRenderer')
|
||||
?.getList('tabs')
|
||||
?.map((e) => e['tabRenderer'])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.firstWhereOrNull((e) => e['selected'] as bool)
|
||||
?.get('content')
|
||||
?.get('sectionListRenderer')
|
||||
?.getList('contents')
|
||||
?.firstOrNull
|
||||
?.get('itemSectionRenderer')
|
||||
?.getList('contents')
|
||||
?.firstOrNull
|
||||
?.get('gridRenderer')
|
||||
?.getList('continuations')
|
||||
?.firstOrNull
|
||||
?.get('nextContinuationData');
|
||||
}
|
||||
if (root.response != null) {
|
||||
return root?.response?.continuationContents?.gridContinuation
|
||||
?.continuations?.first?.nextContinuationData;
|
||||
if (root.containsKey('response')) {
|
||||
return root
|
||||
.get('response')
|
||||
?.get('continuationContents')
|
||||
?.get('gridContinuation')
|
||||
?.getList('continuations')
|
||||
?.firstOrNull
|
||||
?.get('nextContinuationData');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<ChannelVideo> get uploads => _uploads ??= getContentContext()
|
||||
?.map(_parseContent)
|
||||
?.where((e) => e != null)
|
||||
?.toList();
|
||||
|
||||
String get continuation =>
|
||||
_continuation ??= getContinuationContext()?.continuation ?? '';
|
||||
|
||||
String get clickTrackingParams => _clickTrackingParams ??=
|
||||
getContinuationContext()?.clickTrackingParams ?? '';
|
||||
|
||||
ChannelVideo _parseContent(GridRendererItem content) {
|
||||
if (content == null || content.gridVideoRenderer == null) {
|
||||
ChannelVideo? _parseContent(Map<String, dynamic>? content) {
|
||||
if (content == null || !content.containsKey('gridVideoRenderer')) {
|
||||
return null;
|
||||
}
|
||||
var video = content.gridVideoRenderer;
|
||||
|
||||
var video = content.get('gridVideoRenderer')!;
|
||||
return ChannelVideo(
|
||||
VideoId(video.videoId),
|
||||
video.title?.simpleText ??
|
||||
video.title?.runs?.map((e) => e.text)?.join() ??
|
||||
VideoId(video.getT<String>('videoId')!),
|
||||
video.get('title')?.getT<String>('simpleText') ??
|
||||
video.get('title')?.getList('runs')?.map((e) => e['text']).join() ??
|
||||
'');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,20 +6,18 @@ import '../youtube_http_client.dart';
|
|||
|
||||
///
|
||||
class ClosedCaptionTrackResponse {
|
||||
final xml.XmlDocument _root;
|
||||
|
||||
Iterable<ClosedCaption> _closedCaptions;
|
||||
final xml.XmlDocument root;
|
||||
|
||||
///
|
||||
Iterable<ClosedCaption> get closedCaptions => _closedCaptions ??=
|
||||
_root.findAllElements('p').map((e) => ClosedCaption._(e));
|
||||
late final Iterable<ClosedCaption> closedCaptions =
|
||||
root.findAllElements('p').map((e) => ClosedCaption._(e));
|
||||
|
||||
///
|
||||
ClosedCaptionTrackResponse(this._root);
|
||||
ClosedCaptionTrackResponse(this.root);
|
||||
|
||||
///
|
||||
// ignore: deprecated_member_use
|
||||
ClosedCaptionTrackResponse.parse(String raw) : _root = xml.parse(raw);
|
||||
ClosedCaptionTrackResponse.parse(String raw) : root = xml.parse(raw);
|
||||
|
||||
///
|
||||
static Future<ClosedCaptionTrackResponse> get(
|
||||
|
@ -34,46 +32,39 @@ class ClosedCaptionTrackResponse {
|
|||
|
||||
///
|
||||
class ClosedCaption {
|
||||
final xml.XmlElement _root;
|
||||
|
||||
Duration _offset;
|
||||
Duration _duration;
|
||||
Duration _end;
|
||||
List<ClosedCaptionPart> _parts;
|
||||
final xml.XmlElement root;
|
||||
|
||||
///
|
||||
String get text => _root.text;
|
||||
String get text => root.text;
|
||||
|
||||
///
|
||||
Duration get offset => _offset ??=
|
||||
Duration(milliseconds: int.parse(_root.getAttribute('t') ?? 0));
|
||||
late final Duration offset =
|
||||
Duration(milliseconds: int.parse(root.getAttribute('t') ?? '0'));
|
||||
|
||||
///
|
||||
Duration get duration => _duration ??=
|
||||
Duration(milliseconds: int.parse(_root.getAttribute('d') ?? 0));
|
||||
late final Duration duration =
|
||||
Duration(milliseconds: int.parse(root.getAttribute('d') ?? '0'));
|
||||
|
||||
///
|
||||
Duration get end => _end ??= offset + duration;
|
||||
late final Duration end = offset + duration;
|
||||
|
||||
///
|
||||
List<ClosedCaptionPart> getParts() => _parts ??=
|
||||
_root.findAllElements('s').map((e) => ClosedCaptionPart._(e)).toList();
|
||||
late final List<ClosedCaptionPart> parts =
|
||||
root.findAllElements('s').map((e) => ClosedCaptionPart._(e)).toList();
|
||||
|
||||
ClosedCaption._(this._root);
|
||||
ClosedCaption._(this.root);
|
||||
}
|
||||
|
||||
///
|
||||
class ClosedCaptionPart {
|
||||
final xml.XmlElement _root;
|
||||
|
||||
Duration _offset;
|
||||
final xml.XmlElement root;
|
||||
|
||||
///
|
||||
String get text => _root.text;
|
||||
String get text => root.text;
|
||||
|
||||
///
|
||||
Duration get offset => _offset ??=
|
||||
Duration(milliseconds: int.parse(_root.getAttribute('t') ?? '0'));
|
||||
late final Duration offset =
|
||||
Duration(milliseconds: int.parse(root.getAttribute('t') ?? '0'));
|
||||
|
||||
ClosedCaptionPart._(this._root);
|
||||
ClosedCaptionPart._(this.root);
|
||||
}
|
||||
|
|
|
@ -9,15 +9,14 @@ class DashManifest {
|
|||
static final _urlSignatureExp = RegExp(r'/s/(.*?)(?:/|$)');
|
||||
|
||||
final xml.XmlDocument _root;
|
||||
Iterable<_StreamInfo> _streams;
|
||||
|
||||
///
|
||||
Iterable<_StreamInfo> get streams => _streams ??= _root
|
||||
late final Iterable<_StreamInfo> streams = _root
|
||||
.findElements('Representation')
|
||||
.where((e) => e
|
||||
.findElements('Initialization')
|
||||
.first
|
||||
.getAttribute('sourceURL')
|
||||
.getAttribute('sourceURL')!
|
||||
.contains('sq/'))
|
||||
.map((e) => _StreamInfo(e));
|
||||
|
||||
|
@ -37,50 +36,53 @@ class DashManifest {
|
|||
}
|
||||
|
||||
///
|
||||
static String getSignatureFromUrl(String url) =>
|
||||
static String? getSignatureFromUrl(String url) =>
|
||||
_urlSignatureExp.firstMatch(url)?.group(1);
|
||||
}
|
||||
|
||||
class _StreamInfo extends StreamInfoProvider {
|
||||
static final _contentLenExp = RegExp(r'[/\?]clen[/=](\d+)');
|
||||
static final _containerExp = RegExp(r'mime[/=]\w*%2F([\w\d]*)');
|
||||
|
||||
final xml.XmlElement _root;
|
||||
final xml.XmlElement root;
|
||||
|
||||
_StreamInfo(this._root);
|
||||
_StreamInfo(this.root);
|
||||
|
||||
@override
|
||||
int get tag => int.parse(_root.getAttribute('id'));
|
||||
late final int tag = int.parse(root.getAttribute('id')!);
|
||||
|
||||
@override
|
||||
String get url => _root.getAttribute('BaseURL');
|
||||
late final String url = root.getAttribute('BaseURL')!;
|
||||
|
||||
@override
|
||||
int get contentLength => int.parse(_root.getAttribute('contentLength') ??
|
||||
_contentLenExp.firstMatch(url).group(1));
|
||||
late final int contentLength = int.parse(
|
||||
(root.getAttribute('contentLength') ??
|
||||
_contentLenExp.firstMatch(url)?.group(1))!);
|
||||
|
||||
@override
|
||||
int get bitrate => int.parse(_root.getAttribute('bandwidth'));
|
||||
late final int bitrate = int.parse(root.getAttribute('bandwidth')!);
|
||||
|
||||
@override
|
||||
String get container =>
|
||||
Uri.decodeFull(_containerExp.firstMatch(url).group(1));
|
||||
late final String? container = '';
|
||||
/*
|
||||
Uri.decodeFull((_containerExp.firstMatch(url)?.group(1))!);*/
|
||||
|
||||
bool get isAudioOnly =>
|
||||
_root.findElements('AudioChannelConfiguration').isNotEmpty;
|
||||
late final bool isAudioOnly =
|
||||
root.findElements('AudioChannelConfiguration').isNotEmpty;
|
||||
|
||||
@override
|
||||
String get audioCodec => isAudioOnly ? null : _root.getAttribute('codecs');
|
||||
late final String? audioCodec =
|
||||
isAudioOnly ? null : root.getAttribute('codecs');
|
||||
|
||||
@override
|
||||
String get videoCodec => isAudioOnly ? _root.getAttribute('codecs') : null;
|
||||
late final String? videoCodec =
|
||||
isAudioOnly ? root.getAttribute('codecs') : null;
|
||||
|
||||
@override
|
||||
int get videoWidth => int.parse(_root.getAttribute('width'));
|
||||
late final int videoWidth = int.parse(root.getAttribute('width')!);
|
||||
|
||||
@override
|
||||
int get videoHeight => int.parse(_root.getAttribute('height'));
|
||||
late final int videoHeight = int.parse(root.getAttribute('height')!);
|
||||
|
||||
@override
|
||||
int get framerate => int.parse(_root.getAttribute('framerate'));
|
||||
late final int framerate = int.parse(root.getAttribute('framerate')!);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:html/parser.dart' as parser;
|
||||
|
||||
|
@ -14,16 +13,16 @@ class EmbedPage {
|
|||
RegExp('[\'""]PLAYER_CONFIG[\'""]\\s*:\\s*(\\{.*\\})');
|
||||
static final _playerConfigExp2 = RegExp(r'yt.setConfig\((\{.*\})');
|
||||
|
||||
final Document _root;
|
||||
EmbedPlayerConfig _playerConfig;
|
||||
final Document root;
|
||||
late final EmbedPlayerConfig? playerConfig = getPlayerConfig();
|
||||
|
||||
///
|
||||
String get sourceUrl {
|
||||
var url = _root
|
||||
String? get sourceUrl {
|
||||
var url = root
|
||||
.querySelectorAll('*[name="player_ias/base"]')
|
||||
.map((e) => e.attributes['src'])
|
||||
.where((e) => !e.isNullOrWhiteSpace)
|
||||
.firstWhere((e) => e.contains('player_ias') && e.endsWith('.js'),
|
||||
.firstWhere((e) => e!.contains('player_ias') && e.endsWith('.js'),
|
||||
orElse: () => null);
|
||||
// _root.querySelector('*[name="player_ias/base"]').attributes['src'];
|
||||
if (url == null) {
|
||||
|
@ -33,35 +32,32 @@ class EmbedPage {
|
|||
}
|
||||
|
||||
///
|
||||
EmbedPlayerConfig get playerConfig {
|
||||
if (_playerConfig != null) {
|
||||
return _playerConfig;
|
||||
}
|
||||
var playerConfigJson = _playerConfigJson ?? _playerConfigJson2;
|
||||
EmbedPlayerConfig? getPlayerConfig() {
|
||||
var playerConfigJson =
|
||||
(_playerConfigJson ?? _playerConfigJson2)?.extractJson();
|
||||
if (playerConfigJson == null) {
|
||||
return null;
|
||||
}
|
||||
return _playerConfig =
|
||||
EmbedPlayerConfig(json.decode(playerConfigJson.extractJson()));
|
||||
return EmbedPlayerConfig(playerConfigJson);
|
||||
}
|
||||
|
||||
String get _playerConfigJson => _root
|
||||
String? get _playerConfigJson => root
|
||||
.getElementsByTagName('script')
|
||||
.map((e) => e.text)
|
||||
.map((e) => _playerConfigExp.firstMatch(e)?.group(1))
|
||||
.firstWhere((e) => !e.isNullOrWhiteSpace, orElse: () => null);
|
||||
.firstWhereOrNull((e) => !e.isNullOrWhiteSpace);
|
||||
|
||||
String get _playerConfigJson2 => _root
|
||||
String? get _playerConfigJson2 => root
|
||||
.getElementsByTagName('script')
|
||||
.map((e) => e.text)
|
||||
.map((e) => _playerConfigExp2.firstMatch(e)?.group(1))
|
||||
.firstWhere((e) => !e.isNullOrWhiteSpace, orElse: () => null);
|
||||
.firstWhereOrNull((e) => !e.isNullOrWhiteSpace);
|
||||
|
||||
///
|
||||
EmbedPage(this._root);
|
||||
EmbedPage(this.root);
|
||||
|
||||
///
|
||||
EmbedPage.parse(String raw) : _root = parser.parse(raw);
|
||||
EmbedPage.parse(String raw) : root = parser.parse(raw);
|
||||
|
||||
///
|
||||
static Future<EmbedPage> get(YoutubeHttpClient httpClient, String videoId) {
|
||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -1,237 +0,0 @@
|
|||
// To parse this JSON data, do
|
||||
//
|
||||
// final playerConfigJson = playerConfigJsonFromJson(jsonString);
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
class PlayerConfigJson {
|
||||
PlayerConfigJson({
|
||||
this.assets,
|
||||
this.attrs,
|
||||
this.args,
|
||||
});
|
||||
|
||||
final Assets assets;
|
||||
final Attrs attrs;
|
||||
final Args args;
|
||||
|
||||
factory PlayerConfigJson.fromRawJson(String str) =>
|
||||
PlayerConfigJson.fromJson(json.decode(str));
|
||||
|
||||
String toRawJson() => json.encode(toJson());
|
||||
|
||||
factory PlayerConfigJson.fromJson(Map<String, dynamic> json) =>
|
||||
PlayerConfigJson(
|
||||
assets: json["assets"] == null ? null : Assets.fromJson(json["assets"]),
|
||||
attrs: json["attrs"] == null ? null : Attrs.fromJson(json["attrs"]),
|
||||
args: json["args"] == null ? null : Args.fromJson(json["args"]),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"assets": assets == null ? null : assets.toJson(),
|
||||
"attrs": attrs == null ? null : attrs.toJson(),
|
||||
"args": args == null ? null : args.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
class Args {
|
||||
Args({
|
||||
this.innertubeApiKey,
|
||||
this.showMiniplayerButton,
|
||||
this.useMiniplayerUi,
|
||||
this.gapiHintParams,
|
||||
this.playerResponse,
|
||||
this.cbrver,
|
||||
this.cbr,
|
||||
this.innertubeApiVersion,
|
||||
this.innertubeContextClientVersion,
|
||||
this.vssHost,
|
||||
this.hostLanguage,
|
||||
this.cr,
|
||||
this.externalFullscreen,
|
||||
this.useFastSizingOnWatchDefault,
|
||||
this.c,
|
||||
this.ps,
|
||||
this.csiPageType,
|
||||
this.cos,
|
||||
this.enablecsi,
|
||||
this.watermark,
|
||||
this.cver,
|
||||
this.transparentBackground,
|
||||
this.hl,
|
||||
this.enablejsapi,
|
||||
this.cosver,
|
||||
this.loaderUrl,
|
||||
});
|
||||
|
||||
final String innertubeApiKey;
|
||||
final String showMiniplayerButton;
|
||||
final String useMiniplayerUi;
|
||||
final String gapiHintParams;
|
||||
final String playerResponse;
|
||||
final String cbrver;
|
||||
final String cbr;
|
||||
final String innertubeApiVersion;
|
||||
final String innertubeContextClientVersion;
|
||||
final String vssHost;
|
||||
final String hostLanguage;
|
||||
final String cr;
|
||||
final bool externalFullscreen;
|
||||
final bool useFastSizingOnWatchDefault;
|
||||
final String c;
|
||||
final String ps;
|
||||
final String csiPageType;
|
||||
final String cos;
|
||||
final String enablecsi;
|
||||
final String watermark;
|
||||
final String cver;
|
||||
final String transparentBackground;
|
||||
final String hl;
|
||||
final String enablejsapi;
|
||||
final String cosver;
|
||||
final String loaderUrl;
|
||||
|
||||
factory Args.fromRawJson(String str) => Args.fromJson(json.decode(str));
|
||||
|
||||
String toRawJson() => json.encode(toJson());
|
||||
|
||||
factory Args.fromJson(Map<String, dynamic> json) => Args(
|
||||
innertubeApiKey: json["innertube_api_key"] == null
|
||||
? null
|
||||
: json["innertube_api_key"],
|
||||
showMiniplayerButton: json["show_miniplayer_button"] == null
|
||||
? null
|
||||
: json["show_miniplayer_button"],
|
||||
useMiniplayerUi: json["use_miniplayer_ui"] == null
|
||||
? null
|
||||
: json["use_miniplayer_ui"],
|
||||
gapiHintParams:
|
||||
json["gapi_hint_params"] == null ? null : json["gapi_hint_params"],
|
||||
playerResponse:
|
||||
json["player_response"] == null ? null : json["player_response"],
|
||||
cbrver: json["cbrver"] == null ? null : json["cbrver"],
|
||||
cbr: json["cbr"] == null ? null : json["cbr"],
|
||||
innertubeApiVersion: json["innertube_api_version"] == null
|
||||
? null
|
||||
: json["innertube_api_version"],
|
||||
innertubeContextClientVersion:
|
||||
json["innertube_context_client_version"] == null
|
||||
? null
|
||||
: json["innertube_context_client_version"],
|
||||
vssHost: json["vss_host"] == null ? null : json["vss_host"],
|
||||
hostLanguage:
|
||||
json["host_language"] == null ? null : json["host_language"],
|
||||
cr: json["cr"] == null ? null : json["cr"],
|
||||
externalFullscreen: json["external_fullscreen"] == null
|
||||
? null
|
||||
: json["external_fullscreen"],
|
||||
useFastSizingOnWatchDefault:
|
||||
json["use_fast_sizing_on_watch_default"] == null
|
||||
? null
|
||||
: json["use_fast_sizing_on_watch_default"],
|
||||
c: json["c"] == null ? null : json["c"],
|
||||
ps: json["ps"] == null ? null : json["ps"],
|
||||
csiPageType:
|
||||
json["csi_page_type"] == null ? null : json["csi_page_type"],
|
||||
cos: json["cos"] == null ? null : json["cos"],
|
||||
enablecsi: json["enablecsi"] == null ? null : json["enablecsi"],
|
||||
watermark: json["watermark"] == null ? null : json["watermark"],
|
||||
cver: json["cver"] == null ? null : json["cver"],
|
||||
transparentBackground: json["transparent_background"] == null
|
||||
? null
|
||||
: json["transparent_background"],
|
||||
hl: json["hl"] == null ? null : json["hl"],
|
||||
enablejsapi: json["enablejsapi"] == null ? null : json["enablejsapi"],
|
||||
cosver: json["cosver"] == null ? null : json["cosver"],
|
||||
loaderUrl: json["loaderUrl"] == null ? null : json["loaderUrl"],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"innertube_api_key": innertubeApiKey == null ? null : innertubeApiKey,
|
||||
"show_miniplayer_button":
|
||||
showMiniplayerButton == null ? null : showMiniplayerButton,
|
||||
"use_miniplayer_ui": useMiniplayerUi == null ? null : useMiniplayerUi,
|
||||
"gapi_hint_params": gapiHintParams == null ? null : gapiHintParams,
|
||||
"player_response": playerResponse == null ? null : playerResponse,
|
||||
"cbrver": cbrver == null ? null : cbrver,
|
||||
"cbr": cbr == null ? null : cbr,
|
||||
"innertube_api_version":
|
||||
innertubeApiVersion == null ? null : innertubeApiVersion,
|
||||
"innertube_context_client_version":
|
||||
innertubeContextClientVersion == null
|
||||
? null
|
||||
: innertubeContextClientVersion,
|
||||
"vss_host": vssHost == null ? null : vssHost,
|
||||
"host_language": hostLanguage == null ? null : hostLanguage,
|
||||
"cr": cr == null ? null : cr,
|
||||
"external_fullscreen":
|
||||
externalFullscreen == null ? null : externalFullscreen,
|
||||
"use_fast_sizing_on_watch_default": useFastSizingOnWatchDefault == null
|
||||
? null
|
||||
: useFastSizingOnWatchDefault,
|
||||
"c": c == null ? null : c,
|
||||
"ps": ps == null ? null : ps,
|
||||
"csi_page_type": csiPageType == null ? null : csiPageType,
|
||||
"cos": cos == null ? null : cos,
|
||||
"enablecsi": enablecsi == null ? null : enablecsi,
|
||||
"watermark": watermark == null ? null : watermark,
|
||||
"cver": cver == null ? null : cver,
|
||||
"transparent_background":
|
||||
transparentBackground == null ? null : transparentBackground,
|
||||
"hl": hl == null ? null : hl,
|
||||
"enablejsapi": enablejsapi == null ? null : enablejsapi,
|
||||
"cosver": cosver == null ? null : cosver,
|
||||
"loaderUrl": loaderUrl == null ? null : loaderUrl,
|
||||
};
|
||||
}
|
||||
|
||||
class Assets {
|
||||
Assets({
|
||||
this.playerCanaryState,
|
||||
this.js,
|
||||
this.css,
|
||||
});
|
||||
|
||||
final String playerCanaryState;
|
||||
final String js;
|
||||
final String css;
|
||||
|
||||
factory Assets.fromRawJson(String str) => Assets.fromJson(json.decode(str));
|
||||
|
||||
String toRawJson() => json.encode(toJson());
|
||||
|
||||
factory Assets.fromJson(Map<String, dynamic> json) => Assets(
|
||||
playerCanaryState: json["player_canary_state"] == null
|
||||
? null
|
||||
: json["player_canary_state"],
|
||||
js: json["js"] == null ? null : json["js"],
|
||||
css: json["css"] == null ? null : json["css"],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"player_canary_state":
|
||||
playerCanaryState == null ? null : playerCanaryState,
|
||||
"js": js == null ? null : js,
|
||||
"css": css == null ? null : css,
|
||||
};
|
||||
}
|
||||
|
||||
class Attrs {
|
||||
Attrs({
|
||||
this.id,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
||||
factory Attrs.fromRawJson(String str) => Attrs.fromJson(json.decode(str));
|
||||
|
||||
String toRawJson() => json.encode(toJson());
|
||||
|
||||
factory Attrs.fromJson(Map<String, dynamic> json) => Attrs(
|
||||
id: json["id"] == null ? null : json["id"],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id == null ? null : id,
|
||||
};
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -1,203 +0,0 @@
|
|||
// To parse this JSON data, do
|
||||
//
|
||||
// final playlistResponseJson = playlistResponseJsonFromJson(jsonString);
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
class PlaylistResponseJson {
|
||||
PlaylistResponseJson({
|
||||
this.title,
|
||||
this.views,
|
||||
this.description,
|
||||
this.video,
|
||||
this.author,
|
||||
this.likes,
|
||||
this.dislikes,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final int views;
|
||||
final String description;
|
||||
final List<Video> video;
|
||||
final String author;
|
||||
final int likes;
|
||||
final int dislikes;
|
||||
|
||||
factory PlaylistResponseJson.fromRawJson(String str) =>
|
||||
PlaylistResponseJson.fromJson(json.decode(str));
|
||||
|
||||
String toRawJson() => json.encode(toJson());
|
||||
|
||||
factory PlaylistResponseJson.fromJson(Map<String, dynamic> json) =>
|
||||
PlaylistResponseJson(
|
||||
title: json["title"] == null ? null : json["title"],
|
||||
views: json["views"] == null ? null : json["views"],
|
||||
description: json["description"] == null ? null : json["description"],
|
||||
video: json["video"] == null
|
||||
? null
|
||||
: List<Video>.from(json["video"].map((x) => Video.fromJson(x))),
|
||||
author: json["author"] == null ? null : json["author"],
|
||||
likes: json["likes"] == null ? null : json["likes"],
|
||||
dislikes: json["dislikes"] == null ? null : json["dislikes"],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"title": title == null ? null : title,
|
||||
"views": views == null ? null : views,
|
||||
"description": description == null ? null : description,
|
||||
"video": video == null
|
||||
? null
|
||||
: List<dynamic>.from(video.map((x) => x.toJson())),
|
||||
"author": author == null ? null : author,
|
||||
"likes": likes == null ? null : likes,
|
||||
"dislikes": dislikes == null ? null : dislikes,
|
||||
};
|
||||
}
|
||||
|
||||
class Video {
|
||||
Video({
|
||||
this.sessionData,
|
||||
this.timeCreated,
|
||||
this.ccLicense,
|
||||
this.title,
|
||||
this.rating,
|
||||
this.isHd,
|
||||
this.privacy,
|
||||
this.lengthSeconds,
|
||||
this.keywords,
|
||||
this.views,
|
||||
this.encryptedId,
|
||||
this.likes,
|
||||
this.isCc,
|
||||
this.description,
|
||||
this.thumbnail,
|
||||
this.userId,
|
||||
this.added,
|
||||
this.endscreenAutoplaySessionData,
|
||||
this.comments,
|
||||
this.dislikes,
|
||||
this.categoryId,
|
||||
this.duration,
|
||||
this.author,
|
||||
});
|
||||
|
||||
final SessionData sessionData;
|
||||
final int timeCreated;
|
||||
final bool ccLicense;
|
||||
final String title;
|
||||
final num rating;
|
||||
final bool isHd;
|
||||
final Privacy privacy;
|
||||
final int lengthSeconds;
|
||||
final String keywords;
|
||||
final String views;
|
||||
final String encryptedId;
|
||||
final int likes;
|
||||
final bool isCc;
|
||||
final String description;
|
||||
final String thumbnail;
|
||||
final String userId;
|
||||
final String added;
|
||||
final EndscreenAutoplaySessionData endscreenAutoplaySessionData;
|
||||
final String comments;
|
||||
final int dislikes;
|
||||
final int categoryId;
|
||||
final String duration;
|
||||
final String author;
|
||||
|
||||
factory Video.fromRawJson(String str) => Video.fromJson(json.decode(str));
|
||||
|
||||
String toRawJson() => json.encode(toJson());
|
||||
|
||||
factory Video.fromJson(Map<String, dynamic> json) => Video(
|
||||
sessionData: json["session_data"] == null
|
||||
? null
|
||||
: sessionDataValues.map[json["session_data"]],
|
||||
timeCreated: json["time_created"] == null ? null : json["time_created"],
|
||||
ccLicense: json["cc_license"] == null ? null : json["cc_license"],
|
||||
title: json["title"] == null ? null : json["title"],
|
||||
rating: json["rating"] == null ? null : json["rating"],
|
||||
isHd: json["is_hd"] == null ? null : json["is_hd"],
|
||||
privacy:
|
||||
json["privacy"] == null ? null : privacyValues.map[json["privacy"]],
|
||||
lengthSeconds:
|
||||
json["length_seconds"] == null ? null : json["length_seconds"],
|
||||
keywords: json["keywords"] == null ? null : json["keywords"],
|
||||
views: json["views"] == null ? null : json["views"],
|
||||
encryptedId: json["encrypted_id"] == null ? null : json["encrypted_id"],
|
||||
likes: json["likes"] == null ? null : json["likes"],
|
||||
isCc: json["is_cc"] == null ? null : json["is_cc"],
|
||||
description: json["description"] == null ? null : json["description"],
|
||||
thumbnail: json["thumbnail"] == null ? null : json["thumbnail"],
|
||||
userId: json["user_id"] == null ? null : json["user_id"],
|
||||
added: json["added"] == null ? null : json["added"],
|
||||
endscreenAutoplaySessionData:
|
||||
json["endscreen_autoplay_session_data"] == null
|
||||
? null
|
||||
: endscreenAutoplaySessionDataValues
|
||||
.map[json["endscreen_autoplay_session_data"]],
|
||||
comments: json["comments"] == null ? null : json["comments"],
|
||||
dislikes: json["dislikes"] == null ? null : json["dislikes"],
|
||||
categoryId: json["category_id"] == null ? null : json["category_id"],
|
||||
duration: json["duration"] == null ? null : json["duration"],
|
||||
author: json["author"] == null ? null : json["author"],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"session_data":
|
||||
sessionData == null ? null : sessionDataValues.reverse[sessionData],
|
||||
"time_created": timeCreated == null ? null : timeCreated,
|
||||
"cc_license": ccLicense == null ? null : ccLicense,
|
||||
"title": title == null ? null : title,
|
||||
"rating": rating == null ? null : rating,
|
||||
"is_hd": isHd == null ? null : isHd,
|
||||
"privacy": privacy == null ? null : privacyValues.reverse[privacy],
|
||||
"length_seconds": lengthSeconds == null ? null : lengthSeconds,
|
||||
"keywords": keywords == null ? null : keywords,
|
||||
"views": views == null ? null : views,
|
||||
"encrypted_id": encryptedId == null ? null : encryptedId,
|
||||
"likes": likes == null ? null : likes,
|
||||
"is_cc": isCc == null ? null : isCc,
|
||||
"description": description == null ? null : description,
|
||||
"thumbnail": thumbnail == null ? null : thumbnail,
|
||||
"user_id": userId == null ? null : userId,
|
||||
"added": added == null ? null : added,
|
||||
"endscreen_autoplay_session_data": endscreenAutoplaySessionData == null
|
||||
? null
|
||||
: endscreenAutoplaySessionDataValues
|
||||
.reverse[endscreenAutoplaySessionData],
|
||||
"comments": comments == null ? null : comments,
|
||||
"dislikes": dislikes == null ? null : dislikes,
|
||||
"category_id": categoryId == null ? null : categoryId,
|
||||
"duration": duration == null ? null : duration,
|
||||
"author": author == null ? null : author,
|
||||
};
|
||||
}
|
||||
|
||||
enum EndscreenAutoplaySessionData { FEATURE_AUTOPLAY }
|
||||
|
||||
final endscreenAutoplaySessionDataValues = EnumValues(
|
||||
{"feature=autoplay": EndscreenAutoplaySessionData.FEATURE_AUTOPLAY});
|
||||
|
||||
enum Privacy { PUBLIC }
|
||||
|
||||
final privacyValues = EnumValues({"public": Privacy.PUBLIC});
|
||||
|
||||
enum SessionData { FEATURE_PLAYLIST }
|
||||
|
||||
final sessionDataValues =
|
||||
EnumValues({"feature=playlist": SessionData.FEATURE_PLAYLIST});
|
||||
|
||||
class EnumValues<T> {
|
||||
Map<String, T> map;
|
||||
Map<T, String> reverseMap;
|
||||
|
||||
EnumValues(this.map);
|
||||
|
||||
Map<T, String> get reverse {
|
||||
if (reverseMap == null) {
|
||||
reverseMap = map.map((k, v) => new MapEntry(v, k));
|
||||
}
|
||||
return reverseMap;
|
||||
}
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
Files in this directory where generated using https://app.quicktype.io/ , using as source the youtube api.
|
||||
https://pypi.org/project/jsonmerge/ was used to merge source from different requests.
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -1,27 +1,19 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
|
||||
import '../../extensions/helpers_extension.dart';
|
||||
import 'generated/player_response.g.dart';
|
||||
import 'stream_info_provider.dart';
|
||||
|
||||
///
|
||||
class PlayerResponse {
|
||||
// Json parsed class
|
||||
PlayerResponseJson _root;
|
||||
|
||||
/// Json parsed map
|
||||
final Map<String, dynamic> _rawJson;
|
||||
|
||||
Iterable<StreamInfoProvider> _muxedStreams;
|
||||
Iterable<StreamInfoProvider> _adaptiveStreams;
|
||||
List<StreamInfoProvider> _streams;
|
||||
Iterable<ClosedCaptionTrack> _closedCaptionTrack;
|
||||
String _videoPlayabilityError;
|
||||
// Json parsed map
|
||||
Map<String, dynamic> root;
|
||||
|
||||
///
|
||||
String get playabilityStatus => _root.playabilityStatus.status;
|
||||
late final String playabilityStatus =
|
||||
root.get('playabilityStatus')!.getT<String>('status')!;
|
||||
|
||||
///
|
||||
bool get isVideoAvailable => playabilityStatus.toLowerCase() != 'error';
|
||||
|
@ -30,41 +22,58 @@ class PlayerResponse {
|
|||
bool get isVideoPlayable => playabilityStatus.toLowerCase() == 'ok';
|
||||
|
||||
///
|
||||
String get videoTitle => _root.videoDetails.title;
|
||||
String get videoTitle => root.get('videoDetails')!.getT<String>('title')!;
|
||||
|
||||
///
|
||||
String get videoAuthor => _root.videoDetails.author;
|
||||
String get videoAuthor => root.get('videoDetails')!.getT<String>('author')!;
|
||||
|
||||
///
|
||||
DateTime get videoUploadDate =>
|
||||
_root.microformat.playerMicroformatRenderer.uploadDate;
|
||||
DateTime get videoUploadDate => root
|
||||
.get('microformat')!
|
||||
.get('playerMicroformatRenderer')!
|
||||
.getT<String>('uploadDate')!
|
||||
.parseDateTime();
|
||||
|
||||
///
|
||||
String get videoChannelId => _root.videoDetails.channelId;
|
||||
DateTime get videoPublishDate => root
|
||||
.get('microformat')!
|
||||
.get('playerMicroformatRenderer')!
|
||||
.getT<String>('publishDate')!
|
||||
.parseDateTime();
|
||||
|
||||
///
|
||||
Duration get videoDuration =>
|
||||
Duration(seconds: int.parse(_root.videoDetails.lengthSeconds));
|
||||
String get videoChannelId =>
|
||||
root.get('videoDetails')!.getT<String>('channelId')!;
|
||||
|
||||
///
|
||||
List<String> get videoKeywords => _root.videoDetails.keywords ?? const [];
|
||||
Duration get videoDuration => Duration(
|
||||
seconds:
|
||||
int.parse(root.get('videoDetails')!.getT<String>('lengthSeconds')!));
|
||||
|
||||
///
|
||||
String get videoDescription => _root.videoDetails.shortDescription;
|
||||
List<String> get videoKeywords =>
|
||||
root
|
||||
.get('videoDetails')
|
||||
?.getT<List<dynamic>>('keywords')
|
||||
?.cast<String>() ??
|
||||
const [];
|
||||
|
||||
///
|
||||
int get videoViewCount => int.parse(_root.videoDetails.viewCount);
|
||||
String get videoDescription =>
|
||||
root.get('videoDetails')!.getT<String>('shortDescription')!;
|
||||
|
||||
//TODO: Get these types
|
||||
///
|
||||
// Can be null
|
||||
String get previewVideoId =>
|
||||
_rawJson
|
||||
int get videoViewCount =>
|
||||
int.parse(root.get('videoDetails')!.getT<String>('viewCount')!);
|
||||
|
||||
///
|
||||
String? get previewVideoId =>
|
||||
root
|
||||
.get('playabilityStatus')
|
||||
?.get('errorScreen')
|
||||
?.get('playerLegacyDesktopYpcTrailerRenderer')
|
||||
?.getValue('trailerVideoId') ??
|
||||
Uri.splitQueryString(_rawJson
|
||||
Uri.splitQueryString(root
|
||||
.get('playabilityStatus')
|
||||
?.get('errorScreen')
|
||||
?.get('')
|
||||
|
@ -73,160 +82,161 @@ class PlayerResponse {
|
|||
'')['video_id'];
|
||||
|
||||
///
|
||||
bool get isLive => _root.videoDetails.isLive ?? false;
|
||||
bool get isLive => root.get('videoDetails')?.getT<bool>('isLive') ?? false;
|
||||
|
||||
///
|
||||
// Can be null
|
||||
String get hlsManifestUrl => _root.streamingData?.hlsManifestUrl;
|
||||
String? get hlsManifestUrl =>
|
||||
root.get('streamingData')?.getT<String>('hlsManifestUrl');
|
||||
|
||||
///
|
||||
// Can be null
|
||||
String get dashManifestUrl => _root.streamingData?.dashManifestUrl;
|
||||
String? get dashManifestUrl =>
|
||||
root.get('streamingData')?.getT<String>('dashManifestUrl');
|
||||
|
||||
///
|
||||
List<StreamInfoProvider> get muxedStreams =>
|
||||
_muxedStreams ??= _root.streamingData?.formats
|
||||
?.map((e) => _StreamInfo(e))
|
||||
?.cast<StreamInfoProvider>()
|
||||
?.toList() ??
|
||||
const <StreamInfoProvider>[];
|
||||
late final List<StreamInfoProvider> muxedStreams = root
|
||||
.get('streamingData')
|
||||
?.getList('formats')
|
||||
?.map((e) => _StreamInfo(e))
|
||||
.cast<StreamInfoProvider>()
|
||||
.toList() ??
|
||||
const <StreamInfoProvider>[];
|
||||
|
||||
///
|
||||
List<StreamInfoProvider> get adaptiveStreams =>
|
||||
_adaptiveStreams ??= _root.streamingData?.adaptiveFormats
|
||||
?.map((e) => _StreamInfo(e))
|
||||
?.cast<StreamInfoProvider>()
|
||||
?.toList() ??
|
||||
const [];
|
||||
late final List<StreamInfoProvider> adaptiveStreams = root
|
||||
.get('streamingData')
|
||||
?.getList('adaptiveFormats')
|
||||
?.map((e) => _StreamInfo(e))
|
||||
.cast<StreamInfoProvider>()
|
||||
.toList() ??
|
||||
const [];
|
||||
|
||||
///
|
||||
List<StreamInfoProvider> get streams =>
|
||||
_streams ??= [...muxedStreams, ...adaptiveStreams];
|
||||
late final List<StreamInfoProvider> streams = [
|
||||
...muxedStreams,
|
||||
...adaptiveStreams
|
||||
];
|
||||
|
||||
///
|
||||
List<ClosedCaptionTrack> get closedCaptionTrack => _closedCaptionTrack ??=
|
||||
_root.captions?.playerCaptionsTracklistRenderer?.captionTracks
|
||||
?.map((e) => ClosedCaptionTrack(e))
|
||||
?.cast<ClosedCaptionTrack>()
|
||||
?.toList() ??
|
||||
const [];
|
||||
|
||||
/// Can be null
|
||||
String getVideoPlayabilityError() =>
|
||||
_videoPlayabilityError ??= _root.playabilityStatus.reason;
|
||||
late final List<ClosedCaptionTrack> closedCaptionTrack = root
|
||||
.get('captions')
|
||||
?.get('playerCaptionsTracklistRenderer')
|
||||
?.getList('captionTracks')
|
||||
?.map((e) => ClosedCaptionTrack(e))
|
||||
.cast<ClosedCaptionTrack>()
|
||||
.toList() ??
|
||||
const [];
|
||||
|
||||
///
|
||||
PlayerResponse.parse(String raw) : _rawJson = json.decode(raw) {
|
||||
_root = PlayerResponseJson.fromJson(_rawJson);
|
||||
}
|
||||
late final String? videoPlayabilityError =
|
||||
root.get('playabilityStatus')?.getT<String>('reason');
|
||||
|
||||
PlayerResponse(this.root);
|
||||
|
||||
///
|
||||
PlayerResponse.parse(String raw) : root = json.decode(raw);
|
||||
}
|
||||
|
||||
///
|
||||
class ClosedCaptionTrack {
|
||||
// Json parsed class
|
||||
final CaptionTrack _root;
|
||||
final Map<String, dynamic> root;
|
||||
|
||||
///
|
||||
String get url => _root.baseUrl;
|
||||
String get url => root.getT<String>('baseUrl')!;
|
||||
|
||||
///
|
||||
String get languageCode => _root.languageCode;
|
||||
String get languageCode => root.getT<String>('languageCode')!;
|
||||
|
||||
///
|
||||
String get languageName => _root.name.simpleText;
|
||||
String get languageName => root.get('name')!.getT<String>('simpleText')!;
|
||||
|
||||
///
|
||||
bool get autoGenerated => _root.vssId.toLowerCase().startsWith('a.');
|
||||
bool get autoGenerated =>
|
||||
root.getT<String>('vssId')!.toLowerCase().startsWith('a.');
|
||||
|
||||
///
|
||||
ClosedCaptionTrack(this._root);
|
||||
ClosedCaptionTrack(this.root);
|
||||
}
|
||||
|
||||
class _StreamInfo extends StreamInfoProvider {
|
||||
static final _contentLenExp = RegExp(r'[\?&]clen=(\d+)');
|
||||
|
||||
// Json parsed class
|
||||
final Format _root;
|
||||
|
||||
int _bitrate;
|
||||
String _container;
|
||||
int _contentLength;
|
||||
int _framerate;
|
||||
String _signature;
|
||||
String _signatureParameter;
|
||||
int _tag;
|
||||
String _url;
|
||||
/// Json parsed map
|
||||
final Map<String, dynamic> root;
|
||||
|
||||
@override
|
||||
int get bitrate => _bitrate ??= _root.bitrate;
|
||||
late final int? bitrate = root.getT<int>('bitrate');
|
||||
|
||||
@override
|
||||
String get container => _container ??= mimeType.subtype;
|
||||
late final String? container = mimeType?.subtype;
|
||||
|
||||
@override
|
||||
int get contentLength =>
|
||||
_contentLength ??= int.tryParse(_root.contentLength ?? '') ??
|
||||
_contentLenExp.firstMatch(url)?.group(1);
|
||||
late final int? contentLength = int.tryParse(
|
||||
root.getT<String>('contentLength') ??
|
||||
_contentLenExp.firstMatch(url)?.group(1) ??
|
||||
'');
|
||||
|
||||
@override
|
||||
int get framerate => _framerate ??= _root.fps;
|
||||
late final int? framerate = root.getT<int>('fps');
|
||||
|
||||
@override
|
||||
String get signature =>
|
||||
_signature ??= Uri.splitQueryString(_root.signatureCipher ?? '')['s'];
|
||||
late final String? signature =
|
||||
Uri.splitQueryString(root.getT<String>('signatureCipher') ?? '')['s'];
|
||||
|
||||
@override
|
||||
String get signatureParameter =>
|
||||
_signatureParameter ??= Uri.splitQueryString(_root.cipher ?? '')['sp'] ??
|
||||
Uri.splitQueryString(_root.signatureCipher ?? '')['sp'];
|
||||
late final String? signatureParameter = Uri.splitQueryString(
|
||||
root.getT<String>('cipher') ?? '')['sp'] ??
|
||||
Uri.splitQueryString(root.getT<String>('signatureCipher') ?? '')['sp'];
|
||||
|
||||
@override
|
||||
int get tag => _tag ??= _root.itag;
|
||||
late final int tag = root.getT<int>('itag')!;
|
||||
|
||||
@override
|
||||
String get url => _url ??= _getUrl();
|
||||
late final String url = root.getT<String>('url') ??
|
||||
Uri.splitQueryString(root.getT<String>('cipher') ?? '')['url'] ??
|
||||
Uri.splitQueryString(root.getT<String>('signatureCipher') ?? '')['url']!;
|
||||
|
||||
String _getUrl() {
|
||||
var url = _root.url;
|
||||
url ??= Uri.splitQueryString(_root.cipher ?? '')['url'];
|
||||
url ??= Uri.splitQueryString(_root.signatureCipher ?? '')['url'];
|
||||
return url;
|
||||
@override
|
||||
late final String? videoCodec = isAudioOnly
|
||||
? null
|
||||
: codecs?.split(',').firstOrNull?.trim().nullIfWhitespace;
|
||||
|
||||
@override
|
||||
late final int? videoHeight = root.getT<int>('height');
|
||||
|
||||
@override
|
||||
late final String? videoQualityLabel = root.getT<String>('qualityLabel');
|
||||
|
||||
@override
|
||||
late final int? videoWidth = root.getT<int>('width');
|
||||
|
||||
late final bool isAudioOnly = mimeType?.type == 'audio';
|
||||
|
||||
late final MediaType? mimeType = _getMimeType();
|
||||
|
||||
MediaType? _getMimeType() {
|
||||
var mime = root.getT<String>('mimeType');
|
||||
if (mime == null) {
|
||||
return null;
|
||||
}
|
||||
return MediaType.parse(mime);
|
||||
}
|
||||
|
||||
bool _isAudioOnly;
|
||||
MediaType _mimeType;
|
||||
String _codecs;
|
||||
late final String? codecs = mimeType?.parameters['codecs']?.toLowerCase();
|
||||
|
||||
@override
|
||||
String get videoCodec =>
|
||||
isAudioOnly ? null : codecs.split(',').first.trim().nullIfWhitespace;
|
||||
late final String? audioCodec =
|
||||
isAudioOnly ? codecs : _getAudioCodec(codecs?.split(','))?.trim();
|
||||
|
||||
@override
|
||||
int get videoHeight => _root.height;
|
||||
|
||||
@override
|
||||
String get videoQualityLabel => _root.qualityLabel;
|
||||
|
||||
@override
|
||||
int get videoWidth => _root.width;
|
||||
|
||||
bool get isAudioOnly => _isAudioOnly ??= mimeType.type == 'audio';
|
||||
|
||||
MediaType get mimeType => _mimeType ??= MediaType.parse(_root.mimeType);
|
||||
|
||||
String get codecs =>
|
||||
_codecs ??= mimeType?.parameters['codecs']?.toLowerCase();
|
||||
|
||||
@override
|
||||
String get audioCodec =>
|
||||
isAudioOnly ? codecs : _getAudioCodec(codecs.split(','))?.trim();
|
||||
|
||||
String _getAudioCodec(List<String> codecs) {
|
||||
String? _getAudioCodec(List<String>? codecs) {
|
||||
if (codecs == null) {
|
||||
return null;
|
||||
}
|
||||
if (codecs.length == 1) {
|
||||
return null;
|
||||
}
|
||||
return codecs.last;
|
||||
}
|
||||
|
||||
_StreamInfo(this._root);
|
||||
_StreamInfo(this.root);
|
||||
}
|
||||
|
|
|
@ -1,72 +1,67 @@
|
|||
import 'dart:async';
|
||||
|
||||
import '../../exceptions/exceptions.dart';
|
||||
import '../../extensions/helpers_extension.dart';
|
||||
import '../../retry.dart';
|
||||
import '../cipher/cipher_operations.dart';
|
||||
import '../youtube_http_client.dart';
|
||||
|
||||
///
|
||||
class PlayerSource {
|
||||
final RegExp _statIndexExp = RegExp(r'\(\w+,(\d+)\)');
|
||||
static final RegExp _statIndexExp = RegExp(r'\(\w+,(\d+)\)');
|
||||
|
||||
final RegExp _funcBodyExp = RegExp(
|
||||
static final RegExp _funcBodyExp = RegExp(
|
||||
r'(\w+)=function\(\w+\){(\w+)=\2\.split\(\x22{2}\);.*?return\s+\2\.join\(\x22{2}\)}');
|
||||
|
||||
final RegExp _objNameExp = RegExp(r'([\$_\w]+).\w+\(\w+,\d+\);');
|
||||
static final RegExp _objNameExp = RegExp(r'([\$_\w]+).\w+\(\w+,\d+\);');
|
||||
|
||||
final RegExp _calledFuncNameExp = RegExp(r'\w+(?:.|\[)(\"?\w+(?:\")?)\]?\(');
|
||||
static final RegExp _calledFuncNameExp =
|
||||
RegExp(r'\w+(?:.|\[)(\"?\w+(?:\")?)\]?\(');
|
||||
|
||||
final String _root;
|
||||
final String root;
|
||||
|
||||
String _sts;
|
||||
String _deciphererDefinitionBody;
|
||||
late final String sts = getSts();
|
||||
|
||||
///
|
||||
String get sts {
|
||||
if (_sts != null) {
|
||||
return _sts;
|
||||
}
|
||||
|
||||
String getSts() {
|
||||
var val = RegExp(r'(?<=invalid namespace.*?;[\w\s]+=)\d+')
|
||||
.stringMatch(_root)
|
||||
.stringMatch(root)
|
||||
?.nullIfWhitespace ??
|
||||
RegExp(r'(?<=signatureTimestamp[=\:])\d+')
|
||||
.stringMatch(_root)
|
||||
.stringMatch(root)
|
||||
?.nullIfWhitespace;
|
||||
if (val == null) {
|
||||
throw FatalFailureException('Could not find sts in player source.');
|
||||
}
|
||||
return _sts ??= val;
|
||||
return val;
|
||||
}
|
||||
|
||||
///
|
||||
Iterable<CipherOperation> getCiperOperations() sync* {
|
||||
var funcBody = _getDeciphererFuncBody();
|
||||
|
||||
if (funcBody == null) {
|
||||
Iterable<CipherOperation> getCipherOperations() sync* {
|
||||
if (deciphererFuncBody == null) {
|
||||
throw FatalFailureException(
|
||||
'Could not find signature decipherer function body.');
|
||||
}
|
||||
|
||||
var definitionBody = _getDeciphererDefinitionBody(funcBody);
|
||||
var definitionBody = _getDeciphererDefinitionBody(deciphererFuncBody!);
|
||||
if (definitionBody == null) {
|
||||
throw FatalFailureException(
|
||||
'Could not find signature decipherer definition body.');
|
||||
}
|
||||
|
||||
for (var statement in funcBody.split(';')) {
|
||||
for (final statement in deciphererFuncBody!.split(';')) {
|
||||
var calledFuncName = _calledFuncNameExp.firstMatch(statement)?.group(1);
|
||||
if (calledFuncName.isNullOrWhiteSpace) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var escapedFuncName = RegExp.escape(calledFuncName);
|
||||
var escapedFuncName = RegExp.escape(calledFuncName!);
|
||||
// Slice
|
||||
var exp = RegExp('$escapedFuncName'
|
||||
r':\bfunction\b\([a],b\).(\breturn\b)?.?\w+\.');
|
||||
|
||||
if (exp.hasMatch(definitionBody)) {
|
||||
var index = int.parse(_statIndexExp.firstMatch(statement).group(1));
|
||||
var index = int.parse(_statIndexExp.firstMatch(statement)!.group(1)!);
|
||||
yield SliceCipherOperation(index);
|
||||
}
|
||||
|
||||
|
@ -74,7 +69,7 @@ class PlayerSource {
|
|||
exp = RegExp(
|
||||
'$escapedFuncName' r':\bfunction\b\(\w+\,\w\).\bvar\b.\bc=a\b');
|
||||
if (exp.hasMatch(definitionBody)) {
|
||||
var index = int.parse(_statIndexExp.firstMatch(statement).group(1));
|
||||
var index = int.parse(_statIndexExp.firstMatch(statement)!.group(1)!);
|
||||
yield SwapCipherOperation(index);
|
||||
}
|
||||
|
||||
|
@ -86,28 +81,28 @@ class PlayerSource {
|
|||
}
|
||||
}
|
||||
|
||||
String _getDeciphererFuncBody() {
|
||||
return _deciphererDefinitionBody ??=
|
||||
_funcBodyExp.firstMatch(_root).group(0);
|
||||
}
|
||||
late final String? deciphererFuncBody =
|
||||
_funcBodyExp.firstMatch(root)?.group(0);
|
||||
|
||||
String _getDeciphererDefinitionBody(String deciphererFuncBody) {
|
||||
var objName = _objNameExp.firstMatch(deciphererFuncBody).group(1);
|
||||
String? _getDeciphererDefinitionBody(String deciphererFuncBody) {
|
||||
final objName = _objNameExp.firstMatch(deciphererFuncBody)?.group(1);
|
||||
if (objName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var exp = RegExp(
|
||||
final exp = RegExp(
|
||||
r'var\s+'
|
||||
'${RegExp.escape(objName)}'
|
||||
r'=\{(\w+:function\(\w+(,\w+)?\)\{(.*?)\}),?\};',
|
||||
dotAll: true);
|
||||
return exp.firstMatch(_root)?.group(0)?.nullIfWhitespace;
|
||||
return exp.firstMatch(root)?.group(0)?.nullIfWhitespace;
|
||||
}
|
||||
|
||||
///
|
||||
PlayerSource(this._root);
|
||||
PlayerSource(this.root);
|
||||
|
||||
///
|
||||
// Same as default constructor
|
||||
PlayerSource.parse(this._root);
|
||||
/// Same as default constructor
|
||||
PlayerSource.parse(this.root);
|
||||
|
||||
///
|
||||
static Future<PlayerSource> get(
|
||||
|
@ -120,10 +115,10 @@ class PlayerSource {
|
|||
if (_cache[url] == null) {
|
||||
_cache[url] = _CachedValue(val);
|
||||
} else {
|
||||
_cache[url].update(val);
|
||||
_cache[url]!.update(val);
|
||||
}
|
||||
}
|
||||
return _cache[url].value;
|
||||
return _cache[url]!.value;
|
||||
}
|
||||
|
||||
static final Map<String, _CachedValue<PlayerSource>> _cache = {};
|
||||
|
@ -148,9 +143,8 @@ class _CachedValue<T> {
|
|||
|
||||
set value(T other) => _value = other;
|
||||
|
||||
_CachedValue(this._value, [this.expireTime, this.cacheTime = 600000]) {
|
||||
expireTime ??= DateTime.now().millisecondsSinceEpoch + cacheTime;
|
||||
}
|
||||
_CachedValue(this._value, [this.cacheTime = 600000])
|
||||
: expireTime = DateTime.now().millisecondsSinceEpoch + cacheTime;
|
||||
|
||||
void update(T newValue) {
|
||||
var now = DateTime.now().millisecondsSinceEpoch;
|
||||
|
@ -158,17 +152,3 @@ class _CachedValue<T> {
|
|||
value = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
extension on String {
|
||||
String get nullIfWhitespace => trim().isEmpty ? null : this;
|
||||
|
||||
bool get isNullOrWhiteSpace {
|
||||
if (this == null) {
|
||||
return true;
|
||||
}
|
||||
if (trim().isEmpty) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:html/parser.dart' as parser;
|
||||
|
||||
|
@ -10,91 +11,36 @@ import '../youtube_http_client.dart';
|
|||
|
||||
///
|
||||
class PlaylistPage {
|
||||
final _apiKeyExp = RegExp(r'"INNERTUBE_API_KEY":"(\w+?)"');
|
||||
|
||||
///
|
||||
final String playlistId;
|
||||
final Document _root;
|
||||
final Document? root;
|
||||
|
||||
String _apiKey;
|
||||
late final _InitialData initialData = getInitialData();
|
||||
_InitialData? _initialData;
|
||||
|
||||
///
|
||||
String get apiKey => _apiKey ??= _apiKeyExp
|
||||
.firstMatch(_root
|
||||
.querySelectorAll('script')
|
||||
.firstWhere((e) => e.text.contains('INNERTUBE_API_KEY'))
|
||||
.text)
|
||||
.group(1);
|
||||
|
||||
_InitialData _initialData;
|
||||
|
||||
///
|
||||
_InitialData get initialData {
|
||||
_InitialData getInitialData() {
|
||||
if (_initialData != null) {
|
||||
return _initialData;
|
||||
return _initialData!;
|
||||
}
|
||||
|
||||
final scriptText = _root
|
||||
final scriptText = root!
|
||||
.querySelectorAll('script')
|
||||
.map((e) => e.text)
|
||||
.toList(growable: false);
|
||||
|
||||
var initialDataText = scriptText.firstWhere(
|
||||
(e) => e.contains('window["ytInitialData"] ='),
|
||||
orElse: () => null);
|
||||
if (initialDataText != null) {
|
||||
return _initialData = _InitialData(json
|
||||
.decode(_extractJson(initialDataText, 'window["ytInitialData"] =')));
|
||||
}
|
||||
|
||||
initialDataText = scriptText.firstWhere(
|
||||
(e) => e.contains('var ytInitialData = '),
|
||||
orElse: () => null);
|
||||
if (initialDataText != null) {
|
||||
return _initialData = _InitialData(
|
||||
json.decode(_extractJson(initialDataText, 'var ytInitialData = ')));
|
||||
}
|
||||
|
||||
throw TransientFailureException(
|
||||
'Failed to retrieve initial data from the search page, please report this to the project GitHub page.'); // ignore: lines_longer_than_80_chars
|
||||
}
|
||||
|
||||
String _extractJson(String html, String separator) {
|
||||
if (html == null || separator == null) {
|
||||
return null;
|
||||
}
|
||||
var index = html.indexOf(separator) + separator.length;
|
||||
if (index > html.length) {
|
||||
return null;
|
||||
}
|
||||
return _matchJson(html.substring(index));
|
||||
}
|
||||
|
||||
String _matchJson(String str) {
|
||||
var bracketCount = 0;
|
||||
int lastI;
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
lastI = i;
|
||||
if (str[i] == '{') {
|
||||
bracketCount++;
|
||||
} else if (str[i] == '}') {
|
||||
bracketCount--;
|
||||
} else if (str[i] == ';') {
|
||||
if (bracketCount == 0) {
|
||||
return str.substring(0, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return str.substring(0, lastI + 1);
|
||||
return scriptText.extractGenericData(
|
||||
(obj) => _InitialData(obj),
|
||||
() => TransientFailureException(
|
||||
'Failed to retrieve initial data from the search page, please report this to the project GitHub page.'));
|
||||
}
|
||||
|
||||
///
|
||||
PlaylistPage(this._root, this.playlistId,
|
||||
[_InitialData initialData, this._apiKey])
|
||||
PlaylistPage(this.root, this.playlistId, [_InitialData? initialData])
|
||||
: _initialData = initialData;
|
||||
|
||||
///
|
||||
Future<PlaylistPage> nextPage(YoutubeHttpClient httpClient) async {
|
||||
Future<PlaylistPage?> nextPage(YoutubeHttpClient httpClient) async {
|
||||
if (initialData.continuationToken == null) {
|
||||
return null;
|
||||
}
|
||||
|
@ -103,10 +49,10 @@ class PlaylistPage {
|
|||
|
||||
///
|
||||
static Future<PlaylistPage> get(YoutubeHttpClient httpClient, String id,
|
||||
{String token}) {
|
||||
{String? token}) {
|
||||
if (token != null && token.isNotEmpty) {
|
||||
var url =
|
||||
'https://www.youtube.com/youtubei/v1/search?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
|
||||
'https://www.youtube.com/youtubei/v1/browse?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
|
||||
|
||||
return retry(() async {
|
||||
var body = {
|
||||
|
@ -120,7 +66,8 @@ class PlaylistPage {
|
|||
'continuation': token
|
||||
};
|
||||
|
||||
var raw = await httpClient.post(url, body: json.encode(body));
|
||||
var raw =
|
||||
await httpClient.post(Uri.parse(url), body: json.encode(body));
|
||||
return PlaylistPage(null, id, _InitialData(json.decode(raw.body)));
|
||||
});
|
||||
// Ask for next page,
|
||||
|
@ -135,7 +82,7 @@ class PlaylistPage {
|
|||
}
|
||||
|
||||
///
|
||||
PlaylistPage.parse(String raw, this.playlistId) : _root = parser.parse(raw);
|
||||
PlaylistPage.parse(String raw, this.playlistId) : root = parser.parse(raw);
|
||||
}
|
||||
|
||||
class _InitialData {
|
||||
|
@ -144,12 +91,12 @@ class _InitialData {
|
|||
|
||||
_InitialData(this.root);
|
||||
|
||||
String get title => root
|
||||
?.get('metadata')
|
||||
late final String? title = root
|
||||
.get('metadata')
|
||||
?.get('playlistMetadataRenderer')
|
||||
?.getT<String>('title');
|
||||
|
||||
String get author => root
|
||||
late final String? author = root
|
||||
.get('sidebar')
|
||||
?.get('playlistSidebarRenderer')
|
||||
?.getList('items')
|
||||
|
@ -161,13 +108,13 @@ class _InitialData {
|
|||
?.getT<List<dynamic>>('runs')
|
||||
?.parseRuns();
|
||||
|
||||
String get description => root
|
||||
?.get('metadata')
|
||||
late final String? description = root
|
||||
.get('metadata')
|
||||
?.get('playlistMetadataRenderer')
|
||||
?.getT<String>('description');
|
||||
|
||||
int get viewCount => root
|
||||
?.get('sidebar')
|
||||
late final int? viewCount = root
|
||||
.get('sidebar')
|
||||
?.get('playlistSidebarRenderer')
|
||||
?.getList('items')
|
||||
?.firstOrNull
|
||||
|
@ -177,15 +124,15 @@ class _InitialData {
|
|||
?.getT<String>('simpleText')
|
||||
?.parseInt();
|
||||
|
||||
String get continuationToken => (videosContent ?? playlistVideosContent)
|
||||
?.firstWhere((e) => e['continuationItemRenderer'] != null,
|
||||
orElse: () => null)
|
||||
?.get('continuationItemRenderer')
|
||||
?.get('continuationEndpoint')
|
||||
?.get('continuationCommand')
|
||||
?.getT<String>('token');
|
||||
late final String? continuationToken =
|
||||
(videosContent ?? playlistVideosContent)
|
||||
?.firstWhereOrNull((e) => e['continuationItemRenderer'] != null)
|
||||
?.get('continuationItemRenderer')
|
||||
?.get('continuationEndpoint')
|
||||
?.get('continuationCommand')
|
||||
?.getT<String>('token');
|
||||
|
||||
List<Map<String, dynamic>> get playlistVideosContent =>
|
||||
List<Map<String, dynamic>>? get playlistVideosContent =>
|
||||
root
|
||||
.get('contents')
|
||||
?.get('twoColumnBrowseResultsRenderer')
|
||||
|
@ -205,26 +152,25 @@ class _InitialData {
|
|||
.getList('onResponseReceivedActions')
|
||||
?.firstOrNull
|
||||
?.get('appendContinuationItemsAction')
|
||||
?.get('continuationItems');
|
||||
?.getList('continuationItems');
|
||||
|
||||
List<Map<String, dynamic>> get videosContent =>
|
||||
root
|
||||
late final List<Map<String, dynamic>>? videosContent = root
|
||||
.get('contents')
|
||||
?.get('twoColumnSearchResultsRenderer')
|
||||
?.get('primaryContents')
|
||||
?.get('sectionListRenderer')
|
||||
?.getList('contents') ??
|
||||
root
|
||||
?.getList('onResponseReceivedCommands')
|
||||
.getList('onResponseReceivedCommands')
|
||||
?.firstOrNull
|
||||
?.get('appendContinuationItemsAction')
|
||||
?.get('continuationItems');
|
||||
?.getList('continuationItems');
|
||||
|
||||
List<_Video> get playlistVideos =>
|
||||
playlistVideosContent
|
||||
?.where((e) => e['playlistVideoRenderer'] != null)
|
||||
?.map((e) => _Video(e['playlistVideoRenderer']))
|
||||
?.toList() ??
|
||||
.map((e) => _Video(e['playlistVideoRenderer']))
|
||||
.toList() ??
|
||||
const [];
|
||||
|
||||
List<_Video> get videos =>
|
||||
|
@ -232,8 +178,8 @@ class _InitialData {
|
|||
?.get('itemSectionRenderer')
|
||||
?.getList('contents')
|
||||
?.where((e) => e['videoRenderer'] != null)
|
||||
?.map((e) => _Video(e))
|
||||
?.toList() ??
|
||||
.map((e) => _Video(e))
|
||||
.toList() ??
|
||||
const [];
|
||||
}
|
||||
|
||||
|
@ -243,11 +189,11 @@ class _Video {
|
|||
|
||||
_Video(this.root);
|
||||
|
||||
String get id => root?.getT<String>('videoId');
|
||||
String get id => root.getT<String>('videoId')!;
|
||||
|
||||
String get author =>
|
||||
root?.get('ownerText')?.getT<List<dynamic>>('runs')?.parseRuns() ??
|
||||
root?.get('shortBylineText')?.getT<List<dynamic>>('runs')?.parseRuns() ??
|
||||
root.get('ownerText')?.getT<List<dynamic>>('runs')?.parseRuns() ??
|
||||
root.get('shortBylineText')?.getT<List<dynamic>>('runs')?.parseRuns() ??
|
||||
'';
|
||||
|
||||
String get channelId =>
|
||||
|
@ -272,14 +218,14 @@ class _Video {
|
|||
String get description =>
|
||||
root.getList('descriptionSnippet')?.parseRuns() ?? '';
|
||||
|
||||
Duration get duration =>
|
||||
Duration? get duration =>
|
||||
_stringToDuration(root.get('lengthText')?.getT<String>('simpleText'));
|
||||
|
||||
int get viewCount =>
|
||||
root.get('viewCountText')?.getT<String>('simpleText')?.parseInt() ?? 0;
|
||||
|
||||
/// Format: HH:MM:SS
|
||||
static Duration _stringToDuration(String string) {
|
||||
static Duration? _stringToDuration(String? string) {
|
||||
if (string == null || string.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -1,106 +1,50 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:html/parser.dart' as parser;
|
||||
import 'package:youtube_explode_dart/src/search/search_channel.dart';
|
||||
|
||||
import '../../../youtube_explode_dart.dart';
|
||||
import '../../extensions/helpers_extension.dart';
|
||||
import '../../retry.dart';
|
||||
import '../../search/base_search_content.dart';
|
||||
import '../../search/related_query.dart';
|
||||
import '../../search/search_filter.dart';
|
||||
import '../../search/search_video.dart';
|
||||
import '../../videos/videos.dart';
|
||||
import '../youtube_http_client.dart';
|
||||
import 'generated/search_page_id.g.dart' hide PlaylistId;
|
||||
|
||||
///
|
||||
class SearchPage {
|
||||
final _apiKeyExp = RegExp(r'"INNERTUBE_API_KEY":"(\w+?)"');
|
||||
|
||||
///
|
||||
final String queryString;
|
||||
final Document _root;
|
||||
final Document? root;
|
||||
|
||||
String _apiKey;
|
||||
late final _InitialData initialData = getInitialData();
|
||||
_InitialData? _initialData;
|
||||
|
||||
///
|
||||
String get apiKey => _apiKey ??= _apiKeyExp
|
||||
.firstMatch(_root
|
||||
.querySelectorAll('script')
|
||||
.firstWhere((e) => e.text.contains('INNERTUBE_API_KEY'))
|
||||
.text)
|
||||
.group(1);
|
||||
|
||||
_InitialData _initialData;
|
||||
|
||||
///
|
||||
_InitialData get initialData {
|
||||
_InitialData getInitialData() {
|
||||
if (_initialData != null) {
|
||||
return _initialData;
|
||||
return _initialData!;
|
||||
}
|
||||
|
||||
final scriptText = _root
|
||||
final scriptText = root!
|
||||
.querySelectorAll('script')
|
||||
.map((e) => e.text)
|
||||
.toList(growable: false);
|
||||
|
||||
var initialDataText = scriptText.firstWhere(
|
||||
(e) => e.contains('window["ytInitialData"] ='),
|
||||
orElse: () => null);
|
||||
if (initialDataText != null) {
|
||||
return _initialData = _InitialData(SearchPageId.fromRawJson(
|
||||
_extractJson(initialDataText, 'window["ytInitialData"] =')));
|
||||
}
|
||||
|
||||
initialDataText = scriptText.firstWhere(
|
||||
(e) => e.contains('var ytInitialData = '),
|
||||
orElse: () => null);
|
||||
if (initialDataText != null) {
|
||||
return _initialData = _InitialData(SearchPageId.fromRawJson(
|
||||
_extractJson(initialDataText, 'var ytInitialData = ')));
|
||||
}
|
||||
|
||||
throw TransientFailureException(
|
||||
'Failed to retrieve initial data from the search page, please report this to the project GitHub page.'); // ignore: lines_longer_than_80_chars
|
||||
}
|
||||
|
||||
String _extractJson(String html, String separator) {
|
||||
if (html == null || separator == null) {
|
||||
return null;
|
||||
}
|
||||
var index = html.indexOf(separator) + separator.length;
|
||||
if (index > html.length) {
|
||||
return null;
|
||||
}
|
||||
return _matchJson(html.substring(index));
|
||||
}
|
||||
|
||||
String _matchJson(String str) {
|
||||
var bracketCount = 0;
|
||||
int lastI;
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
lastI = i;
|
||||
if (str[i] == '{') {
|
||||
bracketCount++;
|
||||
} else if (str[i] == '}') {
|
||||
bracketCount--;
|
||||
} else if (str[i] == ';') {
|
||||
if (bracketCount == 0) {
|
||||
return str.substring(0, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return str.substring(0, lastI + 1);
|
||||
return scriptText.extractGenericData(
|
||||
(obj) => _InitialData(obj),
|
||||
() => TransientFailureException(
|
||||
'Failed to retrieve initial data from the search page, please report this to the project GitHub page.'));
|
||||
}
|
||||
|
||||
///
|
||||
SearchPage(this._root, this.queryString,
|
||||
[_InitialData initialData, this._apiKey])
|
||||
SearchPage(this.root, this.queryString, [_InitialData? initialData])
|
||||
: _initialData = initialData;
|
||||
|
||||
///
|
||||
// TODO: Replace this in favour of async* when quering;
|
||||
Future<SearchPage> nextPage(YoutubeHttpClient httpClient) async {
|
||||
Future<SearchPage?> nextPage(YoutubeHttpClient httpClient) async {
|
||||
if (initialData.continuationToken == '' ||
|
||||
initialData.estimatedResults == 0) {
|
||||
return null;
|
||||
|
@ -111,7 +55,7 @@ class SearchPage {
|
|||
///
|
||||
static Future<SearchPage> get(
|
||||
YoutubeHttpClient httpClient, String queryString,
|
||||
{String token}) {
|
||||
{String? token, SearchFilter filter = const SearchFilter('')}) {
|
||||
if (token != null) {
|
||||
var url =
|
||||
'https://www.youtube.com/youtubei/v1/search?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
|
||||
|
@ -128,15 +72,16 @@ class SearchPage {
|
|||
'continuation': token
|
||||
};
|
||||
|
||||
var raw = await httpClient.post(url, body: json.encode(body));
|
||||
return SearchPage(null, queryString,
|
||||
_InitialData(SearchPageId.fromJson(json.decode(raw.body))));
|
||||
var raw =
|
||||
await httpClient.post(Uri.parse(url), body: json.encode(body));
|
||||
return SearchPage(
|
||||
null, queryString, _InitialData(json.decode(raw.body)));
|
||||
});
|
||||
// Ask for next page,
|
||||
|
||||
}
|
||||
var url =
|
||||
'https://www.youtube.com/results?search_query=${Uri.encodeQueryComponent(queryString)}';
|
||||
'https://www.youtube.com/results?search_query=${Uri.encodeQueryComponent(queryString)}&sp=${filter.value}';
|
||||
return retry(() async {
|
||||
var raw = await httpClient.getString(url);
|
||||
return SearchPage.parse(raw, queryString);
|
||||
|
@ -145,137 +90,182 @@ class SearchPage {
|
|||
}
|
||||
|
||||
///
|
||||
SearchPage.parse(String raw, this.queryString) : _root = parser.parse(raw);
|
||||
SearchPage.parse(String raw, this.queryString) : root = parser.parse(raw);
|
||||
}
|
||||
|
||||
class _InitialData {
|
||||
// Json parsed map
|
||||
final SearchPageId root;
|
||||
final Map<String, dynamic> root;
|
||||
|
||||
_InitialData(this.root);
|
||||
|
||||
List<PurpleContent> getContentContext() {
|
||||
if (root.contents != null) {
|
||||
return root.contents.twoColumnSearchResultsRenderer.primaryContents
|
||||
.sectionListRenderer.contents.first.itemSectionRenderer.contents;
|
||||
List<Map<String, dynamic>>? getContentContext() {
|
||||
if (root['contents'] != null) {
|
||||
return root
|
||||
.get('contents')
|
||||
?.get('twoColumnSearchResultsRenderer')
|
||||
?.get('primaryContents')
|
||||
?.get('sectionListRenderer')
|
||||
?.getList('contents')
|
||||
?.firstOrNull
|
||||
?.get('itemSectionRenderer')
|
||||
?.getList('contents');
|
||||
}
|
||||
if (root.onResponseReceivedCommands != null) {
|
||||
final itemSection = root
|
||||
.onResponseReceivedCommands
|
||||
.first
|
||||
.appendContinuationItemsAction
|
||||
.continuationItems[0]
|
||||
.itemSectionRenderer;
|
||||
if (itemSection == null) {
|
||||
throw SearchItemSectionException();
|
||||
}
|
||||
return itemSection.contents;
|
||||
if (root['onResponseReceivedCommands'] != null) {
|
||||
return root
|
||||
.getList('onResponseReceivedCommands')
|
||||
?.firstOrNull
|
||||
?.get('appendContinuationItemsAction')
|
||||
?.getList('continuationItems')
|
||||
?.firstOrNull
|
||||
?.get('itemSectionRenderer')
|
||||
?.getList('contents');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _getContinuationToken() {
|
||||
if (root.contents != null) {
|
||||
var contents = root.contents.twoColumnSearchResultsRenderer
|
||||
.primaryContents.sectionListRenderer.contents;
|
||||
String? _getContinuationToken() {
|
||||
if (root['contents'] != null) {
|
||||
var contents = root
|
||||
.get('contents')
|
||||
?.get('twoColumnSearchResultsRenderer')
|
||||
?.get('primaryContents')
|
||||
?.get('sectionListRenderer')
|
||||
?.getList('contents');
|
||||
|
||||
if (contents.length <= 1) {
|
||||
if (contents == null || contents.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
return contents[1]
|
||||
.continuationItemRenderer
|
||||
.continuationEndpoint
|
||||
.continuationCommand
|
||||
.token;
|
||||
return contents
|
||||
.elementAtSafe(1)
|
||||
?.get('continuationItemRenderer')
|
||||
?.get('continuationEndpoint')
|
||||
?.get('continuationCommand')
|
||||
?.getT<String>('token');
|
||||
}
|
||||
if (root.onResponseReceivedCommands != null) {
|
||||
if (root['onResponseReceivedCommands'] != null) {
|
||||
return root
|
||||
.onResponseReceivedCommands
|
||||
.first
|
||||
.appendContinuationItemsAction
|
||||
.continuationItems[1]
|
||||
?.continuationItemRenderer
|
||||
?.continuationEndpoint
|
||||
?.continuationCommand
|
||||
?.token ??
|
||||
' ';
|
||||
.getList('onResponseReceivedCommands')
|
||||
?.firstOrNull
|
||||
?.get('appendContinuationItemsAction')
|
||||
?.getList('continuationItems')
|
||||
?.elementAtSafe(1)
|
||||
?.get('continuationItemRenderer')
|
||||
?.get('continuationEndpoint')
|
||||
?.get('continuationCommand')
|
||||
?.getT<String>('token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Contains only [SearchVideo] or [SearchPlaylist]
|
||||
List<BaseSearchContent> get searchContent =>
|
||||
getContentContext().map(_parseContent).where((e) => e != null).toList();
|
||||
late final List<BaseSearchContent> searchContent =
|
||||
getContentContext()?.map(_parseContent).whereNotNull().toList() ??
|
||||
const [];
|
||||
|
||||
List<RelatedQuery> get relatedQueries =>
|
||||
getContentContext()
|
||||
?.where((e) => e.horizontalCardListRenderer != null)
|
||||
?.map((e) => e.horizontalCardListRenderer.cards)
|
||||
?.firstOrNull
|
||||
?.map((e) => e.searchRefinementCardRenderer)
|
||||
?.map((e) => RelatedQuery(
|
||||
?.where((e) => e['horizontalCardListRenderer'] != null)
|
||||
.map((e) => e.get('horizontalCardListRenderer')?.getList('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<RelatedQuery>() ??
|
||||
.toList()
|
||||
.cast<RelatedQuery>() ??
|
||||
const [];
|
||||
|
||||
List<dynamic> get relatedVideos =>
|
||||
getContentContext()
|
||||
?.where((e) => e.shelfRenderer != null)
|
||||
?.map((e) => e.shelfRenderer.content.verticalListRenderer.items)
|
||||
?.firstOrNull
|
||||
?.where((e) => e['shelfRenderer'] != null)
|
||||
.map((e) => e
|
||||
.get('shelfRenderer')
|
||||
?.get('content')
|
||||
?.get('verticalListRenderer')
|
||||
?.getList('items'))
|
||||
.firstOrNull
|
||||
?.map(_parseContent)
|
||||
?.toList() ??
|
||||
.whereNotNull()
|
||||
.toList() ??
|
||||
const [];
|
||||
|
||||
String get continuationToken => _getContinuationToken();
|
||||
late final String? continuationToken = _getContinuationToken();
|
||||
|
||||
int get estimatedResults => int.parse(root.estimatedResults ?? 0);
|
||||
late final int estimatedResults =
|
||||
int.parse(root.getT<String>('estimatedResults') ?? '0');
|
||||
|
||||
BaseSearchContent _parseContent(PurpleContent content) {
|
||||
BaseSearchContent? _parseContent(Map<String, dynamic>? content) {
|
||||
if (content == null) {
|
||||
return null;
|
||||
}
|
||||
if (content.videoRenderer != null) {
|
||||
var renderer = content.videoRenderer;
|
||||
//TODO: Add if it's a live
|
||||
if (content['videoRenderer'] != null) {
|
||||
var renderer = content.get('videoRenderer')!;
|
||||
|
||||
return SearchVideo(
|
||||
VideoId(renderer.videoId),
|
||||
_parseRuns(renderer.title.runs),
|
||||
_parseRuns(renderer.ownerText.runs),
|
||||
_parseRuns(renderer.descriptionSnippet?.runs),
|
||||
renderer.lengthText?.simpleText ?? '',
|
||||
int.parse(renderer.viewCountText?.simpleText
|
||||
VideoId(renderer.getT<String>('videoId')!),
|
||||
_parseRuns(renderer.get('title')?.getList('runs')),
|
||||
_parseRuns(renderer.get('ownerText')?.getList('runs')),
|
||||
_parseRuns(renderer.get('descriptionSnippet')?.getList('runs')),
|
||||
renderer.get('lengthText')?.getT<String>('simpleText') ?? '',
|
||||
int.parse(renderer
|
||||
.get('viewCountText')
|
||||
?.getT<String>('simpleText')
|
||||
?.stripNonDigits()
|
||||
?.nullIfWhitespace ??
|
||||
renderer.viewCountText?.runs?.first?.text
|
||||
.nullIfWhitespace ??
|
||||
renderer
|
||||
.get('viewCountText')
|
||||
?.getList('runs')
|
||||
?.firstOrNull
|
||||
?.getT<String>('text')
|
||||
?.stripNonDigits()
|
||||
?.nullIfWhitespace ??
|
||||
.nullIfWhitespace ??
|
||||
'0'),
|
||||
(renderer.thumbnail.thumbnails ?? <ThumbnailElement>[])
|
||||
.map((e) => Thumbnail(Uri.parse(e.url), e.height, e.width))
|
||||
(renderer.get('thumbnail')?.getList('thumbnails') ?? const [])
|
||||
.map((e) =>
|
||||
Thumbnail(Uri.parse(e['url']), e['height'], e['width']))
|
||||
.toList(),
|
||||
renderer.publishedTimeText?.simpleText,
|
||||
renderer?.viewCountText?.runs?.elementAt(1)?.text?.trim() ==
|
||||
'watching');
|
||||
renderer.get('publishedTimeText')?.getT<String>('simpleText'),
|
||||
renderer
|
||||
.get('viewCountText')
|
||||
?.getList('runs')
|
||||
?.elementAtSafe(1)
|
||||
?.getT<String>('text')
|
||||
?.trim() ==
|
||||
'watching',
|
||||
renderer['ownerText']['runs'][0]['navigationEndpoint']
|
||||
['browseEndpoint']['browseId']);
|
||||
}
|
||||
if (content.radioRenderer != null) {
|
||||
var renderer = content.radioRenderer;
|
||||
if (content['radioRenderer'] != null) {
|
||||
var renderer = content.get('radioRenderer')!;
|
||||
|
||||
return SearchPlaylist(
|
||||
PlaylistId(renderer.playlistId),
|
||||
renderer.title.simpleText,
|
||||
int.parse(_parseRuns(renderer.videoCountText.runs)
|
||||
PlaylistId(renderer.getT<String>('playlistId')!),
|
||||
renderer.get('title')!.getT<String>('simpleText')!,
|
||||
int.parse(_parseRuns(renderer.get('videoCountText')?.getList('runs'))
|
||||
.stripNonDigits()
|
||||
.nullIfWhitespace ??
|
||||
'0'));
|
||||
}
|
||||
if (content['channelRenderer'] != null) {
|
||||
var renderer = content.get('channelRenderer')!;
|
||||
return SearchChannel(
|
||||
ChannelId(renderer.getT<String>('channelId')!),
|
||||
renderer.get('title')!.getT<String>('simpleText')!,
|
||||
renderer.get('descriptionSnippet')?.getList('runs')?.parseRuns() ??
|
||||
'',
|
||||
renderer
|
||||
.get('videoCountText')!
|
||||
.getList('runs')!
|
||||
.first
|
||||
.getT<String>('text')!
|
||||
.parseInt()!);
|
||||
}
|
||||
// Here ignore 'horizontalCardListRenderer' & 'shelfRenderer'
|
||||
return null;
|
||||
}
|
||||
|
||||
String _parseRuns(List<dynamic> runs) =>
|
||||
runs?.map((e) => e.text)?.join() ?? '';
|
||||
String _parseRuns(List<dynamic>? runs) =>
|
||||
runs?.map((e) => e['text']).join() ?? '';
|
||||
}
|
||||
|
|
|
@ -10,57 +10,35 @@ abstract class StreamInfoProvider {
|
|||
String get url;
|
||||
|
||||
///
|
||||
// Can be null
|
||||
// ignore: avoid_returning_null
|
||||
String get signature => null;
|
||||
String? get signature => null;
|
||||
|
||||
///
|
||||
// Can be null
|
||||
// ignore: avoid_returning_null
|
||||
String get signatureParameter => null;
|
||||
String? get signatureParameter => null;
|
||||
|
||||
///
|
||||
// Can be null
|
||||
// ignore: avoid_returning_null
|
||||
int get contentLength => null;
|
||||
int? get contentLength => null;
|
||||
|
||||
///
|
||||
// Can be null
|
||||
// ignore: avoid_returning_null
|
||||
int get bitrate;
|
||||
int? get bitrate;
|
||||
|
||||
///
|
||||
// Can be null
|
||||
// ignore: avoid_returning_null
|
||||
String get container;
|
||||
String? get container;
|
||||
|
||||
///
|
||||
// Can be null
|
||||
// ignore: avoid_returning_null
|
||||
String get audioCodec => null;
|
||||
String? get audioCodec => null;
|
||||
|
||||
///
|
||||
// Can be null
|
||||
// ignore: avoid_returning_null
|
||||
String get videoCodec => null;
|
||||
String? get videoCodec => null;
|
||||
|
||||
///
|
||||
// Can be null
|
||||
// ignore: avoid_returning_null
|
||||
String get videoQualityLabel => null;
|
||||
String? get videoQualityLabel => null;
|
||||
|
||||
///
|
||||
// Can be null
|
||||
// ignore: avoid_returning_null
|
||||
int get videoWidth => null;
|
||||
int? get videoWidth => null;
|
||||
|
||||
///
|
||||
// Can be null
|
||||
// ignore: avoid_returning_null
|
||||
int get videoHeight => null;
|
||||
int? get videoHeight => null;
|
||||
|
||||
///
|
||||
// Can be null
|
||||
// ignore: avoid_returning_null
|
||||
int get framerate => null;
|
||||
int? get framerate => null;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:http_parser/http_parser.dart';
|
||||
|
||||
import '../../exceptions/exceptions.dart';
|
||||
import '../../extensions/helpers_extension.dart';
|
||||
import '../../retry.dart';
|
||||
import '../youtube_http_client.dart';
|
||||
import 'player_response.dart';
|
||||
|
@ -8,56 +9,49 @@ import 'stream_info_provider.dart';
|
|||
|
||||
///
|
||||
class VideoInfoResponse {
|
||||
final Map<String, String> _root;
|
||||
|
||||
String _status;
|
||||
bool _isVideoAvailable;
|
||||
PlayerResponse _playerResponse;
|
||||
Iterable<_StreamInfo> _muxedStreams;
|
||||
Iterable<_StreamInfo> _adaptiveStreams;
|
||||
Iterable<_StreamInfo> _streams;
|
||||
final Map<String, String> root;
|
||||
|
||||
///
|
||||
String get status => _status ??= _root['status'];
|
||||
late final String status = root['status']!;
|
||||
|
||||
///
|
||||
bool get isVideoAvailable =>
|
||||
_isVideoAvailable ??= status.toLowerCase() != 'fail';
|
||||
late final bool isVideoAvailable = status.toLowerCase() != 'fail';
|
||||
|
||||
///
|
||||
PlayerResponse get playerResponse =>
|
||||
_playerResponse ??= PlayerResponse.parse(_root['player_response']);
|
||||
late final PlayerResponse playerResponse =
|
||||
PlayerResponse.parse(root['player_response']!);
|
||||
|
||||
///
|
||||
Iterable<_StreamInfo> get muxedStreams =>
|
||||
_muxedStreams ??= _root['url_encoded_fmt_stream_map']
|
||||
late final Iterable<_StreamInfo> muxedStreams =
|
||||
root['url_encoded_fmt_stream_map']
|
||||
?.split(',')
|
||||
?.map(Uri.splitQueryString)
|
||||
?.map((e) => _StreamInfo(e)) ??
|
||||
.map(Uri.splitQueryString)
|
||||
.map((e) => _StreamInfo(e)) ??
|
||||
const [];
|
||||
|
||||
///
|
||||
Iterable<_StreamInfo> get adaptiveStreams =>
|
||||
_adaptiveStreams ??= _root['adaptive_fmts']
|
||||
?.split(',')
|
||||
?.map(Uri.splitQueryString)
|
||||
?.map((e) => _StreamInfo(e)) ??
|
||||
const [];
|
||||
late final Iterable<_StreamInfo> adaptiveStreams = root['adaptive_fmts']
|
||||
?.split(',')
|
||||
.map(Uri.splitQueryString)
|
||||
.map((e) => _StreamInfo(e)) ??
|
||||
const [];
|
||||
|
||||
///
|
||||
Iterable<_StreamInfo> get streams =>
|
||||
_streams ??= [...muxedStreams, ...adaptiveStreams];
|
||||
late final Iterable<_StreamInfo> streams = [
|
||||
...muxedStreams,
|
||||
...adaptiveStreams
|
||||
];
|
||||
|
||||
///
|
||||
VideoInfoResponse(this._root);
|
||||
VideoInfoResponse(this.root);
|
||||
|
||||
///
|
||||
VideoInfoResponse.parse(String raw) : _root = Uri.splitQueryString(raw);
|
||||
VideoInfoResponse.parse(String raw) : root = Uri.splitQueryString(raw);
|
||||
|
||||
///
|
||||
static Future<VideoInfoResponse> get(
|
||||
YoutubeHttpClient httpClient, String videoId,
|
||||
[String sts]) {
|
||||
[String? sts]) {
|
||||
var eurl = Uri.encodeFull('https://youtube.googleapis.com/v/$videoId');
|
||||
var url =
|
||||
'https://youtube.com/get_video_info?video_id=$videoId&el=embedded&eurl=$eurl&hl=en${sts != null ? '&sts=$sts' : ''}';
|
||||
|
@ -74,75 +68,65 @@ class VideoInfoResponse {
|
|||
}
|
||||
|
||||
class _StreamInfo extends StreamInfoProvider {
|
||||
final Map<String, String> _root;
|
||||
|
||||
int _tag;
|
||||
String _url;
|
||||
String _signature;
|
||||
String _signatureParameter;
|
||||
int _contentLength;
|
||||
int _bitrate;
|
||||
MediaType _mimeType;
|
||||
String _container;
|
||||
List<String> _codecs;
|
||||
String _audioCodec;
|
||||
String _videoCodec;
|
||||
bool _isAudioOnly;
|
||||
String _videoQualityLabel;
|
||||
List<int> __size;
|
||||
int _videoWidth;
|
||||
int _videoHeight;
|
||||
int _framerate;
|
||||
final Map<String, String> root;
|
||||
|
||||
@override
|
||||
int get tag => _tag ??= int.parse(_root['itag']);
|
||||
late final int tag = int.parse(root['itag']!);
|
||||
|
||||
@override
|
||||
String get url => _url ??= _root['url'];
|
||||
late final String url = root['url']!;
|
||||
|
||||
@override
|
||||
String get signature => _signature ??= _root['s'];
|
||||
late final String? signature = root['s'];
|
||||
|
||||
@override
|
||||
String get signatureParameter => _signatureParameter ??= _root['sp'];
|
||||
late final String? signatureParameter = root['sp'];
|
||||
|
||||
@override
|
||||
int get contentLength => _contentLength ??= int.tryParse(_root['clen'] ??
|
||||
StreamInfoProvider.contentLenExp.firstMatch(url).group(1));
|
||||
late final int? contentLength = int.tryParse(root['clen'] ??
|
||||
StreamInfoProvider.contentLenExp.firstMatch(url)?.group(1) ??
|
||||
'');
|
||||
|
||||
@override
|
||||
int get bitrate => _bitrate ??= int.parse(_root['bitrate']);
|
||||
late final int? bitrate = int.tryParse(root['bitrate'] ?? '');
|
||||
|
||||
MediaType get mimeType => _mimeType ??= MediaType.parse(_root['type']);
|
||||
late final MediaType mimeType = MediaType.parse(root['type']!);
|
||||
|
||||
@override
|
||||
String get container => _container ??= mimeType.subtype;
|
||||
late final String container = mimeType.subtype;
|
||||
|
||||
List<String> get codecs =>
|
||||
_codecs ??= mimeType.parameters['codecs'].split(',').map((e) => e.trim());
|
||||
late final List<String> codecs = mimeType.parameters['codecs']!
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.toList()
|
||||
.cast<String>();
|
||||
|
||||
@override
|
||||
String get audioCodec => _audioCodec ??= codecs.last;
|
||||
late final String audioCodec = codecs.last;
|
||||
|
||||
@override
|
||||
String get videoCodec => _videoCodec ??= isAudioOnly ? null : codecs.first;
|
||||
late final String? videoCodec = isAudioOnly ? null : codecs.first;
|
||||
|
||||
bool get isAudioOnly => _isAudioOnly ??= mimeType.type == 'audio';
|
||||
late final bool isAudioOnly = mimeType.type == 'audio';
|
||||
|
||||
@override
|
||||
String get videoQualityLabel => _videoQualityLabel ??= _root['quality_label'];
|
||||
late final String? videoQualityLabel = root['quality_label'];
|
||||
|
||||
List<int> get _size =>
|
||||
__size ??= _root['size'].split(',').map((e) => int.tryParse(e ?? ''));
|
||||
late final List<int>? _size = root
|
||||
.getT<String>('size')
|
||||
?.split(',')
|
||||
.map((e) => int.tryParse(e))
|
||||
.toList()
|
||||
.cast<int>();
|
||||
|
||||
@override
|
||||
int get videoWidth => _videoWidth ??= _size.first;
|
||||
late final int? videoWidth = _size?.first;
|
||||
|
||||
@override
|
||||
int get videoHeight => _videoHeight ??= _size.last;
|
||||
late final int? videoHeight = _size?.last;
|
||||
|
||||
@override
|
||||
int get framerate => _framerate ??= int.tryParse(_root['fps'] ?? '');
|
||||
late final int? framerate = int.tryParse(root['fps'] ?? '');
|
||||
|
||||
_StreamInfo(this._root);
|
||||
_StreamInfo(this.root);
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:html/parser.dart' as parser;
|
||||
|
||||
|
@ -6,8 +7,6 @@ import '../../extensions/helpers_extension.dart';
|
|||
import '../../retry.dart';
|
||||
import '../../videos/video_id.dart';
|
||||
import '../youtube_http_client.dart';
|
||||
import 'generated/player_response_json.g.dart';
|
||||
import 'generated/watch_page_id.g.dart';
|
||||
import 'player_config_base.dart';
|
||||
import 'player_response.dart';
|
||||
|
||||
|
@ -25,7 +24,7 @@ class WatchPage {
|
|||
|
||||
static final _xsfrTokenExp = RegExp(r'"XSRF_TOKEN"\s*:\s*"(.+?)"');
|
||||
|
||||
final Document _root;
|
||||
final Document root;
|
||||
|
||||
///
|
||||
final String visitorInfoLive;
|
||||
|
@ -33,122 +32,122 @@ class WatchPage {
|
|||
///
|
||||
final String ysc;
|
||||
|
||||
_InitialData _initialData;
|
||||
String _xsfrToken;
|
||||
WatchPlayerConfig _playerConfig;
|
||||
_InitialData? _initialData;
|
||||
|
||||
///
|
||||
String get sourceUrl {
|
||||
var url = _root
|
||||
String? get sourceUrl {
|
||||
var url = root
|
||||
.querySelectorAll('script')
|
||||
.map((e) => e.attributes['src'])
|
||||
.where((e) => !e.isNullOrWhiteSpace)
|
||||
.firstWhere((e) => e.contains('player_ias') && e.endsWith('.js'),
|
||||
orElse: () => null);
|
||||
.whereNotNull()
|
||||
.firstWhereOrNull((e) => e.contains('player_ias') && e.endsWith('.js'));
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
return 'https://youtube.com$url';
|
||||
}
|
||||
|
||||
late final _InitialData initialData = getInitialData();
|
||||
|
||||
///
|
||||
_InitialData get initialData {
|
||||
_InitialData getInitialData() {
|
||||
if (_initialData != null) {
|
||||
return _initialData;
|
||||
return _initialData!;
|
||||
}
|
||||
|
||||
final scriptText = _root
|
||||
final scriptText = root
|
||||
.querySelectorAll('script')
|
||||
.map((e) => e.text)
|
||||
.toList(growable: false);
|
||||
return scriptText.extractGenericData(
|
||||
(obj) => _InitialData(obj),
|
||||
() => TransientFailureException(
|
||||
'Failed to retrieve initial data from the watch page, please report this to the project GitHub page.'));
|
||||
}
|
||||
|
||||
var initialDataText = scriptText.firstWhere(
|
||||
(e) => e.contains('window["ytInitialData"] ='),
|
||||
orElse: () => null);
|
||||
if (initialDataText != null) {
|
||||
return _initialData = _InitialData(WatchPageId.fromRawJson(
|
||||
_extractJson(initialDataText, 'window["ytInitialData"] =')));
|
||||
}
|
||||
late final String xsfrToken = getXsfrToken()!;
|
||||
|
||||
initialDataText = scriptText.firstWhere(
|
||||
(e) => e.contains('var ytInitialData = '),
|
||||
orElse: () => null);
|
||||
if (initialDataText != null) {
|
||||
return _initialData = _InitialData(WatchPageId.fromRawJson(
|
||||
_extractJson(initialDataText, 'var ytInitialData = ')));
|
||||
}
|
||||
|
||||
throw TransientFailureException(
|
||||
'Failed to retrieve initial data from the watch page, please report this to the project GitHub page.'); // ignore: lines_longer_than_80_chars
|
||||
///
|
||||
String? getXsfrToken() {
|
||||
return _xsfrTokenExp
|
||||
.firstMatch(root
|
||||
.querySelectorAll('script')
|
||||
.firstWhere((e) => _xsfrTokenExp.hasMatch(e.text))
|
||||
.text)
|
||||
?.group(1);
|
||||
}
|
||||
|
||||
///
|
||||
String get xsfrToken => _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;
|
||||
|
||||
///
|
||||
bool get isVideoAvailable =>
|
||||
_root.querySelector('meta[property="og:url"]') != null;
|
||||
root.querySelector('meta[property="og:url"]') != null;
|
||||
|
||||
///
|
||||
int get videoLikeCount => int.parse(_videoLikeExp
|
||||
.firstMatch(_root.outerHtml)
|
||||
.firstMatch(root.outerHtml)
|
||||
?.group(1)
|
||||
?.stripNonDigits()
|
||||
?.nullIfWhitespace ??
|
||||
_root
|
||||
.nullIfWhitespace ??
|
||||
root
|
||||
.querySelector('.like-button-renderer-like-button')
|
||||
?.text
|
||||
?.stripNonDigits()
|
||||
?.nullIfWhitespace ??
|
||||
.stripNonDigits()
|
||||
.nullIfWhitespace ??
|
||||
'0');
|
||||
|
||||
///
|
||||
int get videoDislikeCount => int.parse(_videoDislikeExp
|
||||
.firstMatch(_root.outerHtml)
|
||||
.firstMatch(root.outerHtml)
|
||||
?.group(1)
|
||||
?.stripNonDigits()
|
||||
?.nullIfWhitespace ??
|
||||
_root
|
||||
.nullIfWhitespace ??
|
||||
root
|
||||
.querySelector('.like-button-renderer-dislike-button')
|
||||
?.text
|
||||
?.stripNonDigits()
|
||||
?.nullIfWhitespace ??
|
||||
.stripNonDigits()
|
||||
.nullIfWhitespace ??
|
||||
'0');
|
||||
|
||||
static final _playerConfigExp = RegExp(r'ytplayer\.config\s*=\s*(\{.*\})');
|
||||
|
||||
///
|
||||
WatchPlayerConfig get playerConfig => _playerConfig ??= WatchPlayerConfig(
|
||||
PlayerConfigJson.fromRawJson(_playerConfigExp
|
||||
.firstMatch(_root.getElementsByTagName('html').first.text)
|
||||
?.group(1)
|
||||
?.extractJson()));
|
||||
late final WatchPlayerConfig? playerConfig = getPlayerConfig();
|
||||
|
||||
late final PlayerResponse? playerResponse = getPlayerResponse();
|
||||
|
||||
///
|
||||
PlayerResponse get playerResponse => PlayerResponse.parse(_root
|
||||
.querySelectorAll('script')
|
||||
.map((e) => e.text)
|
||||
.map((e) => _playerResponseExp.firstMatch(e)?.group(1))
|
||||
.firstWhere((e) => !e.isNullOrWhiteSpace)
|
||||
.extractJson());
|
||||
|
||||
String _extractJson(String html, String separator) =>
|
||||
html.substring(html.indexOf(separator) + separator.length).extractJson();
|
||||
WatchPlayerConfig? getPlayerConfig() {
|
||||
final jsonMap = _playerConfigExp
|
||||
.firstMatch(root.getElementsByTagName('html').first.text)
|
||||
?.group(1)
|
||||
?.extractJson();
|
||||
if (jsonMap == null) {
|
||||
return null;
|
||||
}
|
||||
return WatchPlayerConfig(jsonMap);
|
||||
}
|
||||
|
||||
///
|
||||
WatchPage(this._root, this.visitorInfoLive, this.ysc);
|
||||
PlayerResponse? getPlayerResponse() {
|
||||
final val = root
|
||||
.querySelectorAll('script')
|
||||
.map((e) => e.text)
|
||||
.map((e) => _playerResponseExp.firstMatch(e)?.group(1))
|
||||
.firstWhereOrNull((e) => !e.isNullOrWhiteSpace)
|
||||
?.extractJson();
|
||||
if (val == null) {
|
||||
return null;
|
||||
}
|
||||
return PlayerResponse(val);
|
||||
}
|
||||
|
||||
///
|
||||
WatchPage(this.root, this.visitorInfoLive, this.ysc);
|
||||
|
||||
///
|
||||
WatchPage.parse(String raw, this.visitorInfoLive, this.ysc)
|
||||
: _root = parser.parse(raw);
|
||||
: root = parser.parse(raw);
|
||||
|
||||
///
|
||||
static Future<WatchPage> get(YoutubeHttpClient httpClient, String videoId) {
|
||||
|
@ -156,10 +155,10 @@ class WatchPage {
|
|||
return retry(() async {
|
||||
var req = await httpClient.get(url, validate: true);
|
||||
|
||||
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);
|
||||
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.');
|
||||
|
@ -174,47 +173,48 @@ class WatchPage {
|
|||
}
|
||||
|
||||
/// Used internally
|
||||
class WatchPlayerConfig implements PlayerConfigBase<PlayerConfigJson> {
|
||||
class WatchPlayerConfig implements PlayerConfigBase<Map<String, dynamic>> {
|
||||
@override
|
||||
final PlayerConfigJson root;
|
||||
final Map<String, dynamic> root;
|
||||
|
||||
///
|
||||
WatchPlayerConfig(this.root);
|
||||
|
||||
@override
|
||||
String get sourceUrl => 'https://youtube.com${root.assets.js}';
|
||||
late final String sourceUrl =
|
||||
'https://youtube.com${root.get('assets')!.getT<String>('js')}';
|
||||
|
||||
///
|
||||
PlayerResponse get playerResponse =>
|
||||
PlayerResponse.parse(root.args.playerResponse);
|
||||
late final PlayerResponse playerResponse =
|
||||
PlayerResponse.parse(root.get('args')!.getT<String>('playerResponse')!);
|
||||
}
|
||||
|
||||
class _InitialData {
|
||||
// Json parsed map
|
||||
final WatchPageId root;
|
||||
final Map<String, dynamic> root;
|
||||
|
||||
_InitialData(this.root);
|
||||
|
||||
/* Cache results */
|
||||
|
||||
String _continuation;
|
||||
String _clickTrackingParams;
|
||||
|
||||
NextContinuationData getContinuationContext() {
|
||||
if (root.contents != null) {
|
||||
return root.contents.twoColumnWatchNextResults.results.results.contents
|
||||
.firstWhere((e) => e.itemSectionRenderer != null)
|
||||
.itemSectionRenderer
|
||||
.continuations
|
||||
.first
|
||||
.nextContinuationData;
|
||||
Map<String, dynamic>? getContinuationContext() {
|
||||
if (root['contents'] != null) {
|
||||
return root
|
||||
.get('contents')
|
||||
?.get('twoColumnWatchNextResults')
|
||||
?.get('results')
|
||||
?.get('results')
|
||||
?.getList('contents')
|
||||
?.firstWhere((e) => e['itemSectionRenderer'] != null)
|
||||
.get('itemSectionRenderer')
|
||||
?.getList('continuations')
|
||||
?.firstOrNull
|
||||
?.get('nextContinuationData');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String get continuation =>
|
||||
_continuation ??= getContinuationContext()?.continuation ?? '';
|
||||
late final String continuation =
|
||||
getContinuationContext()?.getT<String>('continuation') ?? '';
|
||||
|
||||
String get clickTrackingParams => _clickTrackingParams ??=
|
||||
getContinuationContext()?.clickTrackingParams ?? '';
|
||||
late final String clickTrackingParams =
|
||||
getContinuationContext()?.getT<String>('clickTrackingParams') ?? '';
|
||||
}
|
||||
|
|
|
@ -24,12 +24,12 @@ class YoutubeHttpClient extends http.BaseClient {
|
|||
};
|
||||
|
||||
/// Initialize an instance of [YoutubeHttpClient]
|
||||
YoutubeHttpClient([http.Client httpClient])
|
||||
YoutubeHttpClient([http.Client? httpClient])
|
||||
: _httpClient = httpClient ?? http.Client();
|
||||
|
||||
/// Throws if something is wrong with the response.
|
||||
void _validateResponse(http.BaseResponse response, int statusCode) {
|
||||
var request = response.request;
|
||||
var request = response.request!;
|
||||
if (request.url.host.endsWith('.google.com') &&
|
||||
request.url.path.startsWith('/sorry/')) {
|
||||
throw RequestLimitExceededException.httpRequest(response);
|
||||
|
@ -50,7 +50,7 @@ class YoutubeHttpClient extends http.BaseClient {
|
|||
|
||||
///
|
||||
Future<String> getString(dynamic url,
|
||||
{Map<String, String> headers, bool validate = true}) async {
|
||||
{Map<String, String> headers = const {}, bool validate = true}) async {
|
||||
var response = await get(url, headers: headers);
|
||||
|
||||
if (validate) {
|
||||
|
@ -62,7 +62,11 @@ class YoutubeHttpClient extends http.BaseClient {
|
|||
|
||||
@override
|
||||
Future<http.Response> get(dynamic url,
|
||||
{Map<String, String> headers, bool validate = false}) async {
|
||||
{Map<String, String>? headers = const {}, bool validate = false}) async {
|
||||
assert(url is String || url is Uri);
|
||||
if (url is String) {
|
||||
url = Uri.parse(url);
|
||||
}
|
||||
var response = await super.get(url, headers: headers);
|
||||
if (validate) {
|
||||
_validateResponse(response, response.statusCode);
|
||||
|
@ -72,8 +76,8 @@ class YoutubeHttpClient extends http.BaseClient {
|
|||
|
||||
///
|
||||
Future<String> postString(dynamic url,
|
||||
{Map<String, String> body,
|
||||
Map<String, String> headers,
|
||||
{Map<String, String>? body,
|
||||
Map<String, String> headers = const {},
|
||||
bool validate = true}) async {
|
||||
var response = await post(url, headers: headers, body: body);
|
||||
|
||||
|
@ -85,7 +89,7 @@ class YoutubeHttpClient extends http.BaseClient {
|
|||
}
|
||||
|
||||
Stream<List<int>> getStream(StreamInfo streamInfo,
|
||||
{Map<String, String> headers,
|
||||
{Map<String, String> headers = const {},
|
||||
bool validate = true,
|
||||
int start = 0,
|
||||
int errorCount = 0}) async* {
|
||||
|
@ -122,8 +126,8 @@ class YoutubeHttpClient extends http.BaseClient {
|
|||
}
|
||||
|
||||
///
|
||||
Future<int> getContentLength(dynamic url,
|
||||
{Map<String, String> headers, bool validate = true}) async {
|
||||
Future<int?> getContentLength(dynamic url,
|
||||
{Map<String, String> headers = const {}, bool validate = true}) async {
|
||||
var response = await head(url, headers: headers);
|
||||
|
||||
if (validate) {
|
||||
|
@ -140,7 +144,7 @@ class YoutubeHttpClient extends http.BaseClient {
|
|||
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||
_defaultHeaders.forEach((key, value) {
|
||||
if (request.headers[key] == null) {
|
||||
request.headers[key] = _defaultHeaders[key];
|
||||
request.headers[key] = _defaultHeaders[key]!;
|
||||
}
|
||||
});
|
||||
// print('Request: $request');
|
||||
|
|
|
@ -5,6 +5,7 @@ library youtube_explode.search;
|
|||
|
||||
export 'related_query.dart';
|
||||
export 'search_client.dart';
|
||||
export 'search_filter.dart';
|
||||
export 'search_list.dart';
|
||||
export 'search_playlist.dart';
|
||||
export 'search_query.dart';
|
||||
|
|
|
@ -1,16 +1,29 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../channels/channel_id.dart';
|
||||
import 'base_search_content.dart';
|
||||
|
||||
/// Metadata related to a search query result (channel)
|
||||
class SearchChannel {
|
||||
/// ChannelId.
|
||||
final ChannelId channelId;
|
||||
class SearchChannel extends BaseSearchContent with EquatableMixin {
|
||||
/// Channel id.
|
||||
final ChannelId id;
|
||||
|
||||
/// Channel name.
|
||||
final String channelName;
|
||||
final String name;
|
||||
|
||||
/// Description snippet.
|
||||
/// Can be empty.
|
||||
final String description;
|
||||
|
||||
/// Channel uploaded videos.
|
||||
final int videoCount;
|
||||
|
||||
/// Initialize a [SearchChannel] instance.
|
||||
SearchChannel(this.channelId, this.channelName);
|
||||
SearchChannel(this.id, this.name, this.description, this.videoCount);
|
||||
|
||||
@override
|
||||
String toString() => '(Channel) $channelName ($channelId)';
|
||||
String toString() => '(Channel) $name ($id)';
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, name, description, videoCount];
|
||||
}
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import '../../youtube_explode_dart.dart';
|
||||
import '../extensions/helpers_extension.dart';
|
||||
import '../retry.dart';
|
||||
import '../reverse_engineering/responses/search_page.dart';
|
||||
import '../reverse_engineering/youtube_http_client.dart';
|
||||
import 'base_search_content.dart';
|
||||
import 'search_filter.dart';
|
||||
import 'search_list.dart';
|
||||
import 'search_query.dart';
|
||||
|
||||
|
@ -17,42 +21,73 @@ class SearchClient {
|
|||
/// (from the video search page).
|
||||
/// The videos are sent in batch of 20 videos.
|
||||
/// You [SearchList.nextPage] to get the next batch of videos.
|
||||
Future<SearchList> getVideos(String searchQuery) {
|
||||
var stream =
|
||||
getVideosFromPage(searchQuery, onlyVideos: true).cast<SearchVideo>();
|
||||
return SearchList.create(stream);
|
||||
Future<SearchList> getVideos(String searchQuery,
|
||||
{SearchFilter filter = const SearchFilter('')}) async {
|
||||
final page = await SearchPage.get(_httpClient, searchQuery, filter: filter);
|
||||
|
||||
return SearchList(
|
||||
page.initialData.searchContent
|
||||
.whereType<SearchVideo>()
|
||||
.map((e) => Video(
|
||||
e.id,
|
||||
e.title,
|
||||
e.author,
|
||||
ChannelId(e.channelId),
|
||||
e.uploadDate?.toDateTime(),
|
||||
null,
|
||||
e.description,
|
||||
e.duration.toDuration(),
|
||||
ThumbnailSet(e.id.value),
|
||||
null,
|
||||
Engagement(e.viewCount, null, null),
|
||||
e.isLive))
|
||||
.toList(),
|
||||
page,
|
||||
_httpClient);
|
||||
}
|
||||
|
||||
/// Enumerates videos returned by the specified search query
|
||||
/// (from the video search page).
|
||||
/// Contains only instances of [SearchVideo] or [SearchPlaylist]
|
||||
@Deprecated(
|
||||
'Since version 1.9.0 this is the same as [SearchClient.getVideos].')
|
||||
Stream<BaseSearchContent> getVideosFromPage(String searchQuery,
|
||||
{bool onlyVideos = true}) async* {
|
||||
var page =
|
||||
await retry(() async => SearchPage.get(_httpClient, searchQuery));
|
||||
if (onlyVideos) {
|
||||
yield* Stream.fromIterable(
|
||||
page.initialData.searchContent.whereType<SearchVideo>());
|
||||
} else {
|
||||
yield* Stream.fromIterable(page.initialData.searchContent);
|
||||
}
|
||||
|
||||
{bool onlyVideos = true,
|
||||
SearchFilter filter = const SearchFilter('')}) async* {
|
||||
SearchPage? page;
|
||||
// ignore: literal_only_boolean_expressions
|
||||
while (true) {
|
||||
page = await page.nextPage(_httpClient);
|
||||
for (;;) {
|
||||
if (page == null) {
|
||||
return;
|
||||
page = await retry(() async =>
|
||||
SearchPage.get(_httpClient, searchQuery, filter: filter));
|
||||
} else {
|
||||
page = await page.nextPage(_httpClient);
|
||||
if (page == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (onlyVideos) {
|
||||
yield* Stream.fromIterable(
|
||||
page.initialData.searchContent.whereType<SearchVideo>());
|
||||
page!.initialData.searchContent.whereType<SearchVideo>());
|
||||
} else {
|
||||
yield* Stream.fromIterable(page.initialData.searchContent);
|
||||
yield* Stream.fromIterable(page!.initialData.searchContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the suggestions youtube provide while search on the page.
|
||||
Future<List<String>> getQuerySuggestions(String query) async {
|
||||
final request = await _httpClient.get(
|
||||
'https://suggestqueries-clients6.youtube.com/complete/search?client=youtube&hl=en&gl=en&q=${Uri.encodeComponent(query)}&callback=func');
|
||||
final body = request.body;
|
||||
final startIndex = body.indexOf('func(');
|
||||
final jsonStr = body.substring(startIndex + 5, body.length - 1);
|
||||
final data = json.decode(jsonStr) as List<dynamic>;
|
||||
final suggestions = data[1] as List<dynamic>;
|
||||
return suggestions.map((e) => e[0]).toList().cast<String>();
|
||||
}
|
||||
|
||||
/// Queries to YouTube to get the results.
|
||||
@Deprecated('Use getVideosFromPage instead - '
|
||||
'Should be used only to get related videos')
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
import 'search_client.dart';
|
||||
|
||||
class SearchFilter {
|
||||
/// The value fo the 'sp' argument.
|
||||
final String value;
|
||||
|
||||
const SearchFilter(this.value);
|
||||
}
|
||||
|
||||
/// Video filters to be used with [SearchClient.getVideos]
|
||||
class Filters {
|
||||
const Filters._();
|
||||
|
||||
/// Features filters.
|
||||
FeatureFilters get features => const FeatureFilters._();
|
||||
|
||||
/// Upload date filters.
|
||||
UploadDateFilter get uploadDate => const UploadDateFilter._();
|
||||
|
||||
/// Types filters.
|
||||
TypeFilters get types => const TypeFilters._();
|
||||
|
||||
/// Duration filters.
|
||||
DurationFilters get duration => const DurationFilters._();
|
||||
|
||||
/// Videos sorting.
|
||||
SortFilters get sort => const SortFilters._();
|
||||
}
|
||||
|
||||
/// Video filters to be used with [SearchClient.getVideos]
|
||||
const filters = Filters._();
|
||||
|
||||
class FeatureFilters {
|
||||
const FeatureFilters._();
|
||||
|
||||
/// Live video.
|
||||
SearchFilter get live => const SearchFilter('EgJAAQ%253D%253D');
|
||||
|
||||
/// 4K video.
|
||||
SearchFilter get v4k => const SearchFilter('EgJwAQ%253D%253D');
|
||||
|
||||
/// HD video.
|
||||
SearchFilter get hd => const SearchFilter('EgIgAQ%253D%253D');
|
||||
|
||||
/// Subtitled video.
|
||||
SearchFilter get subTitles => const SearchFilter('EgIoAQ%253D%253D');
|
||||
|
||||
/// Creative comments video.
|
||||
SearchFilter get creativeCommons => const SearchFilter('EgIwAQ%253D%253D');
|
||||
|
||||
/// 360° video.
|
||||
SearchFilter get v360 => const SearchFilter('EgJ4AQ%253D%253D');
|
||||
|
||||
/// VR 180° video.
|
||||
SearchFilter get vr180 => const SearchFilter('EgPQAQE%253D');
|
||||
|
||||
/// 3D video.
|
||||
SearchFilter get v3D => const SearchFilter('EgI4AQ%253D%253D');
|
||||
|
||||
/// HDR video.
|
||||
SearchFilter get hdr => const SearchFilter('EgPIAQE%253D');
|
||||
|
||||
/// Video with location.
|
||||
SearchFilter get location => const SearchFilter('EgO4AQE%253D');
|
||||
|
||||
/// Purchased video.
|
||||
SearchFilter get purchased => const SearchFilter('EgJIAQ%253D%253D');
|
||||
}
|
||||
|
||||
class UploadDateFilter {
|
||||
const UploadDateFilter._();
|
||||
|
||||
/// Videos uploaded in the last hour.
|
||||
SearchFilter get lastHour => const SearchFilter('EgIIAQ%253D%253D');
|
||||
|
||||
/// Videos uploaded today.
|
||||
SearchFilter get today => const SearchFilter('EgIIAg%253D%253D');
|
||||
|
||||
/// Videos uploaded in the last week.
|
||||
SearchFilter get lastWeek => const SearchFilter('EgIIAw%253D%253D');
|
||||
|
||||
/// Videos uploaded in the last month.
|
||||
SearchFilter get lastMonth => const SearchFilter('EgIIBA%253D%253D');
|
||||
|
||||
/// Videos uploaded in the last year.
|
||||
SearchFilter get lastYear => const SearchFilter('EgIIBQ%253D%253D');
|
||||
}
|
||||
|
||||
class TypeFilters {
|
||||
const TypeFilters._();
|
||||
|
||||
/// Videos.
|
||||
SearchFilter get video => const SearchFilter('EgIQAQ%253D%253D');
|
||||
|
||||
/// Channels.
|
||||
SearchFilter get channel => const SearchFilter('EgIQAg%253D%253D');
|
||||
|
||||
/// Playlists.
|
||||
SearchFilter get playlist => const SearchFilter('EgIQAw%253D%253D');
|
||||
|
||||
/// Movies.
|
||||
SearchFilter get movie => const SearchFilter('EgIQBA%253D%253D');
|
||||
|
||||
/// Shows.
|
||||
SearchFilter get show => const SearchFilter('EgIQBQ%253D%253D');
|
||||
}
|
||||
|
||||
class DurationFilters {
|
||||
const DurationFilters._();
|
||||
|
||||
/// Short videos, < 4 minutes.
|
||||
SearchFilter get short => const SearchFilter('EgIYAQ%253D%253D');
|
||||
|
||||
/// Long videos, > 20 minutes.
|
||||
SearchFilter get long => const SearchFilter('EgIYAg%253D%253D');
|
||||
}
|
||||
|
||||
class SortFilters {
|
||||
const SortFilters._();
|
||||
|
||||
/// Sort by relevance (default).
|
||||
SearchFilter get relevance => const SearchFilter('CAASAhAB');
|
||||
|
||||
/// Sort by upload date (default).
|
||||
SearchFilter get uploadDate => const SearchFilter('CAI%253D');
|
||||
|
||||
/// Sort by view count (default).
|
||||
SearchFilter get viewCount => const SearchFilter('CAM%253D');
|
||||
|
||||
/// Sort by rating (default).
|
||||
SearchFilter get rating => const SearchFilter('CAE%253D');
|
||||
}
|
|
@ -1,109 +1,43 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:youtube_explode_dart/src/reverse_engineering/responses/search_page.dart';
|
||||
|
||||
import '../../youtube_explode_dart.dart';
|
||||
import '../extensions/helpers_extension.dart';
|
||||
|
||||
/// This list contains search videos.
|
||||
class SearchList extends DelegatingList<Video> {
|
||||
final Stream<Video> _stream;
|
||||
final SearchPage _page;
|
||||
final YoutubeHttpClient _httpClient;
|
||||
|
||||
///
|
||||
SearchList._(List<Video> base, this._stream) : super(base);
|
||||
SearchList(List<Video> base, this._page, this._httpClient) : super(base);
|
||||
|
||||
///
|
||||
static Future<SearchList> create(Stream<SearchVideo> stream) async {
|
||||
Stream<Video> broadcast;
|
||||
broadcast = stream
|
||||
.map((e) => Video(
|
||||
e.id,
|
||||
e.title,
|
||||
e.author,
|
||||
null,
|
||||
_stringToDateTime(e.uploadDate),
|
||||
e.description,
|
||||
_stringToDuration(e.duration),
|
||||
ThumbnailSet(e.id.value),
|
||||
null,
|
||||
Engagement(e.viewCount, null, null),
|
||||
e.isLive))
|
||||
.asBroadcastStream(onCancel: (subscription) {
|
||||
subscription.pause();
|
||||
}, onListen: (subscription) {
|
||||
subscription.resume();
|
||||
});
|
||||
final base = await broadcast.take(20).toList();
|
||||
return SearchList._(base, broadcast);
|
||||
}
|
||||
|
||||
///
|
||||
Future<SearchList> nextPage() async {
|
||||
final base = await _stream.take(20).toList();
|
||||
return SearchList._(base, _stream);
|
||||
}
|
||||
|
||||
/// Format: <quantity> <unit> ago (5 years ago)
|
||||
static DateTime _stringToDateTime(String string) {
|
||||
if (string == null) {
|
||||
Future<SearchList?> nextPage() async {
|
||||
final page = await _page.nextPage(_httpClient);
|
||||
if (page == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = string.split(' ');
|
||||
if (parts.length == 4) { // Streamed x y ago
|
||||
parts = parts.skip(1).toList();
|
||||
}
|
||||
assert(parts.length == 3);
|
||||
|
||||
var qty = int.parse(parts.first);
|
||||
|
||||
// Try to get the unit
|
||||
var unit = parts[1];
|
||||
Duration time;
|
||||
if (unit.startsWith('second')) {
|
||||
time = Duration(seconds: qty);
|
||||
} else if (unit.startsWith('minute')) {
|
||||
time = Duration(minutes: qty);
|
||||
} else if (unit.startsWith('hour')) {
|
||||
time = Duration(hours: qty);
|
||||
} else if (unit.startsWith('day')) {
|
||||
time = Duration(days: qty);
|
||||
} else if (unit.startsWith('week')) {
|
||||
time = Duration(days: qty * 7);
|
||||
} else if (unit.startsWith('month')) {
|
||||
time = Duration(days: qty * 30);
|
||||
} else if (unit.startsWith('year')) {
|
||||
time = Duration(days: qty * 365);
|
||||
} else {
|
||||
throw StateError('Couldn\'t parse $unit unit of time. '
|
||||
'Please report this to the project page!');
|
||||
}
|
||||
|
||||
return DateTime.now().subtract(time);
|
||||
}
|
||||
|
||||
/// Format: HH:MM:SS
|
||||
static Duration _stringToDuration(String string) {
|
||||
if (string == null || string.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = string.split(':');
|
||||
assert(parts.length <= 3);
|
||||
|
||||
if (parts.length == 1) {
|
||||
return Duration(seconds: int.parse(parts.first));
|
||||
}
|
||||
if (parts.length == 2) {
|
||||
return Duration(
|
||||
minutes: int.parse(parts[0]), seconds: int.parse(parts[1]));
|
||||
}
|
||||
if (parts.length == 3) {
|
||||
return Duration(
|
||||
hours: int.parse(parts[0]),
|
||||
minutes: int.parse(parts[1]),
|
||||
seconds: int.parse(parts[2]));
|
||||
}
|
||||
// Shouldn't reach here.
|
||||
throw Error();
|
||||
return SearchList(
|
||||
page.initialData.searchContent
|
||||
.whereType<SearchVideo>()
|
||||
.map((e) => Video(
|
||||
e.id,
|
||||
e.title,
|
||||
e.author,
|
||||
ChannelId(e.channelId),
|
||||
e.uploadDate.toDateTime(),
|
||||
null,
|
||||
e.description,
|
||||
e.duration.toDuration(),
|
||||
ThumbnailSet(e.id.value),
|
||||
null,
|
||||
Engagement(e.viewCount, null, null),
|
||||
e.isLive))
|
||||
.toList(),
|
||||
page,
|
||||
_httpClient);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ class SearchQuery {
|
|||
|
||||
/// Get the data of the next page.
|
||||
/// Returns null if there is no next page.
|
||||
Future<SearchQuery> nextPage() async {
|
||||
Future<SearchQuery?> nextPage() async {
|
||||
var page = await _page.nextPage(_httpClient);
|
||||
if (page == null) {
|
||||
return null;
|
||||
|
|
|
@ -26,11 +26,14 @@ class SearchVideo extends BaseSearchContent {
|
|||
final List<Thumbnail> thumbnails;
|
||||
|
||||
/// Video upload date - As string: 5 years ago.
|
||||
final String uploadDate;
|
||||
final String? uploadDate;
|
||||
|
||||
/// True if this video is a live stream.
|
||||
final bool isLive;
|
||||
|
||||
/// Channel id
|
||||
final String channelId;
|
||||
|
||||
/// Initialize a [SearchVideo] instance.
|
||||
const SearchVideo(
|
||||
this.id,
|
||||
|
@ -41,8 +44,8 @@ class SearchVideo extends BaseSearchContent {
|
|||
this.viewCount,
|
||||
this.thumbnails,
|
||||
this.uploadDate,
|
||||
this.isLive // ignore: avoid_positional_boolean_parameters
|
||||
);
|
||||
this.isLive, // ignore: avoid_positional_boolean_parameters
|
||||
this.channelId);
|
||||
|
||||
@override
|
||||
String toString() => '(Video) $title ($id)';
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:collection';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'closed_caption_part.dart';
|
||||
|
@ -35,8 +36,8 @@ class ClosedCaption {
|
|||
/// relative to this caption's offset.
|
||||
/// Returns null if not found.
|
||||
/// Note that some captions may not have any parts at all.
|
||||
ClosedCaptionPart getPartByTime(Duration offset) =>
|
||||
parts.firstWhere((e) => e.offset >= offset, orElse: () => null);
|
||||
ClosedCaptionPart? getPartByTime(Duration offset) =>
|
||||
parts.firstWhereOrNull((e) => e.offset >= offset);
|
||||
|
||||
@override
|
||||
String toString() => 'Text: $text';
|
||||
|
|
|
@ -9,22 +9,17 @@ part of 'closed_caption.dart';
|
|||
ClosedCaption _$ClosedCaptionFromJson(Map<String, dynamic> json) {
|
||||
return ClosedCaption(
|
||||
json['text'] as String,
|
||||
json['offset'] == null
|
||||
? null
|
||||
: Duration(microseconds: json['offset'] as int),
|
||||
json['duration'] == null
|
||||
? null
|
||||
: Duration(microseconds: json['duration'] as int),
|
||||
(json['parts'] as List)?.map((e) => e == null
|
||||
? null
|
||||
: ClosedCaptionPart.fromJson(e as Map<String, dynamic>)),
|
||||
Duration(microseconds: json['offset'] as int),
|
||||
Duration(microseconds: json['duration'] as int),
|
||||
(json['parts'] as List<dynamic>)
|
||||
.map((e) => ClosedCaptionPart.fromJson(e as Map<String, dynamic>)),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$ClosedCaptionToJson(ClosedCaption instance) =>
|
||||
<String, dynamic>{
|
||||
'text': instance.text,
|
||||
'offset': instance.offset?.inMicroseconds,
|
||||
'duration': instance.duration?.inMicroseconds,
|
||||
'offset': instance.offset.inMicroseconds,
|
||||
'duration': instance.duration.inMicroseconds,
|
||||
'parts': instance.parts,
|
||||
};
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import 'package:xml/xml.dart' as xml;
|
||||
|
||||
import '../../extensions/helpers_extension.dart';
|
||||
import '../../reverse_engineering/responses/responses.dart'
|
||||
hide ClosedCaption, ClosedCaptionPart, ClosedCaptionTrack;
|
||||
|
@ -39,8 +37,8 @@ class ClosedCaptionClient {
|
|||
await VideoInfoResponse.get(_httpClient, videoId.value);
|
||||
var playerResponse = videoInfoResponse.playerResponse;
|
||||
|
||||
for (var track in playerResponse.closedCaptionTrack) {
|
||||
for (var ext in formats) {
|
||||
for (final track in playerResponse.closedCaptionTrack) {
|
||||
for (final ext in formats) {
|
||||
tracks.add(ClosedCaptionTrackInfo(
|
||||
Uri.parse(track.url)
|
||||
.replaceQueryParameters({'fmt': ext.formatCode}),
|
||||
|
@ -61,7 +59,7 @@ class ClosedCaptionClient {
|
|||
var captions = response.closedCaptions
|
||||
.where((e) => !e.text.isNullOrWhiteSpace)
|
||||
.map((e) => ClosedCaption(e.text, e.offset, e.duration,
|
||||
e.getParts().map((f) => ClosedCaptionPart(f.text, f.offset))));
|
||||
e.parts.map((f) => ClosedCaptionPart(f.text, f.offset))));
|
||||
return ClosedCaptionTrack(captions);
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ class ClosedCaptionManifest {
|
|||
/// If [autoGenerated] is true auto generated tracks are included as well.
|
||||
/// Returns an empty list of no track is found.
|
||||
List<ClosedCaptionTrackInfo> getByLanguage(String language,
|
||||
{ClosedCaptionFormat format, bool autoGenerated = false}) {
|
||||
{ClosedCaptionFormat? format, bool autoGenerated = false}) {
|
||||
language = language.toLowerCase();
|
||||
return tracks
|
||||
.where((e) =>
|
||||
|
|
|
@ -9,14 +9,12 @@ part of 'closed_caption_part.dart';
|
|||
ClosedCaptionPart _$ClosedCaptionPartFromJson(Map<String, dynamic> json) {
|
||||
return ClosedCaptionPart(
|
||||
json['text'] as String,
|
||||
json['offset'] == null
|
||||
? null
|
||||
: Duration(microseconds: json['offset'] as int),
|
||||
Duration(microseconds: json['offset'] as int),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$ClosedCaptionPartToJson(ClosedCaptionPart instance) =>
|
||||
<String, dynamic>{
|
||||
'text': instance.text,
|
||||
'offset': instance.offset?.inMicroseconds,
|
||||
'offset': instance.offset.inMicroseconds,
|
||||
};
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:collection';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'closed_caption.dart';
|
||||
|
@ -18,8 +19,8 @@ class ClosedCaptionTrack {
|
|||
|
||||
/// Gets the caption displayed at the specified point in time.
|
||||
/// Returns null if not found.
|
||||
ClosedCaption getByTime(Duration time) => captions
|
||||
.firstWhere((e) => time >= e.offset && time <= e.end, orElse: () => null);
|
||||
ClosedCaption? getByTime(Duration time) =>
|
||||
captions.firstWhereOrNull((e) => time >= e.offset && time <= e.end);
|
||||
|
||||
///
|
||||
factory ClosedCaptionTrack.fromJson(Map<String, dynamic> json) =>
|
||||
|
|
|
@ -8,8 +8,8 @@ part of 'closed_caption_track.dart';
|
|||
|
||||
ClosedCaptionTrack _$ClosedCaptionTrackFromJson(Map<String, dynamic> json) {
|
||||
return ClosedCaptionTrack(
|
||||
(json['captions'] as List)?.map((e) =>
|
||||
e == null ? null : ClosedCaption.fromJson(e as Map<String, dynamic>)),
|
||||
(json['captions'] as List<dynamic>)
|
||||
.map((e) => ClosedCaption.fromJson(e as Map<String, dynamic>)),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
|
@ -26,8 +24,7 @@ class ClosedCaptionTrackInfo extends Equatable {
|
|||
|
||||
/// Initializes an instance of [ClosedCaptionTrackInfo]
|
||||
const ClosedCaptionTrackInfo(this.url, this.language,
|
||||
{this.isAutoGenerated = false, this.format})
|
||||
: assert(format != null);
|
||||
{this.isAutoGenerated = false, required this.format});
|
||||
|
||||
/// Returns this auto-translated to another language.
|
||||
/// Keeping the same format.
|
||||
|
|
|
@ -9,21 +9,18 @@ part of 'closed_caption_track_info.dart';
|
|||
ClosedCaptionTrackInfo _$ClosedCaptionTrackInfoFromJson(
|
||||
Map<String, dynamic> json) {
|
||||
return ClosedCaptionTrackInfo(
|
||||
json['url'] == null ? null : Uri.parse(json['url'] as String),
|
||||
json['language'] == null
|
||||
? null
|
||||
: Language.fromJson(json['language'] as Map<String, dynamic>),
|
||||
Uri.parse(json['url'] as String),
|
||||
Language.fromJson(json['language'] as Map<String, dynamic>),
|
||||
isAutoGenerated: json['isAutoGenerated'] as bool,
|
||||
format: json['format'] == null
|
||||
? null
|
||||
: ClosedCaptionFormat.fromJson(json['format'] as Map<String, dynamic>),
|
||||
format:
|
||||
ClosedCaptionFormat.fromJson(json['format'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$ClosedCaptionTrackInfoToJson(
|
||||
ClosedCaptionTrackInfo instance) =>
|
||||
<String, dynamic>{
|
||||
'url': instance.url?.toString(),
|
||||
'url': instance.url.toString(),
|
||||
'language': instance.language,
|
||||
'isAutoGenerated': instance.isAutoGenerated,
|
||||
'format': instance.format,
|
||||
|
|
|
@ -27,11 +27,11 @@ class Comment with EquatableMixin {
|
|||
|
||||
/// Used internally.
|
||||
/// Shouldn't be used in the code.
|
||||
final String continuation;
|
||||
final String? continuation;
|
||||
|
||||
/// Used internally.
|
||||
/// Shouldn't be used in the code.
|
||||
final String clicktrackingParams;
|
||||
final String? clicktrackingParams;
|
||||
|
||||
/// Initializes an instance of [Comment]
|
||||
Comment(
|
||||
|
|
|
@ -52,11 +52,11 @@ class CommentsClient {
|
|||
return;
|
||||
}
|
||||
yield* _getComments(
|
||||
video.watchPage.initialData.continuation,
|
||||
video.watchPage.initialData.clickTrackingParams,
|
||||
video.watchPage.xsfrToken,
|
||||
video.watchPage.visitorInfoLive,
|
||||
video.watchPage.ysc);
|
||||
video.watchPage!.initialData.continuation,
|
||||
video.watchPage!.initialData.clickTrackingParams,
|
||||
video.watchPage!.xsfrToken,
|
||||
video.watchPage!.visitorInfoLive,
|
||||
video.watchPage!.ysc);
|
||||
}
|
||||
|
||||
Stream<Comment> _getComments(String continuation, String clickTrackingParams,
|
||||
|
@ -67,14 +67,14 @@ class CommentsClient {
|
|||
['itemSectionContinuation']['contents']
|
||||
?.map((e) => e['commentThreadRenderer'])
|
||||
?.toList()
|
||||
?.cast<Map<String, dynamic>>() as List<Map<String, dynamic>>;
|
||||
?.cast<Map<String, dynamic>>() as List<Map<String, dynamic>>?;
|
||||
if (contentRoot == null) {
|
||||
return;
|
||||
}
|
||||
for (var content in contentRoot) {
|
||||
for (final content in contentRoot) {
|
||||
var commentRaw = content['comment']['commentRenderer'];
|
||||
String continuation;
|
||||
String clickTrackingParams;
|
||||
String? continuation;
|
||||
String? clickTrackingParams;
|
||||
if (content['replies'] != null) {
|
||||
continuation = content['replies']['commentRepliesRenderer']
|
||||
['continuations']
|
||||
|
@ -96,12 +96,12 @@ class CommentsClient {
|
|||
yield comment;
|
||||
}
|
||||
var continuationRoot = (data
|
||||
?.get('response')
|
||||
.get('response')
|
||||
?.get('continuationContents')
|
||||
?.get('itemSectionContinuation')
|
||||
?.getValue('continuations')
|
||||
?.first as Map<String, dynamic>)
|
||||
?.get('nextContinuationData');
|
||||
.get('nextContinuationData');
|
||||
if (continuationRoot != null) {
|
||||
yield* _getComments(
|
||||
continuationRoot['continuation'],
|
||||
|
@ -113,7 +113,7 @@ class CommentsClient {
|
|||
}
|
||||
|
||||
String _parseRuns(Map<dynamic, dynamic> runs) =>
|
||||
runs?.getValue('runs')?.map((e) => e['text'])?.join() ?? '';
|
||||
runs.getValue('runs')?.map((e) => e['text'])?.join() ?? '';
|
||||
|
||||
//TODO: Implement replies
|
||||
/* Stream<Comment> getReplies(Video video, Comment comment) async* {
|
||||
|
|
|
@ -26,7 +26,7 @@ class StreamsClient {
|
|||
var signature =
|
||||
DashManifest.getSignatureFromUrl(dashManifestUrl.toString());
|
||||
if (!signature.isNullOrWhiteSpace) {
|
||||
signature = cipherOperations.decipher(signature);
|
||||
signature = cipherOperations.decipher(signature!);
|
||||
dashManifestUrl = dashManifestUrl.setQueryParam('signature', signature);
|
||||
}
|
||||
return DashManifest.get(_httpClient, dashManifestUrl);
|
||||
|
@ -41,7 +41,7 @@ class StreamsClient {
|
|||
|
||||
var playerSource = await PlayerSource.get(
|
||||
_httpClient, embedPage.sourceUrl ?? playerConfig.sourceUrl);
|
||||
var cipherOperations = playerSource.getCiperOperations();
|
||||
var cipherOperations = playerSource.getCipherOperations();
|
||||
|
||||
var videoInfoResponse = await VideoInfoResponse.get(
|
||||
_httpClient, videoId.toString(), playerSource.sts);
|
||||
|
@ -50,12 +50,12 @@ class StreamsClient {
|
|||
var previewVideoId = playerResponse.previewVideoId;
|
||||
if (!previewVideoId.isNullOrWhiteSpace) {
|
||||
throw VideoRequiresPurchaseException.preview(
|
||||
videoId, VideoId(previewVideoId));
|
||||
videoId, VideoId(previewVideoId!));
|
||||
}
|
||||
|
||||
if (!playerResponse.isVideoPlayable) {
|
||||
throw VideoUnplayableException.unplayable(videoId,
|
||||
reason: playerResponse.getVideoPlayabilityError());
|
||||
reason: playerResponse.videoPlayabilityError ?? '');
|
||||
}
|
||||
|
||||
if (playerResponse.isLive) {
|
||||
|
@ -70,21 +70,17 @@ class StreamsClient {
|
|||
var dashManifestUrl = playerResponse.dashManifestUrl;
|
||||
if (!dashManifestUrl.isNullOrWhiteSpace) {
|
||||
var dashManifest =
|
||||
await _getDashManifest(Uri.parse(dashManifestUrl), cipherOperations);
|
||||
await _getDashManifest(Uri.parse(dashManifestUrl!), cipherOperations);
|
||||
streamInfoProviders.addAll(dashManifest.streams);
|
||||
}
|
||||
return StreamContext(streamInfoProviders, cipherOperations);
|
||||
}
|
||||
|
||||
Future<StreamContext> _getStreamContextFromWatchPage(VideoId videoId) async {
|
||||
var watchPage = await WatchPage.get(_httpClient, videoId.toString());
|
||||
final watchPage = await WatchPage.get(_httpClient, videoId.toString());
|
||||
|
||||
final playerConfig = watchPage.playerConfig;
|
||||
|
||||
WatchPlayerConfig playerConfig;
|
||||
try {
|
||||
playerConfig = watchPage.playerConfig;
|
||||
} on FormatException {
|
||||
playerConfig = null;
|
||||
}
|
||||
var playerResponse =
|
||||
playerConfig?.playerResponse ?? watchPage.playerResponse;
|
||||
if (playerResponse == null) {
|
||||
|
@ -92,21 +88,21 @@ class StreamsClient {
|
|||
}
|
||||
|
||||
var previewVideoId = playerResponse.previewVideoId;
|
||||
if (!(previewVideoId.isNullOrWhiteSpace ?? true)) {
|
||||
if (!previewVideoId.isNullOrWhiteSpace) {
|
||||
throw VideoRequiresPurchaseException.preview(
|
||||
videoId, VideoId(previewVideoId));
|
||||
videoId, VideoId(previewVideoId!));
|
||||
}
|
||||
|
||||
var playerSourceUrl = watchPage.sourceUrl ?? playerConfig?.sourceUrl;
|
||||
var playerSource = !playerSourceUrl.isNullOrWhiteSpace
|
||||
? await PlayerSource.get(_httpClient, playerSourceUrl)
|
||||
? await PlayerSource.get(_httpClient, playerSourceUrl!)
|
||||
: null;
|
||||
var cipherOperations =
|
||||
playerSource?.getCiperOperations() ?? const <CipherOperation>[];
|
||||
playerSource?.getCipherOperations() ?? const <CipherOperation>[];
|
||||
|
||||
if (!playerResponse.isVideoPlayable) {
|
||||
throw VideoUnplayableException.unplayable(videoId,
|
||||
reason: playerResponse.getVideoPlayabilityError());
|
||||
reason: playerResponse.videoPlayabilityError ?? '');
|
||||
}
|
||||
|
||||
if (playerResponse.isLive) {
|
||||
|
@ -120,7 +116,7 @@ class StreamsClient {
|
|||
var dashManifestUrl = playerResponse.dashManifestUrl;
|
||||
if (!(dashManifestUrl?.isNullOrWhiteSpace ?? true)) {
|
||||
var dashManifest =
|
||||
await _getDashManifest(Uri.parse(dashManifestUrl), cipherOperations);
|
||||
await _getDashManifest(Uri.parse(dashManifestUrl!), cipherOperations);
|
||||
streamInfoProviders.addAll(dashManifest.streams);
|
||||
}
|
||||
return StreamContext(streamInfoProviders, cipherOperations);
|
||||
|
@ -130,7 +126,7 @@ class StreamsClient {
|
|||
// To make sure there are no duplicates streams, group them by tag
|
||||
var streams = <int, StreamInfo>{};
|
||||
|
||||
for (var streamInfo in streamContext.streamInfoProviders) {
|
||||
for (final streamInfo in streamContext.streamInfoProviders) {
|
||||
var tag = streamInfo.tag;
|
||||
var url = Uri.parse(streamInfo.url);
|
||||
|
||||
|
@ -139,7 +135,7 @@ class StreamsClient {
|
|||
var signatureParameter = streamInfo.signatureParameter ?? 'signature';
|
||||
|
||||
if (!signature.isNullOrWhiteSpace) {
|
||||
signature = streamContext.cipherOperations.decipher(signature);
|
||||
signature = streamContext.cipherOperations.decipher(signature!);
|
||||
url = url.setQueryParam(signatureParameter, signature);
|
||||
}
|
||||
|
||||
|
@ -153,9 +149,9 @@ class StreamsClient {
|
|||
}
|
||||
|
||||
// Common
|
||||
var container = StreamContainer.parse(streamInfo.container);
|
||||
var container = StreamContainer.parse(streamInfo.container!);
|
||||
var fileSize = FileSize(contentLength);
|
||||
var bitrate = Bitrate(streamInfo.bitrate);
|
||||
var bitrate = Bitrate(streamInfo.bitrate!);
|
||||
|
||||
var audioCodec = streamInfo.audioCodec;
|
||||
var videoCodec = streamInfo.videoCodec;
|
||||
|
@ -165,14 +161,14 @@ class StreamsClient {
|
|||
var framerate = Framerate(streamInfo.framerate ?? 24);
|
||||
var videoQualityLabel = streamInfo.videoQualityLabel ??
|
||||
VideoQualityUtil.getLabelFromTagWithFramerate(
|
||||
tag, framerate.framesPerSecond);
|
||||
tag, framerate.framesPerSecond.toDouble());
|
||||
|
||||
var videoQuality = VideoQualityUtil.fromLabel(videoQualityLabel);
|
||||
|
||||
var videoWidth = streamInfo.videoWidth;
|
||||
var videoHeight = streamInfo.videoHeight;
|
||||
var videoResolution = videoWidth != -1 && videoHeight != -1
|
||||
? VideoResolution(videoWidth, videoHeight)
|
||||
? VideoResolution(videoWidth ?? 0, videoHeight ?? 0)
|
||||
: videoQuality.toVideoResolution();
|
||||
|
||||
// Muxed
|
||||
|
@ -183,8 +179,8 @@ class StreamsClient {
|
|||
container,
|
||||
fileSize,
|
||||
bitrate,
|
||||
audioCodec,
|
||||
videoCodec,
|
||||
audioCodec!,
|
||||
videoCodec!,
|
||||
videoQualityLabel,
|
||||
videoQuality,
|
||||
videoResolution,
|
||||
|
@ -199,7 +195,7 @@ class StreamsClient {
|
|||
container,
|
||||
fileSize,
|
||||
bitrate,
|
||||
videoCodec,
|
||||
videoCodec!,
|
||||
videoQualityLabel,
|
||||
videoQuality,
|
||||
videoResolution,
|
||||
|
@ -209,7 +205,7 @@ class StreamsClient {
|
|||
// Audio-only
|
||||
if (!audioCodec.isNullOrWhiteSpace) {
|
||||
streams[tag] = AudioOnlyStreamInfo(
|
||||
tag, url, container, fileSize, bitrate, audioCodec);
|
||||
tag, url, container, fileSize, bitrate, audioCodec!);
|
||||
}
|
||||
|
||||
// #if DEBUG
|
||||
|
@ -244,7 +240,7 @@ class StreamsClient {
|
|||
var playerResponse = videoInfoResponse.playerResponse;
|
||||
if (!playerResponse.isVideoPlayable) {
|
||||
throw VideoUnplayableException.unplayable(videoId,
|
||||
reason: playerResponse.getVideoPlayabilityError());
|
||||
reason: playerResponse.videoPlayabilityError ?? '');
|
||||
}
|
||||
|
||||
var hlsManifest = playerResponse.hlsManifestUrl;
|
||||
|
|
|
@ -22,19 +22,21 @@ class Video with EquatableMixin {
|
|||
final String author;
|
||||
|
||||
/// Video author Id.
|
||||
/// Note: null if the video is from a search query.
|
||||
final ChannelId channelId;
|
||||
|
||||
/// Video upload date.
|
||||
/// Note: For search queries it is calculated with:
|
||||
/// DateTime.now() - how much time is was published.
|
||||
final DateTime uploadDate;
|
||||
final DateTime? uploadDate;
|
||||
|
||||
/// Video publish date.
|
||||
final DateTime? publishDate;
|
||||
|
||||
/// Video description.
|
||||
final String description;
|
||||
|
||||
/// Duration of the video.
|
||||
final Duration duration;
|
||||
final Duration? duration;
|
||||
|
||||
/// Available thumbnails for this video.
|
||||
final ThumbnailSet thumbnails;
|
||||
|
@ -50,7 +52,7 @@ class Video with EquatableMixin {
|
|||
|
||||
/// Used internally.
|
||||
/// Shouldn't be used in the code.
|
||||
final WatchPage watchPage;
|
||||
final WatchPage? watchPage;
|
||||
|
||||
/// Returns true if the watch page is available for this video.
|
||||
bool get hasWatchPage => watchPage != null;
|
||||
|
@ -62,14 +64,15 @@ class Video with EquatableMixin {
|
|||
this.author,
|
||||
this.channelId,
|
||||
this.uploadDate,
|
||||
this.publishDate,
|
||||
this.description,
|
||||
this.duration,
|
||||
this.thumbnails,
|
||||
Iterable<String> keywords,
|
||||
Iterable<String>? keywords,
|
||||
this.engagement,
|
||||
this.isLive, // ignore: avoid_positional_boolean_parameters
|
||||
[this.watchPage])
|
||||
: keywords = UnmodifiableListView(keywords);
|
||||
: keywords = UnmodifiableListView(keywords ?? []);
|
||||
|
||||
@override
|
||||
String toString() => 'Video ($title)';
|
||||
|
|
|
@ -38,11 +38,12 @@ class VideoClient {
|
|||
playerResponse.videoAuthor,
|
||||
ChannelId(playerResponse.videoChannelId),
|
||||
playerResponse.videoUploadDate,
|
||||
playerResponse.videoPublishDate,
|
||||
playerResponse.videoDescription,
|
||||
playerResponse.videoDuration,
|
||||
ThumbnailSet(videoId.value),
|
||||
playerResponse.videoKeywords,
|
||||
Engagement(playerResponse.videoViewCount ?? 0, watchPage.videoLikeCount,
|
||||
Engagement(playerResponse.videoViewCount, watchPage.videoLikeCount,
|
||||
watchPage.videoDislikeCount),
|
||||
playerResponse.isLive,
|
||||
watchPage);
|
||||
|
|
|
@ -12,8 +12,8 @@ class VideoId with EquatableMixin {
|
|||
final String value;
|
||||
|
||||
/// Initializes an instance of [VideoId] with a url or video id.
|
||||
VideoId(String idOrUrl) : value = parseVideoId(idOrUrl) {
|
||||
if (value == null) {
|
||||
VideoId(String idOrUrl) : value = parseVideoId(idOrUrl) ?? '' {
|
||||
if (value.isEmpty) {
|
||||
throw ArgumentError.value(
|
||||
idOrUrl, 'urlOrUrl', 'Invalid YouTube video ID or URL');
|
||||
}
|
||||
|
@ -40,7 +40,7 @@ class VideoId with EquatableMixin {
|
|||
|
||||
/// Parses a video id from url or if given a valid id as url returns itself.
|
||||
/// Returns null if the id couldn't be extracted.
|
||||
static String parseVideoId(String url) {
|
||||
static String? parseVideoId(String url) {
|
||||
if (url.isNullOrWhiteSpace) {
|
||||
return null;
|
||||
}
|
||||
|
@ -51,19 +51,19 @@ class VideoId with EquatableMixin {
|
|||
|
||||
// https://www.youtube.com/watch?v=yIVRs6YSbOM
|
||||
var regMatch = _regMatchExp.firstMatch(url)?.group(1);
|
||||
if (!regMatch.isNullOrWhiteSpace && validateVideoId(regMatch)) {
|
||||
if (!regMatch.isNullOrWhiteSpace && validateVideoId(regMatch!)) {
|
||||
return regMatch;
|
||||
}
|
||||
|
||||
// https://youtu.be/yIVRs6YSbOM
|
||||
var shortMatch = _shortMatchExp.firstMatch(url)?.group(1);
|
||||
if (!shortMatch.isNullOrWhiteSpace && validateVideoId(shortMatch)) {
|
||||
if (!shortMatch.isNullOrWhiteSpace && validateVideoId(shortMatch!)) {
|
||||
return shortMatch;
|
||||
}
|
||||
|
||||
// https://www.youtube.com/embed/yIVRs6YSbOM
|
||||
var embedMatch = _embedMatchExp.firstMatch(url)?.group(1);
|
||||
if (!embedMatch.isNullOrWhiteSpace && validateVideoId(embedMatch)) {
|
||||
if (!embedMatch.isNullOrWhiteSpace && validateVideoId(embedMatch!)) {
|
||||
return embedMatch;
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
library youtube_explode.base;
|
||||
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
import 'channels/channels.dart';
|
||||
import 'playlists/playlist_client.dart';
|
||||
import 'reverse_engineering/youtube_http_client.dart';
|
||||
|
@ -8,35 +10,31 @@ import 'videos/video_client.dart';
|
|||
|
||||
/// Library entry point.
|
||||
class YoutubeExplode {
|
||||
final YoutubeHttpClient _httpClient;
|
||||
@visibleForTesting
|
||||
final YoutubeHttpClient httpClient;
|
||||
|
||||
/// Queries related to YouTube videos.
|
||||
VideoClient get videos => _videos;
|
||||
late final VideoClient videos;
|
||||
|
||||
/// Queries related to YouTube playlists.
|
||||
PlaylistClient get playlists => _playlists;
|
||||
late final PlaylistClient playlists;
|
||||
|
||||
/// Queries related to YouTube channels.
|
||||
ChannelClient get channels => _channels;
|
||||
late final ChannelClient channels;
|
||||
|
||||
/// YouTube search queries.
|
||||
SearchClient get search => _search;
|
||||
late final SearchClient search;
|
||||
|
||||
/// Initializes an instance of [YoutubeClient].
|
||||
YoutubeExplode([YoutubeHttpClient httpClient])
|
||||
: _httpClient = httpClient ?? YoutubeHttpClient() {
|
||||
_videos = VideoClient(_httpClient);
|
||||
_playlists = PlaylistClient(_httpClient);
|
||||
_channels = ChannelClient(_httpClient);
|
||||
_search = SearchClient(_httpClient);
|
||||
YoutubeExplode([YoutubeHttpClient? httpClient])
|
||||
: httpClient = httpClient ?? YoutubeHttpClient() {
|
||||
videos = VideoClient(this.httpClient);
|
||||
playlists = PlaylistClient(this.httpClient);
|
||||
channels = ChannelClient(this.httpClient);
|
||||
search = SearchClient(this.httpClient);
|
||||
}
|
||||
|
||||
VideoClient _videos;
|
||||
PlaylistClient _playlists;
|
||||
ChannelClient _channels;
|
||||
SearchClient _search;
|
||||
|
||||
/// Closes the HttpClient assigned to this [YoutubeHttpClient].
|
||||
/// Should be called after this is not used anymore.
|
||||
void close() => _httpClient.close();
|
||||
void close() => httpClient.close();
|
||||
}
|
||||
|
|
36
pubspec.yaml
36
pubspec.yaml
|
@ -1,26 +1,28 @@
|
|||
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.
|
||||
version: 1.8.0
|
||||
version: 1.9.0
|
||||
|
||||
homepage: https://github.com/Hexer10/youtube_explode_dart
|
||||
|
||||
environment:
|
||||
sdk: '>=2.6.0 <3.0.0'
|
||||
sdk: '>=2.12.0 <3.0.0'
|
||||
|
||||
dependencies:
|
||||
html: ^0.14.0+3
|
||||
http: ^0.12.0+4
|
||||
http_parser: ^3.1.3
|
||||
xml: '>=3.0.0 <5.0.0'
|
||||
equatable: ^1.1.0
|
||||
meta: ^1.1.8
|
||||
json_annotation: ^3.1.0
|
||||
collection: ^1.14.13
|
||||
collection: ^1.15.0
|
||||
equatable: ^2.0.0
|
||||
html: ^0.15.0
|
||||
http: ^0.13.0
|
||||
http_parser: ^4.0.0
|
||||
json_annotation: ^4.0.0
|
||||
meta: ^1.3.0
|
||||
xml: ^5.0.2
|
||||
|
||||
dev_dependencies:
|
||||
effective_dart: ^1.2.4
|
||||
console: ^3.1.0
|
||||
test: ^1.12.0
|
||||
grinder: ^0.8.5
|
||||
pedantic: ^1.9.2
|
||||
json_serializable: ^3.5.0
|
||||
build_runner: ^1.10.4
|
||||
#TODO: Add build_runner when is nnbd
|
||||
build_runner: ^1.12.2
|
||||
console: ^4.0.0
|
||||
grinder: ^0.9.0-nullsafety.0
|
||||
json_serializable: ^4.1.0
|
||||
lint: ^1.5.3
|
||||
pedantic: ^1.11.0
|
||||
test: ^1.16.8
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import 'channel_about_test.dart' as i0;
|
||||
import 'channel_id_test.dart' as i1;
|
||||
import 'channel_test.dart' as i2;
|
||||
import 'closed_caption_test.dart' as i3;
|
||||
import 'comments_client_test.dart' as i4;
|
||||
import 'playlist_id_test.dart' as i5;
|
||||
import 'playlist_test.dart' as i6;
|
||||
import 'search_test.dart' as i7;
|
||||
import 'streams_test.dart' as i8;
|
||||
import 'user_name_test.dart' as i9;
|
||||
import 'user_name_test.dart' as i10;
|
||||
import 'video_id_test.dart' as i11;
|
||||
import 'video_test.dart' as i12;
|
||||
|
||||
void main() {
|
||||
i0.main();
|
||||
i1.main();
|
||||
i2.main();
|
||||
i3.main();
|
||||
i4.main();
|
||||
i5.main();
|
||||
i6.main();
|
||||
i7.main();
|
||||
i8.main();
|
||||
i9.main();
|
||||
i10.main();
|
||||
i11.main();
|
||||
i12.main();
|
||||
}
|
|
@ -2,18 +2,18 @@ import 'package:test/test.dart';
|
|||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
YoutubeExplode yt;
|
||||
YoutubeExplode? yt;
|
||||
setUpAll(() {
|
||||
yt = YoutubeExplode();
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
yt.close();
|
||||
yt?.close();
|
||||
});
|
||||
|
||||
test('Get a channel about page', () async {
|
||||
var channelUrl = 'https://www.youtube.com/user/FavijTV';
|
||||
var channel = await yt.channels.getAboutPageByUsername(channelUrl);
|
||||
var channel = await yt!.channels.getAboutPageByUsername(channelUrl);
|
||||
expect(channel.country, 'Italy');
|
||||
expect(channel.thumbnails, isNotEmpty);
|
||||
expect(channel.channelLinks, isNotEmpty);
|
||||
|
|
|
@ -3,7 +3,7 @@ import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
|||
|
||||
void main() {
|
||||
group('These are valid channel ids', () {
|
||||
for (var val in <dynamic>{
|
||||
for (final val in <dynamic>{
|
||||
[ChannelId('UCEnBXANsKmyj2r9xVyKoDiQ'), 'UCEnBXANsKmyj2r9xVyKoDiQ'],
|
||||
[ChannelId('UC46807r_RiRjH8IU-h_DrDQ'), 'UC46807r_RiRjH8IU-h_DrDQ'],
|
||||
}) {
|
||||
|
@ -13,7 +13,7 @@ void main() {
|
|||
}
|
||||
});
|
||||
group('These are valid channel urls', () {
|
||||
for (var val in <dynamic>{
|
||||
for (final val in <dynamic>{
|
||||
[
|
||||
ChannelId('youtube.com/channel/UC3xnGqlcL3y-GXz5N3wiTJQ'),
|
||||
'UC3xnGqlcL3y-GXz5N3wiTJQ'
|
||||
|
@ -34,7 +34,7 @@ void main() {
|
|||
});
|
||||
|
||||
group('These are not valid channel ids', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
'',
|
||||
'UC3xnGqlcL3y-GXz5N3wiTJ',
|
||||
'UC3xnGqlcL y-GXz5N3wiTJQ'
|
||||
|
@ -46,7 +46,7 @@ void main() {
|
|||
});
|
||||
|
||||
group('These are not valid channel urls', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
'youtube.com/?channel=UCUC3xnGqlcL3y-GXz5N3wiTJQ',
|
||||
'youtube.com/channel/asd',
|
||||
'youtube.com/'
|
||||
|
|
|
@ -2,18 +2,18 @@ import 'package:test/test.dart';
|
|||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
YoutubeExplode yt;
|
||||
YoutubeExplode? yt;
|
||||
setUpAll(() {
|
||||
yt = YoutubeExplode();
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
yt.close();
|
||||
yt?.close();
|
||||
});
|
||||
|
||||
test('Get metadata of a channel', () async {
|
||||
var channelUrl = 'https://www.youtube.com/channel/UCEnBXANsKmyj2r9xVyKoDiQ';
|
||||
var channel = await yt.channels.get(ChannelId(channelUrl));
|
||||
var channel = await yt!.channels.get(ChannelId(channelUrl));
|
||||
expect(channel.url, channelUrl);
|
||||
expect(channel.title, 'Tyrrrz');
|
||||
expect(channel.logoUrl, isNotEmpty);
|
||||
|
@ -21,31 +21,31 @@ void main() {
|
|||
});
|
||||
|
||||
group('Get metadata of any channel', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
'UC46807r_RiRjH8IU-h_DrDQ',
|
||||
'UCJ6td3C9QlPO9O_J5dF4ZzA',
|
||||
'UCiGm_E4ZwYSHV3bcW1pnSeQ'
|
||||
}) {
|
||||
test('Channel - $val', () async {
|
||||
var channelId = ChannelId(val);
|
||||
var channel = await yt.channels.get(channelId);
|
||||
var channel = await yt!.channels.get(channelId);
|
||||
expect(channel.id, channelId);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Get metadata of a channel by username', () async {
|
||||
var channel = await yt.channels.getByUsername(Username('TheTyrrr'));
|
||||
var channel = await yt!.channels.getByUsername(Username('TheTyrrr'));
|
||||
expect(channel.id.value, 'UCEnBXANsKmyj2r9xVyKoDiQ');
|
||||
});
|
||||
|
||||
test('Get metadata of a channel by a video', () async {
|
||||
var channel = await yt.channels.getByVideo(VideoId('5NmxuoNyDss'));
|
||||
var channel = await yt!.channels.getByVideo(VideoId('5NmxuoNyDss'));
|
||||
expect(channel.id.value, 'UCEnBXANsKmyj2r9xVyKoDiQ');
|
||||
});
|
||||
|
||||
test('Get the videos of a youtube channel', () async {
|
||||
var videos = await yt.channels
|
||||
var videos = await yt!.channels
|
||||
.getUploads(ChannelId(
|
||||
'https://www.youtube.com/channel/UCEnBXANsKmyj2r9xVyKoDiQ'))
|
||||
.toList();
|
||||
|
@ -53,23 +53,35 @@ void main() {
|
|||
});
|
||||
|
||||
group('Get the videos of any youtube channel', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
'UC46807r_RiRjH8IU-h_DrDQ',
|
||||
'UCJ6td3C9QlPO9O_J5dF4ZzA',
|
||||
'UCiGm_E4ZwYSHV3bcW1pnSeQ'
|
||||
}) {
|
||||
test('Channel - $val', () async {
|
||||
var videos = await yt.channels.getUploads(ChannelId(val)).toList();
|
||||
var videos = await yt!.channels.getUploads(ChannelId(val)).toList();
|
||||
expect(videos, isNotEmpty);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Get videos of a youtube channel from the uploads page', () async {
|
||||
var videos = await yt.channels
|
||||
var videos = await yt!.channels
|
||||
.getUploadsFromPage('UCEnBXANsKmyj2r9xVyKoDiQ')
|
||||
.take(30)
|
||||
.toList();
|
||||
expect(videos, hasLength(30));
|
||||
});
|
||||
|
||||
test('Get about page of a youtube', () async {
|
||||
var aboutPage = await yt!.channels.getAboutPageByUsername(
|
||||
'PewDiePie'); // or yt.channels.getAboutPage(channelId)
|
||||
expect(aboutPage.title, 'PewDiePie');
|
||||
expect(aboutPage.viewCount, greaterThanOrEqualTo(27123740560));
|
||||
expect(aboutPage.description, isNotEmpty);
|
||||
expect(aboutPage.thumbnails, isNotEmpty); // Avatar list
|
||||
expect(aboutPage.channelLinks, isNotEmpty);
|
||||
expect(aboutPage.country, 'United States');
|
||||
expect(aboutPage.joinDate, 'Apr 29, 2010');
|
||||
});
|
||||
}
|
||||
|
|
|
@ -2,59 +2,64 @@ import 'package:test/test.dart';
|
|||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
YoutubeExplode yt;
|
||||
YoutubeExplode? yt;
|
||||
setUpAll(() {
|
||||
yt = YoutubeExplode();
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
yt.close();
|
||||
yt?.close();
|
||||
});
|
||||
|
||||
test('Get closed captions of a video', () async {
|
||||
var manifest = await yt.videos.closedCaptions.getManifest('WOxr2dmLHLo');
|
||||
var manifest = await yt!.videos.closedCaptions.getManifest('WOxr2dmLHLo');
|
||||
expect(manifest.tracks, isNotEmpty);
|
||||
});
|
||||
test('Get closed caption track of a video', () async {
|
||||
var manifest = await yt.videos.closedCaptions.getManifest('WOxr2dmLHLo');
|
||||
var manifest = await yt!.videos.closedCaptions.getManifest('WOxr2dmLHLo');
|
||||
var trackInfo = manifest.tracks.first;
|
||||
var track = await yt.videos.closedCaptions.get(trackInfo);
|
||||
var track = await yt!.videos.closedCaptions.get(trackInfo);
|
||||
|
||||
expect(track.captions, isNotEmpty);
|
||||
});
|
||||
|
||||
test('Get closed auto translated caption track file of a video', () async {
|
||||
var manifest = await yt.videos.closedCaptions.getManifest('WOxr2dmLHLo');
|
||||
var manifest = await yt!.videos.closedCaptions.getManifest('WOxr2dmLHLo');
|
||||
var trackInfo = manifest.tracks.first;
|
||||
|
||||
var subtitles = await yt.videos.closedCaptions
|
||||
var subtitles = await yt!.videos.closedCaptions
|
||||
.getSubTitles(trackInfo.autoTranslate('it'));
|
||||
|
||||
expect(subtitles, isNotEmpty);
|
||||
});
|
||||
test('Get closed caption track at a specific time', () async {
|
||||
var manifest = await yt.videos.closedCaptions.getManifest('qfJthDvcZ08');
|
||||
var trackInfo = manifest.getByLanguage('en', autoGenerated: false);
|
||||
var track = await yt.videos.closedCaptions.get(trackInfo.first);
|
||||
var caption =
|
||||
track.getByTime(const Duration(hours: 0, minutes: 1, seconds: 48));
|
||||
var manifest = await yt!.videos.closedCaptions.getManifest('qfJthDvcZ08');
|
||||
var trackInfo = manifest.getByLanguage('en',
|
||||
autoGenerated: false); // ignore: avoid_redundant_argument_values
|
||||
var track = await yt!.videos.closedCaptions.get(trackInfo.first);
|
||||
var caption = track.getByTime(const Duration(
|
||||
hours: 0, // ignore: avoid_redundant_argument_values
|
||||
minutes: 1,
|
||||
seconds: 48)); // ignore: avoid_redundant_argument_values
|
||||
|
||||
expect(caption, isNotNull);
|
||||
expect(caption.parts, isEmpty);
|
||||
expect(caption.text, 'But what if you don\'t have a captions file');
|
||||
expect(caption?.parts, isEmpty);
|
||||
expect(caption?.text, 'But what if you don\'t have a captions file');
|
||||
});
|
||||
|
||||
test('Get auto-generated closed caption track at a specific time', () async {
|
||||
var manifest = await yt.videos.closedCaptions.getManifest('ppJy5uGZLi4');
|
||||
var manifest = await yt!.videos.closedCaptions.getManifest('ppJy5uGZLi4');
|
||||
var trackInfo = manifest.getByLanguage('en', autoGenerated: true);
|
||||
var track = await yt.videos.closedCaptions.get(trackInfo.first);
|
||||
var caption =
|
||||
track.getByTime(const Duration(hours: 0, minutes: 13, seconds: 22));
|
||||
var captionPart = caption.getPartByTime(const Duration(milliseconds: 200));
|
||||
var track = await yt!.videos.closedCaptions.get(trackInfo.first);
|
||||
var caption = track.getByTime(const Duration(
|
||||
hours: 0, // ignore: avoid_redundant_argument_values
|
||||
minutes: 13,
|
||||
seconds: 22)); // ignore: avoid_redundant_argument_values
|
||||
var captionPart = caption!.getPartByTime(const Duration(milliseconds: 200));
|
||||
|
||||
expect(caption, isNotNull);
|
||||
expect(captionPart, isNotNull);
|
||||
expect(caption.text, 'how about this black there are some');
|
||||
expect(captionPart.text, ' about');
|
||||
expect(captionPart?.text, ' about');
|
||||
});
|
||||
}
|
||||
|
|
|
@ -2,19 +2,19 @@ import 'package:test/test.dart';
|
|||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
YoutubeExplode yt;
|
||||
YoutubeExplode? yt;
|
||||
setUpAll(() {
|
||||
yt = YoutubeExplode();
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
yt.close();
|
||||
yt?.close();
|
||||
});
|
||||
|
||||
test('Get comments of a video', () 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();
|
||||
var video = await yt!.videos.get(VideoId(videoUrl));
|
||||
var comments = await yt!.videos.commentsClient.getComments(video).toList();
|
||||
expect(comments.length, greaterThanOrEqualTo(1));
|
||||
}, skip: 'This may fail on some environments');
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
|||
|
||||
void main() {
|
||||
group('These are valid playlist ids', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
'PL601B2E69B03FAB9D',
|
||||
'PLI5YfMzCfRtZ8eV576YoY3vIYrHjyVm_e',
|
||||
'PLWwAypAcFRgKFlxtLbn_u14zddtDJj3mk',
|
||||
|
@ -23,7 +23,7 @@ void main() {
|
|||
});
|
||||
|
||||
group('These are valid playlist urls', () {
|
||||
for (var val in <dynamic>{
|
||||
for (final val in <dynamic>{
|
||||
[
|
||||
PlaylistId(
|
||||
'youtube.com/playlist?list=PLOU2XLYxmsIJGErt5rrCqaSGTMyyqNt2H'),
|
||||
|
@ -62,7 +62,7 @@ void main() {
|
|||
});
|
||||
|
||||
group('These are not valid playlist ids', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
'PLm_3vnTS-pvmZFuF L1Pyhqf8kTTYVKjW',
|
||||
'PLm_3vnTS-pvmZFuF3L=Pyhqf8kTTYVKjW'
|
||||
}) {
|
||||
|
@ -73,7 +73,7 @@ void main() {
|
|||
});
|
||||
|
||||
group('These are not valid playlist urls', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
'youtube.com/playlist?lisp=PLOU2XLYxmsIJGErt5rrCqaSGTMyyqNt2H',
|
||||
'youtube.com/playlist?list=asd'
|
||||
'youtube.com/'
|
||||
|
|
|
@ -2,19 +2,19 @@ import 'package:test/test.dart';
|
|||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
YoutubeExplode yt;
|
||||
YoutubeExplode? yt;
|
||||
setUpAll(() {
|
||||
yt = YoutubeExplode();
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
yt.close();
|
||||
yt?.close();
|
||||
});
|
||||
|
||||
test('Get metadata of a playlist', () async {
|
||||
var playlistUrl =
|
||||
'https://www.youtube.com/playlist?list=PLr-IftNTIujSF-8tlGbZBQyGIT6TCF6Yd';
|
||||
var playlist = await yt.playlists.get(PlaylistId(playlistUrl));
|
||||
var playlist = await yt!.playlists.get(PlaylistId(playlistUrl));
|
||||
expect(playlist.id.value, 'PLr-IftNTIujSF-8tlGbZBQyGIT6TCF6Yd');
|
||||
expect(playlist.url, playlistUrl);
|
||||
expect(playlist.title, 'osu! Highlights');
|
||||
|
@ -30,7 +30,7 @@ void main() {
|
|||
expect(playlist.thumbnails.maxResUrl, isNotEmpty);
|
||||
});
|
||||
group('Get metadata of any playlist', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
PlaylistId('PLI5YfMzCfRtZ8eV576YoY3vIYrHjyVm_e'),
|
||||
PlaylistId('RD1hu8-y6fKg0'),
|
||||
PlaylistId('RDMMU-ty-2B02VY'),
|
||||
|
@ -38,14 +38,14 @@ void main() {
|
|||
PlaylistId('PL601B2E69B03FAB9D')
|
||||
}) {
|
||||
test('PlaylistID - ${val.value}', () async {
|
||||
var playlist = await yt.playlists.get(val);
|
||||
var playlist = await yt!.playlists.get(val);
|
||||
expect(playlist.id.value, val.value);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Get videos in a playlist', () async {
|
||||
var videos = await yt.playlists
|
||||
var videos = await yt!.playlists
|
||||
.getVideos(PlaylistId(
|
||||
'https://www.youtube.com/playlist?list=PLr-IftNTIujSF-8tlGbZBQyGIT6TCF6Yd'))
|
||||
.toList();
|
||||
|
@ -63,8 +63,16 @@ void main() {
|
|||
]));
|
||||
});
|
||||
|
||||
test('Get more than 100 videos in a playlist', () async {
|
||||
var videos = await yt!.playlists
|
||||
.getVideos(PlaylistId(
|
||||
'https://www.youtube.com/playlist?list=PLCSusC_jlo14J0uBgFqfHsKu7gc5W2HyM'))
|
||||
.toList();
|
||||
expect(videos.length, greaterThan(100));
|
||||
});
|
||||
|
||||
group('Get videos in any playlist', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
PlaylistId('PLI5YfMzCfRtZ8eV576YoY3vIYrHjyVm_e'),
|
||||
PlaylistId('PLWwAypAcFRgKFlxtLbn_u14zddtDJj3mk'),
|
||||
PlaylistId('OLAK5uy_mtOdjCW76nDvf5yOzgcAVMYpJ5gcW5uKU'),
|
||||
|
@ -74,7 +82,7 @@ void main() {
|
|||
PlaylistId('PL601B2E69B03FAB9D'),
|
||||
}) {
|
||||
test('PlaylistID - ${val.value}', () async {
|
||||
expect(yt.playlists.getVideos(val), emits(isNotNull));
|
||||
expect(yt!.playlists.getVideos(val), emits(isNotNull));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -2,22 +2,23 @@ import 'package:test/test.dart';
|
|||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
YoutubeExplode yt;
|
||||
YoutubeExplode? yt;
|
||||
setUp(() {
|
||||
yt = YoutubeExplode();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
yt.close();
|
||||
});
|
||||
|
||||
test('Search a youtube video from the api', () async {
|
||||
var videos = await yt.search.getVideos('undead corporation megalomania');
|
||||
expect(videos, isNotEmpty);
|
||||
yt?.close();
|
||||
});
|
||||
|
||||
test('Search a youtube video from the search page', () async {
|
||||
var videos = await yt.search
|
||||
var videos = await yt!.search.getVideos('undead corporation megalomania');
|
||||
expect(videos, isNotEmpty);
|
||||
});
|
||||
|
||||
test('Search a youtube video from the search page-2', () async {
|
||||
var videos = await yt!.search
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
.getVideosFromPage('hello')
|
||||
.where((e) => e is SearchVideo) // Take only the videos.
|
||||
.cast<SearchVideo>()
|
||||
|
@ -35,18 +36,10 @@ void main() {
|
|||
expect(video.thumbnails, isNotEmpty);
|
||||
});
|
||||
|
||||
test('Search a youtube videos from the search page - old', () async {
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
var searchQuery = await yt.search.queryFromPage('hello');
|
||||
expect(searchQuery.content, isNotEmpty);
|
||||
expect(searchQuery.relatedVideos, isNotEmpty);
|
||||
expect(searchQuery.relatedQueries, isNotEmpty);
|
||||
}, skip: 'Not supported anymore');
|
||||
|
||||
test('Search with no results - old', () async {
|
||||
var query =
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
await yt.search.queryFromPage('g;jghEOGHJeguEPOUIhjegoUEHGOGHPSASG');
|
||||
await yt!.search.queryFromPage('g;jghEOGHJeguEPOUIhjegoUEHGOGHPSASG');
|
||||
expect(query.content, isEmpty);
|
||||
expect(query.relatedQueries, isEmpty);
|
||||
expect(query.relatedVideos, isEmpty);
|
||||
|
@ -56,15 +49,10 @@ void main() {
|
|||
|
||||
test('Search youtube videos have thumbnails - old', () async {
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
var searchQuery = await yt.search.queryFromPage('hello');
|
||||
var searchQuery = await yt!.search.queryFromPage('hello');
|
||||
expect(searchQuery.content.first, isA<SearchVideo>());
|
||||
|
||||
var video = searchQuery.content.first as SearchVideo;
|
||||
expect(video.thumbnails, isNotEmpty);
|
||||
});
|
||||
|
||||
test('Search youtube videos from search page (stream) - old', () async {
|
||||
var query = await yt.search.getVideosFromPage('hello').take(30).toList();
|
||||
expect(query, hasLength(30));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -2,19 +2,19 @@ import 'package:test/test.dart';
|
|||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
YoutubeExplode yt;
|
||||
YoutubeExplode? yt;
|
||||
setUpAll(() {
|
||||
yt = YoutubeExplode();
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
yt.close();
|
||||
yt?.close();
|
||||
});
|
||||
|
||||
group('Get streams manifest of any video', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
VideoId('9bZkp7q19f0'), // very popular
|
||||
VideoId('SkRSXFQerZs'), // age restricted (embed allowed)
|
||||
// VideoId('SkRSXFQerZs'), // age restricted (embed allowed) - This is unplayable
|
||||
VideoId('hySoCSoH-g8'), // age restricted (embed not allowed)
|
||||
VideoId('_kmeFXjjGfk'), // embed not allowed (type 1)
|
||||
VideoId('MeJVWBSsPAY'), // embed not allowed (type 2)
|
||||
|
@ -25,30 +25,30 @@ void main() {
|
|||
VideoId('-xNN-bJQ4vI'), // 360° video
|
||||
}) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Stream of paid videos throw VideoRequiresPurchaseException', () {
|
||||
expect(yt.videos.streamsClient.getManifest(VideoId('p3dDcKOFXQg')),
|
||||
expect(yt!.videos.streamsClient.getManifest(VideoId('p3dDcKOFXQg')),
|
||||
throwsA(const TypeMatcher<VideoRequiresPurchaseException>()));
|
||||
});
|
||||
|
||||
group('Stream of unavailable videos throws VideoUnavailableException', () {
|
||||
for (var val in {VideoId('qld9w0b-1ao'), VideoId('pb_hHv3fByo')}) {
|
||||
for (final val in {VideoId('qld9w0b-1ao'), VideoId('pb_hHv3fByo')}) {
|
||||
test('VideoId - ${val.value}', () {
|
||||
expect(yt.videos.streamsClient.getManifest(val),
|
||||
expect(yt!.videos.streamsClient.getManifest(val),
|
||||
throwsA(const TypeMatcher<VideoUnavailableException>()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
group('Get specific stream of any playable video', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
VideoId('9bZkp7q19f0'), // very popular
|
||||
VideoId('SkRSXFQerZs'), // age restricted (embed allowed)
|
||||
// VideoId('SkRSXFQerZs'), // age restricted (embed allowed) - This is unplayable
|
||||
VideoId('hySoCSoH-g8'), // age restricted (embed not allowed)
|
||||
VideoId('_kmeFXjjGfk'), // embed not allowed (type 1)
|
||||
VideoId('MeJVWBSsPAY'), // embed not allowed (type 2)
|
||||
|
@ -59,9 +59,9 @@ void main() {
|
|||
VideoId('-xNN-bJQ4vI'), // 360° video
|
||||
}) {
|
||||
test('VideoId - ${val.value}', () async {
|
||||
var manifest = await yt.videos.streamsClient.getManifest(val);
|
||||
for (var streamInfo in manifest.streams) {
|
||||
expect(yt.videos.streamsClient.get(streamInfo), emits(isNotNull));
|
||||
var manifest = await yt!.videos.streamsClient.getManifest(val);
|
||||
for (final streamInfo in manifest.streams) {
|
||||
expect(yt!.videos.streamsClient.get(streamInfo), emits(isNotNull));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -3,14 +3,14 @@ import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
|||
|
||||
void main() {
|
||||
group('These are valid usernames', () {
|
||||
for (var val in {'TheTyrrr', 'KannibalenRecords', 'JClayton1994'}) {
|
||||
for (final val in {'TheTyrrr', 'KannibalenRecords', 'JClayton1994'}) {
|
||||
test('Username - $val', () {
|
||||
expect(Username(val).value, val);
|
||||
});
|
||||
}
|
||||
});
|
||||
group('These are valid username urls', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
['youtube.com/user/ProZD', 'ProZD'],
|
||||
['youtube.com/user/TheTyrrr', 'TheTyrrr'],
|
||||
}) {
|
||||
|
@ -20,7 +20,7 @@ void main() {
|
|||
}
|
||||
});
|
||||
group('These are invalid usernames', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
'The_Tyrrr',
|
||||
'0123456789ABCDEFGHIJK',
|
||||
'A1B2C3-',
|
||||
|
@ -33,7 +33,7 @@ void main() {
|
|||
});
|
||||
|
||||
group('These are not valid username urls', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
'youtube.com/user/P_roZD',
|
||||
'example.com/user/ProZD',
|
||||
}) {
|
||||
|
|
|
@ -3,14 +3,14 @@ import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
|||
|
||||
void main() {
|
||||
group('These are valid video ids', () {
|
||||
for (var val in {'9bZkp7q19f0', '_kmeFXjjGfk', 'AI7ULzgf8RU'}) {
|
||||
for (final val in {'9bZkp7q19f0', '_kmeFXjjGfk', 'AI7ULzgf8RU'}) {
|
||||
test('VideoID - $val', () {
|
||||
expect(VideoId(val).value, val);
|
||||
});
|
||||
}
|
||||
});
|
||||
group('These are valid video urls', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
['youtube.com/watch?v=yIVRs6YSbOM', 'yIVRs6YSbOM'],
|
||||
['youtu.be/yIVRs6YSbOM', 'yIVRs6YSbOM'],
|
||||
['youtube.com/embed/yIVRs6YSbOM', 'yIVRs6YSbOM'],
|
||||
|
@ -21,14 +21,14 @@ void main() {
|
|||
}
|
||||
});
|
||||
group('These are not valid video ids', () {
|
||||
for (var val in {'', 'pI2I2zqzeK', 'pI2I2z zeKg'}) {
|
||||
for (final val in {'', 'pI2I2zqzeK', 'pI2I2z zeKg'}) {
|
||||
test('VideoID - $val', () {
|
||||
expect(() => VideoId(val), throwsArgumentError);
|
||||
});
|
||||
}
|
||||
});
|
||||
group('These are not valid video urls', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
'youtube.com/xxx?v=pI2I2zqzeKg',
|
||||
'youtu.be/watch?v=xxx',
|
||||
'youtube.com/embed'
|
||||
|
|
|
@ -2,18 +2,18 @@ import 'package:test/test.dart';
|
|||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
YoutubeExplode yt;
|
||||
YoutubeExplode? yt;
|
||||
setUpAll(() {
|
||||
yt = YoutubeExplode();
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
yt.close();
|
||||
yt?.close();
|
||||
});
|
||||
|
||||
test('Get metadata of a video', () async {
|
||||
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));
|
||||
expect(video.id.value, 'AI7ULzgf8RU');
|
||||
expect(video.url, videoUrl);
|
||||
expect(video.title, 'Aka no Ha [Another] +HDHR');
|
||||
|
@ -21,12 +21,14 @@ void main() {
|
|||
expect(video.author, 'Tyrrrz');
|
||||
var rangeMs = DateTime(2017, 09, 30, 17, 15, 26).millisecondsSinceEpoch;
|
||||
// 1day margin since the uploadDate could differ from timezones
|
||||
expect(video.uploadDate.millisecondsSinceEpoch,
|
||||
expect(video.uploadDate!.millisecondsSinceEpoch,
|
||||
inInclusiveRange(rangeMs - 86400000, rangeMs + 86400000));
|
||||
expect(video.publishDate!.millisecondsSinceEpoch,
|
||||
inInclusiveRange(rangeMs - 86400000, rangeMs + 86400000));
|
||||
expect(video.description, contains('246pp'));
|
||||
// Should be 1:38 but sometimes it differs
|
||||
// so where using a 10 seconds range from it.
|
||||
expect(video.duration.inSeconds, inInclusiveRange(108, 118));
|
||||
// so we're using a 10 seconds range from it.
|
||||
expect(video.duration!.inSeconds, inInclusiveRange(108, 118));
|
||||
expect(video.thumbnails.lowResUrl, isNotEmpty);
|
||||
expect(video.thumbnails.mediumResUrl, isNotEmpty);
|
||||
expect(video.thumbnails.highResUrl, isNotEmpty);
|
||||
|
@ -44,7 +46,7 @@ void main() {
|
|||
});
|
||||
|
||||
group('Get metadata of any video', () {
|
||||
for (var val in {
|
||||
for (final val in {
|
||||
VideoId('9bZkp7q19f0'),
|
||||
VideoId('SkRSXFQerZs'),
|
||||
VideoId('5VGm0dczmHc'),
|
||||
|
@ -52,16 +54,16 @@ void main() {
|
|||
VideoId('5qap5aO4i9A')
|
||||
}) {
|
||||
test('VideoId - ${val.value}', () async {
|
||||
var video = await yt.videos.get(val);
|
||||
var video = await yt!.videos.get(val);
|
||||
expect(video.id.value, val.value);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
group('Get metadata of invalid videos throws VideoUnplayableException', () {
|
||||
for (var val in {VideoId('qld9w0b-1ao'), VideoId('pb_hHv3fByo')}) {
|
||||
for (final val in {VideoId('qld9w0b-1ao'), VideoId('pb_hHv3fByo')}) {
|
||||
test('VideoId - $val', () {
|
||||
expect(() async => yt.videos.get(val),
|
||||
expect(() async => yt!.videos.get(val),
|
||||
throwsA(const TypeMatcher<VideoUnplayableException>()));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:grinder/grinder.dart';
|
||||
|
||||
final pub = sdkBin('pub');
|
||||
void main(args) => grind(args);
|
||||
void main(List<String> args) => grind(args);
|
||||
|
||||
@Task('Run tests')
|
||||
void test() => TestRunner().testAsync();
|
||||
|
@ -11,11 +11,11 @@ void analysis() {}
|
|||
|
||||
@DefaultTask()
|
||||
@Depends(test)
|
||||
build() {
|
||||
void build() {
|
||||
Pub.build();
|
||||
Pub.upgrade();
|
||||
Pub.version();
|
||||
}
|
||||
|
||||
@Task()
|
||||
clean() => defaultClean();
|
||||
void clean() => defaultClean();
|
||||
|
|
Loading…
Reference in New Issue