Version `1.9.0-nullsafety.4`

Fix #109
Fix #110
This commit is contained in:
Mattia 2021-03-18 22:22:34 +01:00
parent ae097a3198
commit 36f3efac70
11 changed files with 273 additions and 627 deletions

View File

@ -1,5 +1,8 @@
library _youtube_explode.extensions;
import 'dart:convert';
import 'package:collection/collection.dart';
import '../reverse_engineering/cipher/cipher_operations.dart';
/// Utility for Strings.
@ -11,36 +14,35 @@ extension StringUtility on String {
String substringUntil(String separator) => substring(0, indexOf(separator));
///
String substringAfter(String separator) =>
substring(indexOf(separator) + separator.length);
String substringAfter(String separator) => substring(indexOf(separator) + separator.length);
static final _exp = RegExp(r'\D');
/// 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();
}
DateTime parseDateTime() => DateTime.parse(this);
@ -166,3 +168,16 @@ extension RunsParser on List<dynamic> {
///
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();
}
}

View File

@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:html/dom.dart';
import 'package:html/parser.dart' as parser;
@ -18,57 +16,16 @@ class ChannelAboutPage {
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: () => '');
if (initialDataText.isNotEmpty) {
return _InitialData(json
.decode(_extractJson(initialDataText, 'window["ytInitialData"] =')));
}
initialDataText = scriptText.firstWhere(
(e) => e.contains('var ytInitialData = '),
orElse: () => '');
if (initialDataText.isNotEmpty) {
return _InitialData(
json.decode(_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
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 channel about page, please report this to the project GitHub page.'));
}
///
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;
late 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,8 +45,7 @@ class ChannelAboutPage {
}
///
static Future<ChannelAboutPage> getByUsername(
YoutubeHttpClient httpClient, String username) {
static Future<ChannelAboutPage> getByUsername(YoutubeHttpClient httpClient, String username) {
var url = 'https://www.youtube.com/user/$username/about?hl=en';
return retry(() async {
@ -128,49 +84,29 @@ class _InitialData {
.get('channelAboutFullMetadataRenderer')!;
}
late final String description =
content.get('description')!.getT<String>('simpleText')!;
late final String description = content.get('description')!.getT<String>('simpleText')!;
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') ??
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') ??
'')))
Uri.parse(e.get('icon')?.getList('thumbnails')?.firstOrNull?.getT<String>('url') ?? '')))
.toList();
late final int viewCount = int.parse(content
.get('viewCountText')!
.getT<String>('simpleText')!
.stripNonDigits());
late final int viewCount = int.parse(content.get('viewCountText')!.getT<String>('simpleText')!.stripNonDigits());
late final String joinDate =
content.get('joinedDateText')!.getList('runs')![1].getT<String>('text')!;
late final String joinDate = content.get('joinedDateText')!.getList('runs')![1].getT<String>('text')!;
late final String title = content.get('title')!.getT<String>('simpleText')!;
late final List<Map<String, dynamic>> avatar =
content.get('avatar')!.getList('thumbnails')!;
late final List<Map<String, dynamic>> avatar = content.get('avatar')!.getList('thumbnails')!;
String get 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) ?? ''));
Uri extractUrl(String text) => Uri.parse(Uri.decodeFull(_urlExp.firstMatch(text)?.group(1) ?? ''));
}

View File

@ -30,179 +30,140 @@ class ChannelUploadPage {
.map((e) => e.text)
.toList(growable: false);
var initialDataText = scriptText.firstWhere(
(e) => e.contains('window["ytInitialData"] ='),
orElse: () => '');
if (initialDataText.isNotEmpty) {
return _InitialData(json
.decode(_extractJson(initialDataText, 'window["ytInitialData"] =')));
}
initialDataText = scriptText.firstWhere(
(e) => e.contains('var ytInitialData = '),
orElse: () => '');
if (initialDataText.isNotEmpty) {
return _InitialData(
json.decode(_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
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.'));
}
String _extractJson(String html, String separator) {
return _matchJson(
html.substring(html.indexOf(separator) + separator.length));
}
///
ChannelUploadPage(this._root, this.channelId, [_InitialData ? initialData]): _initialData = initialData;
String _matchJson(String str) {
var bracketCount = 0;
late 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);
}
///
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);
return Future.value(null);
}
var url =
'https://www.youtube.com/browse_ajax?ctoken=${initialData.continuation}&continuation=${initialData.continuation}&itct=${initialData.clickTrackingParams}';
'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(json.decode(raw)[1]));
var raw = await httpClient.getString(url);
return ChannelUploadPage(
null, channelId, _InitialData(json.decode(raw)[1]));
});
}
}
///
static Future<ChannelUploadPage> get(
YoutubeHttpClient httpClient, String channelId, String sorting) {
///
static Future<ChannelUploadPage> get(
YoutubeHttpClient httpClient, String channelId, String sorting) {
var url =
'https://www.youtube.com/channel/$channelId/videos?view=0&sort=$sorting&flow=grid';
'https://www.youtube.com/channel/$channelId/videos?view=0&sort=$sorting&flow=grid';
return retry(() async {
var raw = await httpClient.getString(url);
return ChannelUploadPage.parse(raw, channelId);
var raw = await httpClient.getString(url);
return ChannelUploadPage.parse(raw, channelId);
});
}
///
ChannelUploadPage.parse(String raw, this.channelId)
: _root = parser.parse(raw);
}
///
ChannelUploadPage.parse(String raw, this.channelId)
: _root = parser.parse(raw);
}
class _InitialData {
class _InitialData {
// Json parsed map
final Map<String, dynamic> root;
_InitialData(this.root);
late final Map<String, dynamic>? continuationContext =
getContinuationContext();
getContinuationContext();
late final String clickTrackingParams =
continuationContext?.getT<String>('continuationContext') ?? '';
continuationContext?.getT<String>('continuationContext') ?? '';
late final List<ChannelVideo> uploads =
getContentContext().map(_parseContent).whereNotNull().toList();
getContentContext().map(_parseContent).whereNotNull().toList();
late final String continuation =
continuationContext?.getT<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 (context == null && root.containsKey('response')) {
context = root
.get('response')
?.get('continuationContents')
?.get('gridContinuation')
?.getList('items')
?.cast<Map<String, dynamic>>();
}
if (context == null) {
throw FatalFailureException('Failed to get initial data context.');
}
return context;
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 (context == null && root.containsKey('response')) {
context = root
.get('response')
?.get('continuationContents')
?.get('gridContinuation')
?.getList('items')
?.cast<Map<String, dynamic>>();
}
if (context == null) {
throw FatalFailureException('Failed to get initial data context.');
}
return context;
}
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.containsKey('response')) {
return root
.get('response')
?.get('continuationContents')
?.get('gridContinuation')
?.getList('continuations')
?.firstOrNull
?.get('nextContinuationData');
}
return null;
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.containsKey('response')) {
return root
.get('response')
?.get('continuationContents')
?.get('gridContinuation')
?.getList('continuations')
?.firstOrNull
?.get('nextContinuationData');
}
return null;
}
ChannelVideo? _parseContent(Map<String, dynamic>? content) {
if (content == null || !content.containsKey('gridVideoRenderer')) {
return null;
}
var video = content.get('gridVideoRenderer')!;
return ChannelVideo(
VideoId(video.getT<String>('videoId')!),
video.get('title')?.getT<String>('simpleText') ??
video.get('title')?.getList('runs')?.map((e) => e['text']).join() ??
'');
if (content == null || !content.containsKey('gridVideoRenderer')) {
return null;
}
var video = content.get('gridVideoRenderer')!;
return ChannelVideo(
VideoId(video.getT<String>('videoId')!),
video.get('title')?.getT<String>('simpleText') ??
video.get('title')?.getList('runs')?.map((e) => e['text']).join() ??
'');
}
}
}

View File

@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:html/dom.dart';
import 'package:html/parser.dart' as parser;
@ -11,8 +9,7 @@ import 'player_config_base.dart';
///
class EmbedPage {
static final _playerConfigExp =
RegExp('[\'""]PLAYER_CONFIG[\'""]\\s*:\\s*(\\{.*\\})');
static final _playerConfigExp = RegExp('[\'""]PLAYER_CONFIG[\'""]\\s*:\\s*(\\{.*\\})');
static final _playerConfigExp2 = RegExp(r'yt.setConfig\((\{.*\})');
final Document root;
@ -24,8 +21,7 @@ class EmbedPage {
.querySelectorAll('*[name="player_ias/base"]')
.map((e) => e.attributes['src'])
.where((e) => !e.isNullOrWhiteSpace)
.firstWhere((e) => e!.contains('player_ias') && e.endsWith('.js'),
orElse: () => null);
.firstWhere((e) => e!.contains('player_ias') && e.endsWith('.js'), orElse: () => null);
// _root.querySelector('*[name="player_ias/base"]').attributes['src'];
if (url == null) {
return null;
@ -35,11 +31,11 @@ class EmbedPage {
///
EmbedPlayerConfig? getPlayerConfig() {
var playerConfigJson = _playerConfigJson ?? _playerConfigJson2;
var playerConfigJson = (_playerConfigJson ?? _playerConfigJson2)?.extractJson();
if (playerConfigJson == null) {
return null;
}
return EmbedPlayerConfig(json.decode(playerConfigJson.extractJson()));
return EmbedPlayerConfig(playerConfigJson);
}
String? get _playerConfigJson => root

View File

@ -123,6 +123,8 @@ class PlayerResponse {
late final String? videoPlayabilityError =
root.get('playabilityStatus')?.getT<String>('reason');
PlayerResponse(this.root);
///
PlayerResponse.parse(String raw) : root = json.decode(raw);
}

View File

@ -24,59 +24,16 @@ class PlaylistPage {
return _initialData!;
}
final scriptText = root!
.querySelectorAll('script')
.map((e) => e.text)
.toList(growable: false);
final scriptText = root!.querySelectorAll('script').map((e) => e.text).toList(growable: false);
var initialDataText = scriptText
.firstWhereOrNull((e) => e.contains('window["ytInitialData"] ='));
if (initialDataText != null) {
return _InitialData(json
.decode(_extractJson(initialDataText, 'window["ytInitialData"] =')));
}
initialDataText =
scriptText.firstWhereOrNull((e) => e.contains('var ytInitialData = '));
if (initialDataText != null) {
return _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) {
var index = html.indexOf(separator) + separator.length;
if (index > html.length) {
throw TransientFailureException(
'Failed to retrieve initial data from the search page, please report this to the project GitHub page. Couldn\'t extract json: $html');
}
return _matchJson(html.substring(index));
}
String _matchJson(String str) {
var bracketCount = 0;
var lastI = 0;
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])
: _initialData = initialData;
PlaylistPage(this.root, this.playlistId, [_InitialData? initialData]) : _initialData = initialData;
///
Future<PlaylistPage?> nextPage(YoutubeHttpClient httpClient) async {
@ -87,26 +44,19 @@ class PlaylistPage {
}
///
static Future<PlaylistPage> get(YoutubeHttpClient httpClient, String id,
{String? token}) {
static Future<PlaylistPage> get(YoutubeHttpClient httpClient, String id, {String? token}) {
if (token != null && token.isNotEmpty) {
var url =
'https://www.youtube.com/youtubei/v1/search?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
var url = 'https://www.youtube.com/youtubei/v1/guide?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
return retry(() async {
var body = {
'context': const {
'client': {
'hl': 'en',
'clientName': 'WEB',
'clientVersion': '2.20200911.04.00'
}
'client': {'hl': 'en', 'clientName': 'WEB', 'clientVersion': '2.20200911.04.00'}
},
'continuation': token
};
var raw =
await httpClient.post(Uri.parse(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,
@ -130,10 +80,7 @@ class _InitialData {
_InitialData(this.root);
late final String? title = root
.get('metadata')
?.get('playlistMetadataRenderer')
?.getT<String>('title');
late final String? title = root.get('metadata')?.get('playlistMetadataRenderer')?.getT<String>('title');
late final String? author = root
.get('sidebar')
@ -147,10 +94,7 @@ class _InitialData {
?.getT<List<dynamic>>('runs')
?.parseRuns();
late final String? description = root
.get('metadata')
?.get('playlistMetadataRenderer')
?.getT<String>('description');
late final String? description = root.get('metadata')?.get('playlistMetadataRenderer')?.getT<String>('description');
late final int? viewCount = root
.get('sidebar')
@ -163,13 +107,12 @@ class _InitialData {
?.getT<String>('simpleText')
?.parseInt();
late final String? continuationToken =
(videosContent ?? playlistVideosContent)
?.firstWhereOrNull((e) => e['continuationItemRenderer'] != 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 =>
root
@ -187,11 +130,7 @@ class _InitialData {
?.firstOrNull
?.get('playlistVideoListRenderer')
?.getList('contents') ??
root
.getList('onResponseReceivedActions')
?.firstOrNull
?.get('appendContinuationItemsAction')
?.getList('continuationItems');
root.getList('onResponseReceivedActions')?.firstOrNull?.get('appendContinuationItemsAction')?.getList('continuationItems');
late final List<Map<String, dynamic>>? videosContent = root
.get('contents')
@ -199,17 +138,10 @@ class _InitialData {
?.get('primaryContents')
?.get('sectionListRenderer')
?.getList('contents') ??
root
.getList('onResponseReceivedCommands')
?.firstOrNull
?.get('appendContinuationItemsAction')
?.getList('continuationItems');
root.getList('onResponseReceivedCommands')?.firstOrNull?.get('appendContinuationItemsAction')?.getList('continuationItems');
List<_Video> get playlistVideos =>
playlistVideosContent
?.where((e) => e['playlistVideoRenderer'] != null)
.map((e) => _Video(e['playlistVideoRenderer']))
.toList() ??
playlistVideosContent?.where((e) => e['playlistVideoRenderer'] != null).map((e) => _Video(e['playlistVideoRenderer'])).toList() ??
const [];
List<_Video> get videos =>
@ -236,13 +168,7 @@ class _Video {
'';
String get channelId =>
root
.get('ownerText')
?.getList('runs')
?.firstOrNull
?.get('navigationEndpoint')
?.get('browseEndpoint')
?.getT<String>('browseId') ??
root.get('ownerText')?.getList('runs')?.firstOrNull?.get('navigationEndpoint')?.get('browseEndpoint')?.getT<String>('browseId') ??
root
.get('shortBylineText')
?.getList('runs')
@ -254,14 +180,11 @@ class _Video {
String get title => root.get('title')?.getList('runs')?.parseRuns() ?? '';
String get description =>
root.getList('descriptionSnippet')?.parseRuns() ?? '';
String get description => root.getList('descriptionSnippet')?.parseRuns() ?? '';
Duration? get duration =>
_stringToDuration(root.get('lengthText')?.getT<String>('simpleText'));
Duration? get duration => _stringToDuration(root.get('lengthText')?.getT<String>('simpleText'));
int get viewCount =>
root.get('viewCountText')?.getT<String>('simpleText')?.parseInt() ?? 0;
int get viewCount => root.get('viewCountText')?.getT<String>('simpleText')?.parseInt() ?? 0;
/// Format: HH:MM:SS
static Duration? _stringToDuration(String? string) {
@ -276,14 +199,10 @@ class _Video {
return Duration(seconds: int.parse(parts.first));
}
if (parts.length == 2) {
return Duration(
minutes: int.parse(parts.first), seconds: int.parse(parts[1]));
return Duration(minutes: int.parse(parts.first), 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]));
return Duration(hours: int.parse(parts[0]), minutes: int.parse(parts[1]), seconds: int.parse(parts[2]));
}
throw Error();
}

View File

@ -28,98 +28,43 @@ class SearchPage {
return _initialData!;
}
final scriptText = root!
.querySelectorAll('script')
.map((e) => e.text)
.toList(growable: false);
var initialDataText = scriptText
.firstWhereOrNull((e) => e.contains('window["ytInitialData"] ='));
if (initialDataText != null) {
return _initialData = _InitialData(json
.decode(_extractJson(initialDataText, 'window["ytInitialData"] =')));
}
initialDataText =
scriptText.firstWhereOrNull((e) => e.contains('var ytInitialData = '));
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) {
var index = html.indexOf(separator) + separator.length;
if (index > html.length) {
throw TransientFailureException(
'Failed to retrieve initial data from the search page, please report this to the project GitHub page. Couldn\'t extract json: $html');
}
return _matchJson(html.substring(index));
}
String _matchJson(String str) {
var bracketCount = 0;
var lastI = 0;
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);
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 search page, please report this to the project GitHub page.'));
}
///
SearchPage(this.root, this.queryString, [_InitialData? initialData])
: _initialData = initialData;
SearchPage(this.root, this.queryString, [_InitialData? initialData]) : _initialData = initialData;
Future<SearchPage?> nextPage(YoutubeHttpClient httpClient) async {
if (initialData.continuationToken == '' ||
initialData.estimatedResults == 0) {
if (initialData.continuationToken == '' || initialData.estimatedResults == 0) {
return null;
}
return get(httpClient, queryString, token: initialData.continuationToken);
}
///
static Future<SearchPage> get(
YoutubeHttpClient httpClient, String queryString,
{String? token}) {
static Future<SearchPage> get(YoutubeHttpClient httpClient, String queryString, {String? token}) {
if (token != null) {
var url =
'https://www.youtube.com/youtubei/v1/search?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
var url = 'https://www.youtube.com/youtubei/v1/search?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
return retry(() async {
var body = {
'context': const {
'client': {
'hl': 'en',
'clientName': 'WEB',
'clientVersion': '2.20200911.04.00'
}
'client': {'hl': 'en', 'clientName': 'WEB', 'clientVersion': '2.20200911.04.00'}
},
'continuation': token
};
var raw =
await httpClient.post(Uri.parse(url), body: json.encode(body));
return SearchPage(
null, queryString, _InitialData(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)}';
var url = 'https://www.youtube.com/results?search_query=${Uri.encodeQueryComponent(queryString)}';
return retry(() async {
var raw = await httpClient.getString(url);
return SearchPage.parse(raw, queryString);
@ -197,9 +142,7 @@ class _InitialData {
}
// Contains only [SearchVideo] or [SearchPlaylist]
late final List<BaseSearchContent> searchContent =
getContentContext()?.map(_parseContent).whereNotNull().toList() ??
const [];
late final List<BaseSearchContent> searchContent = getContentContext()?.map(_parseContent).whereNotNull().toList() ?? const [];
List<RelatedQuery> get relatedQueries =>
getContentContext()
@ -207,10 +150,8 @@ class _InitialData {
.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])))
.map((e) =>
RelatedQuery(e.searchEndpoint.searchEndpoint.query, VideoId(Uri.parse(e.thumbnail.thumbnails.first.url).pathSegments[1])))
.toList()
.cast<RelatedQuery>() ??
const [];
@ -218,11 +159,7 @@ class _InitialData {
List<dynamic> get relatedVideos =>
getContentContext()
?.where((e) => e['shelfRenderer'] != null)
.map((e) => e
.get('shelfRenderer')
?.get('content')
?.get('verticalListRenderer')
?.getList('items'))
.map((e) => e.get('shelfRenderer')?.get('content')?.get('verticalListRenderer')?.getList('items'))
.firstOrNull
?.map(_parseContent)
.whereNotNull()
@ -231,8 +168,7 @@ class _InitialData {
late final String? continuationToken = _getContinuationToken();
late final int estimatedResults =
int.parse(root.getT<String>('estimatedResults') ?? '0');
late final int estimatedResults = int.parse(root.getT<String>('estimatedResults') ?? '0');
BaseSearchContent? _parseContent(Map<String, dynamic>? content) {
if (content == null) {
@ -247,47 +183,24 @@ class _InitialData {
_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
.get('viewCountText')
?.getList('runs')
?.firstOrNull
?.getT<String>('text')
?.stripNonDigits()
.nullIfWhitespace ??
int.parse(renderer.get('viewCountText')?.getT<String>('simpleText')?.stripNonDigits().nullIfWhitespace ??
renderer.get('viewCountText')?.getList('runs')?.firstOrNull?.getT<String>('text')?.stripNonDigits().nullIfWhitespace ??
'0'),
(renderer.get('thumbnail')?.getList('thumbnails') ?? const [])
.map((e) =>
Thumbnail(Uri.parse(e['url']), e['height'], e['width']))
.map((e) => Thumbnail(Uri.parse(e['url']), e['height'], e['width']))
.toList(),
renderer.get('publishedTimeText')?.getT<String>('simpleText'),
renderer
.get('viewCountText')
?.getList('runs')
?.elementAtSafe(1)
?.getT<String>('text')
?.trim() ==
'watching');
renderer.get('viewCountText')?.getList('runs')?.elementAtSafe(1)?.getT<String>('text')?.trim() == 'watching');
}
if (content['radioRenderer'] != null) {
var renderer = content.get('radioRenderer')!;
return SearchPlaylist(
PlaylistId(renderer.getT<String>('playlistId')!),
renderer.get('title')!.getT<String>('simpleText')!,
int.parse(_parseRuns(renderer.get('videoCountText')?.getList('runs'))
.stripNonDigits()
.nullIfWhitespace ??
'0'));
return SearchPlaylist(PlaylistId(renderer.getT<String>('playlistId')!), renderer.get('title')!.getT<String>('simpleText')!,
int.parse(_parseRuns(renderer.get('videoCountText')?.getList('runs')).stripNonDigits().nullIfWhitespace ?? '0'));
}
// 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() ?? '';
}

View File

@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:html/dom.dart';
import 'package:html/parser.dart' as parser;
@ -14,15 +12,11 @@ import 'player_response.dart';
///
class WatchPage {
static final RegExp _videoLikeExp =
RegExp(r'"label"\s*:\s*"([\d,\.]+) likes"');
static final RegExp _videoDislikeExp =
RegExp(r'"label"\s*:\s*"([\d,\.]+) dislikes"');
static final RegExp _visitorInfoLiveExp =
RegExp('VISITOR_INFO1_LIVE=([^;]+)');
static final RegExp _videoLikeExp = RegExp(r'"label"\s*:\s*"([\d,\.]+) likes"');
static final RegExp _videoDislikeExp = RegExp(r'"label"\s*:\s*"([\d,\.]+) dislikes"');
static final RegExp _visitorInfoLiveExp = RegExp('VISITOR_INFO1_LIVE=([^;]+)');
static final RegExp _yscExp = RegExp('YSC=([^;]+)');
static final RegExp _playerResponseExp =
RegExp(r'var\s+ytInitialPlayerResponse\s*=\s*(\{.*\})');
static final RegExp _playerResponseExp = RegExp(r'var\s+ytInitialPlayerResponse\s*=\s*(\{.*\})');
static final _xsfrTokenExp = RegExp(r'"XSRF_TOKEN"\s*:\s*"(.+?)"');
@ -57,85 +51,51 @@ class WatchPage {
return _initialData!;
}
final scriptText = root
.querySelectorAll('script')
.map((e) => e.text)
.toList(growable: false);
var initialDataText = scriptText
.firstWhereOrNull((e) => e.contains('window["ytInitialData"] ='));
if (initialDataText != null) {
return _initialData = _InitialData(json
.decode(_extractJson(initialDataText, 'window["ytInitialData"] =')));
}
initialDataText =
scriptText.firstWhereOrNull((e) => e.contains('var ytInitialData = '));
if (initialDataText != null) {
return _initialData = _InitialData(
json.decode(_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
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.'));
}
late final String xsfrToken = getXsfrToken()!;
///
String? getXsfrToken() {
return _xsfrTokenExp
.firstMatch(root
.querySelectorAll('script')
.firstWhere((e) => _xsfrTokenExp.hasMatch(e.text))
.text)
?.group(1);
return _xsfrTokenExp.firstMatch(root.querySelectorAll('script').firstWhere((e) => _xsfrTokenExp.hasMatch(e.text)).text)?.group(1);
}
///
bool get isOk => root.body?.querySelector('#player') != null;
///
bool get isVideoAvailable =>
root.querySelector('meta[property="og:url"]') != null;
bool get isVideoAvailable => root.querySelector('meta[property="og:url"]') != null;
///
int get videoLikeCount => int.parse(_videoLikeExp
.firstMatch(root.outerHtml)
?.group(1)
?.stripNonDigits()
.nullIfWhitespace ??
root
.querySelector('.like-button-renderer-like-button')
?.text
.stripNonDigits()
.nullIfWhitespace ??
int get videoLikeCount => int.parse(_videoLikeExp.firstMatch(root.outerHtml)?.group(1)?.stripNonDigits().nullIfWhitespace ??
root.querySelector('.like-button-renderer-like-button')?.text.stripNonDigits().nullIfWhitespace ??
'0');
///
int get videoDislikeCount => int.parse(_videoDislikeExp
.firstMatch(root.outerHtml)
?.group(1)
?.stripNonDigits()
.nullIfWhitespace ??
root
.querySelector('.like-button-renderer-dislike-button')
?.text
.stripNonDigits()
.nullIfWhitespace ??
int get videoDislikeCount => int.parse(_videoDislikeExp.firstMatch(root.outerHtml)?.group(1)?.stripNonDigits().nullIfWhitespace ??
root.querySelector('.like-button-renderer-dislike-button')?.text.stripNonDigits().nullIfWhitespace ??
'0');
static final _playerConfigExp = RegExp(r'ytplayer\.config\s*=\s*(\{.*\})');
late final WatchPlayerConfig playerConfig = WatchPlayerConfig(json.decode(
_playerConfigExp
.firstMatch(root.getElementsByTagName('html').first.text)
?.group(1)
?.extractJson() ??
'a'));
late final WatchPlayerConfig? playerConfig = getPlayerConfig();
late final PlayerResponse? playerResponse = getPlayerResponse();
///
WatchPlayerConfig? getPlayerConfig() {
final jsonMap = _playerConfigExp.firstMatch(root.getElementsByTagName('html').first.text)?.group(1)?.extractJson();
if (jsonMap == null) {
return null;
}
return WatchPlayerConfig(jsonMap);
}
///
PlayerResponse? getPlayerResponse() {
final val = root
@ -147,18 +107,14 @@ class WatchPage {
if (val == null) {
return null;
}
return PlayerResponse.parse(val);
return PlayerResponse(val);
}
String _extractJson(String html, String separator) =>
html.substring(html.indexOf(separator) + separator.length).extractJson();
///
WatchPage(this.root, this.visitorInfoLive, this.ysc);
///
WatchPage.parse(String raw, this.visitorInfoLive, this.ysc)
: root = parser.parse(raw);
WatchPage.parse(String raw, this.visitorInfoLive, this.ysc) : root = parser.parse(raw);
///
static Future<WatchPage> get(YoutubeHttpClient httpClient, String videoId) {
@ -167,9 +123,9 @@ class WatchPage {
var req = await httpClient.get(url, validate: true);
var cookies = req.headers['set-cookie']!;
var visitorInfoLive = _visitorInfoLiveExp.firstMatch(cookies)!.group(1)!;
var visitorInfoLive = _visitorInfoLiveExp.firstMatch(cookies)?.group(1)!;
var ysc = _yscExp.firstMatch(cookies)!.group(1)!;
var result = WatchPage.parse(req.body, visitorInfoLive, ysc);
var result = WatchPage.parse(req.body, visitorInfoLive ?? '', ysc);
if (!result.isOk) {
throw TransientFailureException('Video watch page is broken.');
@ -192,12 +148,10 @@ class WatchPlayerConfig implements PlayerConfigBase<Map<String, dynamic>> {
WatchPlayerConfig(this.root);
@override
late final String sourceUrl =
'https://youtube.com${root.get('assets')!.getT<String>('js')}';
late final String sourceUrl = 'https://youtube.com${root.get('assets')!.getT<String>('js')}';
///
late final PlayerResponse playerResponse =
PlayerResponse.parse(root.get('args')!.getT<String>('playerResponse')!);
late final PlayerResponse playerResponse = PlayerResponse.parse(root.get('args')!.getT<String>('playerResponse')!);
}
class _InitialData {
@ -223,9 +177,7 @@ class _InitialData {
return null;
}
late final String continuation =
getContinuationContext()?.getT<String>('continuation') ?? '';
late final String continuation = getContinuationContext()?.getT<String>('continuation') ?? '';
late final String clickTrackingParams =
getContinuationContext()?.getT<String>('clickTrackingParams') ?? '';
late final String clickTrackingParams = getContinuationContext()?.getT<String>('clickTrackingParams') ?? '';
}

View File

@ -21,10 +21,8 @@ class StreamsClient {
/// Initializes an instance of [StreamsClient]
StreamsClient(this._httpClient);
Future<DashManifest> _getDashManifest(
Uri dashManifestUrl, Iterable<CipherOperation> cipherOperations) {
var signature =
DashManifest.getSignatureFromUrl(dashManifestUrl.toString());
Future<DashManifest> _getDashManifest(Uri dashManifestUrl, Iterable<CipherOperation> cipherOperations) {
var signature = DashManifest.getSignatureFromUrl(dashManifestUrl.toString());
if (!signature.isNullOrWhiteSpace) {
signature = cipherOperations.decipher(signature!);
dashManifestUrl = dashManifestUrl.setQueryParam('signature', signature);
@ -39,74 +37,56 @@ class StreamsClient {
throw VideoUnplayableException.unplayable(videoId);
}
var playerSource = await PlayerSource.get(
_httpClient, embedPage.sourceUrl ?? playerConfig.sourceUrl);
var playerSource = await PlayerSource.get(_httpClient, embedPage.sourceUrl ?? playerConfig.sourceUrl);
var cipherOperations = playerSource.getCipherOperations();
var videoInfoResponse = await VideoInfoResponse.get(
_httpClient, videoId.toString(), playerSource.sts);
var videoInfoResponse = await VideoInfoResponse.get(_httpClient, videoId.toString(), playerSource.sts);
var playerResponse = videoInfoResponse.playerResponse;
var previewVideoId = playerResponse.previewVideoId;
if (!previewVideoId.isNullOrWhiteSpace) {
throw VideoRequiresPurchaseException.preview(
videoId, VideoId(previewVideoId!));
throw VideoRequiresPurchaseException.preview(videoId, VideoId(previewVideoId!));
}
if (!playerResponse.isVideoPlayable) {
throw VideoUnplayableException.unplayable(videoId,
reason: playerResponse.videoPlayabilityError ?? '');
throw VideoUnplayableException.unplayable(videoId, reason: playerResponse.videoPlayabilityError ?? '');
}
if (playerResponse.isLive) {
throw VideoUnplayableException.liveStream(videoId);
}
var streamInfoProviders = <StreamInfoProvider>[
...videoInfoResponse.streams,
...playerResponse.streams
];
var streamInfoProviders = <StreamInfoProvider>[...videoInfoResponse.streams, ...playerResponse.streams];
var dashManifestUrl = playerResponse.dashManifestUrl;
if (!dashManifestUrl.isNullOrWhiteSpace) {
var dashManifest =
await _getDashManifest(Uri.parse(dashManifestUrl!), cipherOperations);
var dashManifest = 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());
WatchPlayerConfig? playerConfig;
try {
playerConfig = watchPage.playerConfig;
} on FormatException {
playerConfig = null;
}
var playerResponse =
playerConfig?.playerResponse ?? watchPage.playerResponse;
final playerConfig = watchPage.playerConfig;
var playerResponse = playerConfig?.playerResponse ?? watchPage.playerResponse;
if (playerResponse == null) {
throw VideoUnplayableException.unplayable(videoId);
}
var previewVideoId = playerResponse.previewVideoId;
if (!previewVideoId.isNullOrWhiteSpace) {
throw VideoRequiresPurchaseException.preview(
videoId, VideoId(previewVideoId!));
throw VideoRequiresPurchaseException.preview(videoId, VideoId(previewVideoId!));
}
var playerSourceUrl = watchPage.sourceUrl ?? playerConfig?.sourceUrl;
var playerSource = !playerSourceUrl.isNullOrWhiteSpace
? await PlayerSource.get(_httpClient, playerSourceUrl!)
: null;
var cipherOperations =
playerSource?.getCipherOperations() ?? const <CipherOperation>[];
var playerSource = !playerSourceUrl.isNullOrWhiteSpace ? await PlayerSource.get(_httpClient, playerSourceUrl!) : null;
var cipherOperations = playerSource?.getCipherOperations() ?? const <CipherOperation>[];
if (!playerResponse.isVideoPlayable) {
throw VideoUnplayableException.unplayable(videoId,
reason: playerResponse.videoPlayabilityError ?? '');
throw VideoUnplayableException.unplayable(videoId, reason: playerResponse.videoPlayabilityError ?? '');
}
if (playerResponse.isLive) {
@ -119,8 +99,7 @@ class StreamsClient {
var dashManifestUrl = playerResponse.dashManifestUrl;
if (!(dashManifestUrl?.isNullOrWhiteSpace ?? true)) {
var dashManifest =
await _getDashManifest(Uri.parse(dashManifestUrl!), cipherOperations);
var dashManifest = await _getDashManifest(Uri.parse(dashManifestUrl!), cipherOperations);
streamInfoProviders.addAll(dashManifest.streams);
}
return StreamContext(streamInfoProviders, cipherOperations);
@ -144,9 +123,7 @@ class StreamsClient {
}
// Content length
var contentLength = streamInfo.contentLength ??
await _httpClient.getContentLength(url, validate: false) ??
0;
var contentLength = streamInfo.contentLength ?? await _httpClient.getContentLength(url, validate: false) ?? 0;
if (contentLength <= 0) {
continue;
@ -163,53 +140,31 @@ class StreamsClient {
// Muxed or Video-only
if (!videoCodec.isNullOrWhiteSpace) {
var framerate = Framerate(streamInfo.framerate ?? 24);
var videoQualityLabel = streamInfo.videoQualityLabel ??
VideoQualityUtil.getLabelFromTagWithFramerate(
tag, framerate.framesPerSecond.toDouble());
var videoQualityLabel =
streamInfo.videoQualityLabel ?? VideoQualityUtil.getLabelFromTagWithFramerate(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 ?? 0, videoHeight ?? 0)
: videoQuality.toVideoResolution();
var videoResolution =
videoWidth != -1 && videoHeight != -1 ? VideoResolution(videoWidth ?? 0, videoHeight ?? 0) : videoQuality.toVideoResolution();
// Muxed
if (!audioCodec.isNullOrWhiteSpace) {
streams[tag] = MuxedStreamInfo(
tag,
url,
container,
fileSize,
bitrate,
audioCodec!,
videoCodec!,
videoQualityLabel,
videoQuality,
videoResolution,
framerate);
streams[tag] = MuxedStreamInfo(tag, url, container, fileSize, bitrate, audioCodec!, videoCodec!, videoQualityLabel, videoQuality,
videoResolution, framerate);
continue;
}
// Video only
streams[tag] = VideoOnlyStreamInfo(
tag,
url,
container,
fileSize,
bitrate,
videoCodec!,
videoQualityLabel,
videoQuality,
videoResolution,
framerate);
tag, url, container, fileSize, bitrate, videoCodec!, videoQualityLabel, videoQuality, videoResolution, framerate);
continue;
}
// Audio-only
if (!audioCodec.isNullOrWhiteSpace) {
streams[tag] = AudioOnlyStreamInfo(
tag, url, container, fileSize, bitrate, audioCodec!);
streams[tag] = AudioOnlyStreamInfo(tag, url, container, fileSize, bitrate, audioCodec!);
}
// #if DEBUG
@ -239,12 +194,10 @@ class StreamsClient {
/// Gets the HTTP Live Stream (HLS) manifest URL
/// for the specified video (if it's a live video stream).
Future<String> getHttpLiveStreamUrl(VideoId videoId) async {
var videoInfoResponse =
await VideoInfoResponse.get(_httpClient, videoId.toString());
var videoInfoResponse = await VideoInfoResponse.get(_httpClient, videoId.toString());
var playerResponse = videoInfoResponse.playerResponse;
if (!playerResponse.isVideoPlayable) {
throw VideoUnplayableException.unplayable(videoId,
reason: playerResponse.videoPlayabilityError ?? '');
throw VideoUnplayableException.unplayable(videoId, reason: playerResponse.videoPlayabilityError ?? '');
}
var hlsManifest = playerResponse.hlsManifestUrl;
@ -255,6 +208,5 @@ class StreamsClient {
}
/// Gets the actual stream which is identified by the specified metadata.
Stream<List<int>> get(StreamInfo streamInfo) =>
_httpClient.getStream(streamInfo);
Stream<List<int>> get(StreamInfo streamInfo) => _httpClient.getStream(streamInfo);
}

View File

@ -1,6 +1,6 @@
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.9.0-nullsafety.3
version: 1.9.0-nullsafety.4
homepage: https://github.com/Hexer10/youtube_explode_dart

View File

@ -25,7 +25,7 @@ void main() {
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.
// 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);