More on v5
This commit is contained in:
parent
447f7f27d7
commit
911712cfa1
|
@ -13,6 +13,7 @@ linter:
|
|||
- prefer_const_literals_to_create_immutables
|
||||
- prefer_constructors_over_static_methods
|
||||
- prefer_contains
|
||||
- annotate_overrides
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
library youtube_explode.exceptions;
|
||||
|
||||
export 'unrecognized_structure_exception.dart';
|
||||
export 'fatal_failure_exception.dart';
|
||||
export 'request_limit_exceeded_exception.dart';
|
||||
export 'transient_failure_exception.dart';
|
||||
export 'video_requires_purchase_exception.dart';
|
||||
export 'video_stream_unavailable_exception.dart';
|
||||
export 'video_unavailable_exception.dart';
|
||||
export 'video_unplayable_exception.dart';
|
||||
export 'youtube_explode_exception.dart';
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
|
||||
import 'youtube_explode_exception.dart';
|
||||
|
||||
/// Exception thrown when a fatal failure occurs.
|
||||
class FatalFailureException implements YoutubeExplodeException {
|
||||
|
||||
/// Description message
|
||||
final String message;
|
||||
|
||||
/// Initializes an instance of [FatalFailureException]
|
||||
FatalFailureException(this.message);
|
||||
|
||||
/// Initializes an instance of [FatalFailureException] with an [HttpRequest]
|
||||
/// Initializes an instance of [FatalFailureException] with a [Response]
|
||||
FatalFailureException.httpRequest(Response response)
|
||||
: message = '''
|
||||
Failed to perform an HTTP request to YouTube due to a fatal failure.
|
||||
|
|
|
@ -4,12 +4,14 @@ import 'youtube_explode_exception.dart';
|
|||
|
||||
/// Exception thrown when a fatal failure occurs.
|
||||
class RequestLimitExceeded implements YoutubeExplodeException {
|
||||
|
||||
/// Description message
|
||||
final String message;
|
||||
|
||||
/// Initializes an instance of [FatalFailureException]
|
||||
/// Initializes an instance of [RequestLimitExceeded]
|
||||
RequestLimitExceeded(this.message);
|
||||
|
||||
/// Initializes an instance of [FatalFailureException] with an [HttpRequest]
|
||||
/// Initializes an instance of [RequestLimitExceeded] with a [Response]
|
||||
RequestLimitExceeded.httpRequest(Response response)
|
||||
: message = '''
|
||||
Failed to perform an HTTP request to YouTube because of rate limiting.
|
||||
|
@ -21,5 +23,5 @@ Response: $response
|
|||
''';
|
||||
|
||||
@override
|
||||
String toString() => 'FatalFailureException: $message';
|
||||
String toString() => 'RequestLimitExceeded: $message';
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import 'package:http/http.dart';
|
||||
|
||||
import 'youtube_explode_exception.dart';
|
||||
|
||||
/// Exception thrown when a fatal failure occurs.
|
||||
class TransientFailureException implements YoutubeExplodeException {
|
||||
final String message;
|
||||
|
||||
/// Initializes an instance of [TransientFailureException]
|
||||
TransientFailureException(this.message);
|
||||
|
||||
/// Initializes an instance of [TransientFailureException] with a [Response]
|
||||
TransientFailureException.httpRequest(Response response)
|
||||
: message = '''
|
||||
Failed to perform an HTTP request to YouTube due to a transient failure.
|
||||
In most cases, this error indicates that the problem is on YouTube's side and this is not a bug in the library.
|
||||
To resolve this error, please wait some time and try again.
|
||||
If this issue persists, please report it on the project's GitHub page.
|
||||
Request: ${response.request}
|
||||
Response: $response
|
||||
''';
|
||||
|
||||
@override
|
||||
String toString() => 'TransientFailureException: $message';
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
/// Thrown when YoutubeExplode fails to extract required information.
|
||||
/// This usually happens when YouTube makes changes that break YoutubeExplode.
|
||||
class UnrecognizedStructureException implements FormatException {
|
||||
///A message describing the format error.
|
||||
@override
|
||||
final String message;
|
||||
|
||||
/// The actual source input which caused the error.
|
||||
@override
|
||||
final String source;
|
||||
|
||||
/// Initializes an instance of [UnrecognizedStructureException]
|
||||
const UnrecognizedStructureException([this.message, this.source]);
|
||||
|
||||
/// Unimplemented
|
||||
@override
|
||||
int get offset => throw UnsupportedError('Offset not supported');
|
||||
}
|
|
@ -1,16 +1,24 @@
|
|||
import 'exceptions.dart';
|
||||
import '../models/models.dart';
|
||||
|
||||
/// Thrown when a video is not playable because it requires purchase.
|
||||
import 'video_unplayable_exception.dart';
|
||||
|
||||
/// Exception thrown when the requested video requires purchase.
|
||||
class VideoRequiresPurchaseException implements VideoUnplayableException {
|
||||
/// ID of the video.
|
||||
final String videoId;
|
||||
/// Description message
|
||||
final String message;
|
||||
|
||||
/// ID of the preview video.
|
||||
final String previewVideoId;
|
||||
/// VideoId instance
|
||||
final VideoId previewVideoId;
|
||||
|
||||
/// Initializes an instance of [VideoRequiresPurchaseException]
|
||||
const VideoRequiresPurchaseException(this.videoId, this.previewVideoId);
|
||||
VideoRequiresPurchaseException(this.message, this.previewVideoId);
|
||||
|
||||
@override
|
||||
String get reason => 'Requires purchase';
|
||||
/// Initializes an instance of [VideoUnplayableException] with a [VideoId]
|
||||
VideoRequiresPurchaseException.unavailable(this.previewVideoId)
|
||||
: message = 'Video \'$previewVideoId\' is unavailable.\n'
|
||||
'In most cases, this error indicates that the video doesn\'t exist, ' // ignore: lines_longer_than_80_chars
|
||||
'is private, or has been taken down.\n'
|
||||
'If you can however open this video in your browser in incognito mode, ' // ignore: lines_longer_than_80_chars
|
||||
'it most likely means that YouTube changed something, which broke this library.\n' // ignore: lines_longer_than_80_chars
|
||||
'Please report this issue on GitHub in that case.';
|
||||
}
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
|
||||
|
||||
/// Thrown when a video stream is not available
|
||||
/// and returns a status code not equal to 200 OK.
|
||||
class VideoStreamUnavailableException implements Exception {
|
||||
|
||||
/// The returned status code.
|
||||
final int statusCode;
|
||||
|
||||
/// Url
|
||||
final Uri url;
|
||||
|
||||
/// Initializes an instance of [VideoStreamUnavailableException]
|
||||
VideoStreamUnavailableException(this.statusCode, this.url);
|
||||
|
||||
@override
|
||||
String toString() => 'VideoStreamUnavailableException: '
|
||||
'The video stream in not availble (status code: $statusCode).\n'
|
||||
'Url: $url';
|
||||
}
|
|
@ -1,14 +1,22 @@
|
|||
import 'exceptions.dart';
|
||||
import '../models/models.dart';
|
||||
|
||||
/// Thrown when a video is not available and cannot be processed.
|
||||
/// This can happen because the video does not exist, is deleted,
|
||||
/// is private, or due to other reasons.
|
||||
class VideoUnavailableException implements Exception {
|
||||
/// ID of the video.
|
||||
final String videoId;
|
||||
class VideoUnavailableException implements VideoUnplayableException {
|
||||
/// Description message
|
||||
final String message;
|
||||
|
||||
/// Initializes an instance of [VideoUnavailableException]
|
||||
const VideoUnavailableException(this.videoId);
|
||||
VideoUnavailableException(this.message);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'VideoUnavailableException: Video $videoId is unavailable.';
|
||||
/// Initializes an instance of [VideoUnplayableException] with a [VideoId]
|
||||
VideoUnavailableException.unavailable(VideoId videoId)
|
||||
: message = 'Video \'$videoId\' is unavailable\n'
|
||||
'In most cases, this error indicates that the video doesn\'t exist, ' // ignore: lines_longer_than_80_chars
|
||||
'is private, or has been taken down.\n'
|
||||
'If you can however open this video in your browser in incognito mode, ' // ignore: lines_longer_than_80_chars
|
||||
'it most likely means that YouTube changed something, which broke this library.\n' // ignore: lines_longer_than_80_chars
|
||||
'Please report this issue on GitHub in that case.';
|
||||
}
|
||||
|
|
|
@ -1,17 +1,33 @@
|
|||
/// Thrown when a video is not playable and its streams cannot be resolved.
|
||||
/// This can happen because the video requires purchase,
|
||||
/// is blocked in your country, is controversial, or due to other reasons.
|
||||
class VideoUnplayableException {
|
||||
/// ID of the video.
|
||||
final String videoId;
|
||||
import '../models/models.dart';
|
||||
import 'youtube_explode_exception.dart';
|
||||
|
||||
/// Reason why the video can't be played.
|
||||
final String reason;
|
||||
/// Exception thrown when the requested video is unplayable.
|
||||
class VideoUnplayableException implements YoutubeExplodeException {
|
||||
/// Description message
|
||||
final String message;
|
||||
|
||||
/// Initializes an instance of [VideoUnplayableException]
|
||||
const VideoUnplayableException(this.videoId, [this.reason]);
|
||||
VideoUnplayableException(this.message);
|
||||
|
||||
String toString() =>
|
||||
'VideoUnplayableException: Video $videoId couldn\'t be played.'
|
||||
'${reason == null ? '' : 'Reason: $reason'}';
|
||||
/// Initializes an instance of [VideoUnplayableException] with a [VideoId]
|
||||
VideoUnplayableException.unplayable(VideoId videoId, {String reason = ''})
|
||||
: message = 'Video \'$videoId\' is unplayable.\n'
|
||||
'Streams are not available for this video.\n'
|
||||
'In most cases, this error indicates that there are \n'
|
||||
'some restrictions in place that prevent watching this video.\n'
|
||||
'Reason: $reason';
|
||||
|
||||
/// Initializes an instance of [VideoUnplayableException] with a [VideoId]
|
||||
VideoUnplayableException.liveStream(VideoId videoId)
|
||||
: message = 'Video \'$videoId\' is an ongoing live stream.\n'
|
||||
'Streams are not available for this video.\n'
|
||||
'Please wait until the live stream finishes and try again.';
|
||||
|
||||
/// Initializes an instance of [VideoUnplayableException] with a [VideoId]
|
||||
VideoUnplayableException.notLiveStream(VideoId videoId)
|
||||
: message = 'Video \'$videoId\' is not an ongoing live stream.\n'
|
||||
'Live stream manifest is not available for this video';
|
||||
|
||||
@override
|
||||
String toString() => 'VideoUnplayableException: $message';
|
||||
}
|
||||
|
|
|
@ -67,7 +67,7 @@ extension CaptionExtension on YoutubeExplode {
|
|||
var trackXml = await _getClosedCaptionTrackXml(info.url);
|
||||
|
||||
var captions = <ClosedCaption>[];
|
||||
for (var captionXml in trackXml.findAllElements('p')) {
|
||||
for (var captionXml in trackXml.findElements('p')) {
|
||||
var text = captionXml.text;
|
||||
if (text.isNullOrWhiteSpace) {
|
||||
continue;
|
||||
|
|
|
@ -21,3 +21,4 @@ export 'playlist_type.dart';
|
|||
export 'statistics.dart';
|
||||
export 'thumbnail_set.dart';
|
||||
export 'video.dart';
|
||||
export 'video_id.dart';
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../extensions/extensions.dart';
|
||||
|
||||
class VideoId extends Equatable {
|
||||
static final _regMatchExp = RegExp(r'youtube\..+?/watch.*?v=(.*?)(?:&|/|$)');
|
||||
static final _shortMatchExp = RegExp(r'youtu\.be/(.*?)(?:\?|&|/|$)');
|
||||
static final _embedMatchExp = RegExp(r'youtube\..+?/embed/(.*?)(?:\?|&|/|$)');
|
||||
|
||||
/// ID as string.
|
||||
final String value;
|
||||
|
||||
/// Initializes an instance of [VideoId] with a url or video id.
|
||||
VideoId(String url)
|
||||
: value = parseVideoId(url) ??
|
||||
ArgumentError('Invalid YouTube video ID or URL: $url.');
|
||||
|
||||
String toString() => value;
|
||||
|
||||
|
||||
@override
|
||||
List<Object> get props => [value];
|
||||
|
||||
/// Returns true if the given [videoId] is valid.
|
||||
static bool validateVideoId(String videoId) {
|
||||
if (videoId.isNullOrWhiteSpace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (videoId.length != 11) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !RegExp(r'[^0-9a-zA-Z_\-]').hasMatch(videoId);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if (url.isNullOrWhiteSpace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (validateVideoId(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// https://www.youtube.com/watch?v=yIVRs6YSbOM
|
||||
var regMatch = _regMatchExp.firstMatch(url)?.group(1);
|
||||
if (!regMatch.isNullOrWhiteSpace && validateVideoId(regMatch)) {
|
||||
return regMatch;
|
||||
}
|
||||
|
||||
// https://youtu.be/yIVRs6YSbOM
|
||||
var shortMatch = _shortMatchExp.firstMatch(url)?.group(1);
|
||||
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)) {
|
||||
return embedMatch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'exceptions/exceptions.dart';
|
||||
|
||||
/// Run the [function] each time an exception is thrown until the retryCount
|
||||
/// is 0.
|
||||
Future<T> retry<T>(FutureOr<T> function()) async {
|
||||
var retryCount = 5;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
return await function();
|
||||
// ignore: avoid_catches_without_on_clauses
|
||||
} on Exception catch (e) {
|
||||
retryCount -= getExceptionCost(e);
|
||||
if (retryCount <= 0) {
|
||||
rethrow;
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get "retry" cost of each YoutubeExplode exception.
|
||||
int getExceptionCost(Exception e) {
|
||||
if (e is TransientFailureException) {
|
||||
return 1;
|
||||
}
|
||||
if (e is RequestLimitExceeded) {
|
||||
return 2;
|
||||
}
|
||||
if (e is FatalFailureException) {
|
||||
return 3;
|
||||
}
|
||||
return 100;
|
||||
}
|
|
@ -1,50 +1,55 @@
|
|||
import 'package:html/dom.dart';
|
||||
import 'package:html/parser.dart' as parser;
|
||||
import 'package:youtube_explode_dart/src/exceptions/exceptions.dart';
|
||||
import 'package:youtube_explode_dart/src/retry.dart';
|
||||
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
|
||||
|
||||
class ChannelPage {
|
||||
final Document _root;
|
||||
|
||||
|
||||
bool get isOk => _root.querySelector('meta[property="og:url"]') != null;
|
||||
|
||||
String get channelUrl => _root
|
||||
.querySelectorThrow('meta[property="og:url"]')
|
||||
.getAttributeThrow('content');
|
||||
String get channelUrl =>
|
||||
_root.querySelector('meta[property="og:url"]')?.attributes['content'];
|
||||
|
||||
String get channelId => channelId.substringAfter('channel/');
|
||||
|
||||
String get channelTitle => _root
|
||||
.querySelectorThrow('meta[property="og:title"]')
|
||||
.getAttributeThrow('content');
|
||||
String get channelTitle =>
|
||||
_root.querySelector('meta[property="og:title"]')?.attributes['content'];
|
||||
|
||||
String get channelLogoUrl => _root
|
||||
.querySelectorThrow('meta[property="og:image"]')
|
||||
.getAttributeThrow('content');
|
||||
String get channelLogoUrl =>
|
||||
_root.querySelector('meta[property="og:image"]')?.attributes['content'];
|
||||
|
||||
ChannelPage(this._root);
|
||||
|
||||
ChannelPage.parse(String raw) : _root = parser.parse(raw);
|
||||
|
||||
static Future<ChannelPage> hello() {}
|
||||
}
|
||||
static Future<ChannelPage> get(YoutubeHttpClient httpClient, String id) {
|
||||
var url = 'https://www.youtube.com/channel/$id?hl=en';
|
||||
|
||||
extension on Document {
|
||||
Element querySelectorThrow(String selectors) {
|
||||
var element = querySelector(selectors);
|
||||
if (element == null) {
|
||||
//TODO: throw
|
||||
}
|
||||
return element;
|
||||
return retry(() async {
|
||||
var raw = await httpClient.getString(url);
|
||||
var result = ChannelPage.parse(raw);
|
||||
|
||||
if (!result.isOk) {
|
||||
throw TransientFailureException('Channel page is broken');
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension on Element {
|
||||
String getAttributeThrow(String name) {
|
||||
var attribute = attributes[name];
|
||||
if (attribute == null) {
|
||||
//TODO: throw
|
||||
}
|
||||
return attribute;
|
||||
static Future<ChannelPage> getByUsername(YoutubeHttpClient httpClient, String username) {
|
||||
var url = 'https://www.youtube.com/user/$username?hl=en';
|
||||
|
||||
return retry(() async {
|
||||
var raw = await httpClient.getString(url);
|
||||
var result = ChannelPage.parse(raw);
|
||||
|
||||
if (!result.isOk) {
|
||||
throw TransientFailureException('Channel page is broken');
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import 'package:xml/xml.dart' as xml;
|
||||
import 'package:youtube_explode_dart/src/retry.dart';
|
||||
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
|
||||
|
||||
class ClosedCaptionTrackResponse {
|
||||
final xml.XmlDocument _root;
|
||||
|
||||
ClosedCaptionTrackResponse(this._root);
|
||||
|
||||
Iterable<ClosedCaption> get closedCaptions =>
|
||||
_root.findElements('p').map((e) => ClosedCaption._(e));
|
||||
|
||||
ClosedCaptionTrackResponse.parse(String raw) : _root = xml.parse(raw);
|
||||
|
||||
static Future<ClosedCaptionTrackResponse> get(
|
||||
YoutubeHttpClient httpClient, String url) {
|
||||
var formatUrl = _setQueryParameters(url, {'format': '3'});
|
||||
return retry(() async {
|
||||
var raw = await httpClient.getString(formatUrl);
|
||||
return ClosedCaptionTrackResponse.parse(raw);
|
||||
});
|
||||
}
|
||||
|
||||
static Uri _setQueryParameters(String url, Map<String, String> parameters) {
|
||||
var uri = Uri.parse(url);
|
||||
|
||||
var query = Map<String, String>.from(uri.queryParameters);
|
||||
query.addAll(parameters);
|
||||
|
||||
return uri.replace(queryParameters: query);
|
||||
}
|
||||
}
|
||||
|
||||
class ClosedCaption {
|
||||
final xml.XmlElement _root;
|
||||
|
||||
ClosedCaption._(this._root);
|
||||
|
||||
String get text => _root.toXmlString();
|
||||
|
||||
Duration get offset =>
|
||||
Duration(milliseconds: int.parse(_root.getAttribute('t') ?? 0));
|
||||
|
||||
Duration get duration =>
|
||||
Duration(milliseconds: int.parse(_root.getAttribute('d') ?? 0));
|
||||
|
||||
Duration get end => offset + duration;
|
||||
|
||||
void getParts() {
|
||||
_root.findElements('s').map((e) => ClosedCaptionPart._(e));
|
||||
}
|
||||
}
|
||||
|
||||
class ClosedCaptionPart {
|
||||
final xml.XmlElement _root;
|
||||
|
||||
ClosedCaptionPart._(this._root);
|
||||
|
||||
String get text => _root.toXmlString();
|
||||
|
||||
Duration get offset =>
|
||||
Duration(milliseconds: int.parse(_root.getAttribute('t') ?? 0));
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:xml/xml.dart' as xml;
|
||||
import 'package:youtube_explode_dart/src/retry.dart';
|
||||
import 'package:youtube_explode_dart/src/reverse_engineering/responses/stream_info_provider.dart';
|
||||
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
|
||||
|
||||
class DashManifest {
|
||||
static final _urlSignatureExp = RegExp(r'/s/(.*?)(?:/|$)');
|
||||
|
||||
final xml.XmlDocument _root;
|
||||
|
||||
DashManifest(this._root);
|
||||
|
||||
Iterable<_StreamInfo> get streams => _root
|
||||
.findElements('Representation')
|
||||
.where((e) => e
|
||||
.findElements('Initialization')
|
||||
.first
|
||||
.getAttribute('sourceURL')
|
||||
.contains('sq/'))
|
||||
.map((e) => _StreamInfo(e));
|
||||
|
||||
DashManifest.parse(String raw) : _root = xml.parse(raw);
|
||||
|
||||
Future<DashManifest> get(YoutubeHttpClient httpClient, String url) {
|
||||
retry(() async {
|
||||
var raw = await httpClient.getString(url);
|
||||
return DashManifest.parse(raw);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
_StreamInfo(this._root);
|
||||
|
||||
int get tag => int.parse(_root.getAttribute('id'));
|
||||
|
||||
String get url => _root.getAttribute('BaseURL');
|
||||
|
||||
int get contentLength => int.parse(_root.getAttribute('contentLength') ??
|
||||
_contentLenExp.firstMatch(url).group(1));
|
||||
|
||||
int get bitrate => int.parse(_root.getAttribute('bandwidth'));
|
||||
|
||||
String get container =>
|
||||
Uri.decodeFull(_containerExp.firstMatch(url).group(1));
|
||||
|
||||
bool get isAudioOnly =>
|
||||
_root.findElements('AudioChannelConfiguration').isNotEmpty;
|
||||
|
||||
String get audioCodec => isAudioOnly ? null : _root.getAttribute('codecs');
|
||||
|
||||
String get videoCodec => isAudioOnly ? _root.getAttribute('codecs') : null;
|
||||
|
||||
int get videoWidth => int.parse(_root.getAttribute('width'));
|
||||
|
||||
int get videoHeight => int.parse(_root.getAttribute('height'));
|
||||
|
||||
int get framerate => int.parse(_root.getAttribute('framerate'));
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:html/parser.dart' as parser;
|
||||
import 'package:youtube_explode_dart/src/retry.dart';
|
||||
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
|
||||
import '../../extensions/extensions.dart';
|
||||
|
||||
class EmbedPage {
|
||||
static final _playerConfigExp =
|
||||
RegExp(r"yt\.setConfig\({'PLAYER_CONFIG':(.*)}\);");
|
||||
|
||||
final Document _root;
|
||||
|
||||
EmbedPage(this._root);
|
||||
|
||||
_PlayerConfig get playerconfig => _PlayerConfig(json.decode(_playerConfigJson));
|
||||
|
||||
String get _playerConfigJson => _root
|
||||
.getElementsByTagName('script')
|
||||
.map((e) => e.text)
|
||||
.map((e) => _playerConfigExp.firstMatch(e).group(1))
|
||||
.firstWhere((e) => !e.isNullOrWhiteSpace);
|
||||
|
||||
EmbedPage.parse(String raw) : _root = parser.parse(raw);
|
||||
|
||||
Future<EmbedPage> get(YoutubeHttpClient httpClient, String videoId) {
|
||||
var url = 'https://youtube.com/embed/$videoId?hl=en';
|
||||
return retry(() async {
|
||||
var raw = await httpClient.getString(url);
|
||||
return EmbedPage.parse(raw);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerConfig {
|
||||
// Json parsed map.
|
||||
final Map<String, dynamic> _root;
|
||||
|
||||
_PlayerConfig(this._root);
|
||||
|
||||
String get sourceUrl => 'https://youtube.com ${_root['assets']['js']}';
|
||||
}
|
|
@ -0,0 +1,174 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:youtube_explode_dart/src/reverse_engineering/responses/stream_info_provider.dart';
|
||||
|
||||
class PlayerResponse {
|
||||
// Json parsed map
|
||||
final Map<String, dynamic> _root;
|
||||
|
||||
PlayerResponse(this._root);
|
||||
|
||||
String get playabilityStatus => _root['playabilityStatus']['status'];
|
||||
|
||||
// Can be null
|
||||
String get getVideoPlayabilityError =>
|
||||
_root.get('playabilityStatus')?.get('reason');
|
||||
|
||||
bool get isVideoAvailable => playabilityStatus != 'error';
|
||||
|
||||
bool get isVideoPlayable => playabilityStatus == 'ok';
|
||||
|
||||
String get videoTitle => _root['videoDetails']['title'];
|
||||
|
||||
String get videoAuthor => _root['videoDetails']['author'];
|
||||
|
||||
//TODO: Check how this is formatted.
|
||||
|
||||
String /* DateTime */ get videoUploadDate =>
|
||||
_root['microformat']['playerMicroformatRenderer']['uploadDate'];
|
||||
|
||||
String get videoChannelId => _root['videoDetails']['channelId'];
|
||||
|
||||
Duration get videoDuration =>
|
||||
Duration(seconds: _root['videoDetails']['lengthSeconds']);
|
||||
|
||||
Iterable<String> get videoKeywords =>
|
||||
_root['videoDetails']['keywords'] ?? const [];
|
||||
|
||||
String get videoDescription => _root['videoDetails']['shortDescription'];
|
||||
|
||||
int get videoViewCount => int.parse(_root['videoDetails']['viewCount']);
|
||||
|
||||
// Can be null
|
||||
String get previewVideoId =>
|
||||
_root
|
||||
.get('playabilityStatus')
|
||||
?.get('errorScreen')
|
||||
?.get('playerLegacyDesktopYpcTrailerRenderer')
|
||||
?.get('trailerVideoId') ??
|
||||
Uri.splitQueryString(_root
|
||||
.get('playabilityStatus')
|
||||
?.get('errorScreen')
|
||||
?.get('')
|
||||
?.get('ypcTrailerRenderer')
|
||||
?.get('playerVars') ??
|
||||
'')['video_id'];
|
||||
|
||||
bool get isLive => _root['videoDetails'].get('isLive') ?? false;
|
||||
|
||||
// Can be null
|
||||
String get dashManifestUrl =>
|
||||
_root.get('streamingData')?.get('dashManifestUrl');
|
||||
|
||||
Iterable<StreamInfoProvider> get muxedStreams =>
|
||||
_root?.get('streamingData')?.get('formats')?.map((e) => _StreamInfo(e)) ??
|
||||
const [];
|
||||
|
||||
Iterable<StreamInfoProvider> get adaptiveStreams =>
|
||||
_root
|
||||
?.get('streamingData')
|
||||
?.get('adaptiveFormats')
|
||||
?.map((e) => _StreamInfo(e)) ??
|
||||
const [];
|
||||
|
||||
Iterable<StreamInfoProvider> get streams =>
|
||||
[...muxedStreams, ...adaptiveStreams];
|
||||
|
||||
Iterable<ClosedCaptionTrack> get closedCaptionTrack =>
|
||||
_root
|
||||
.get('captions')
|
||||
?.get('playerCaptionsTracklistRenderer')
|
||||
?.get('captionTracks')
|
||||
?.map((e) => ClosedCaptionTrack(e)) ??
|
||||
const [];
|
||||
|
||||
PlayerResponse.parse(String raw) : _root = json.decode(raw);
|
||||
}
|
||||
|
||||
class ClosedCaptionTrack {
|
||||
// Json parsed map
|
||||
final Map<String, dynamic> _root;
|
||||
|
||||
ClosedCaptionTrack(this._root);
|
||||
|
||||
String get url => _root['baseUrl'];
|
||||
|
||||
String get language => _root['name']['simpleText'];
|
||||
|
||||
bool get autoGenerated => _root['vssId'].startsWith("a.");
|
||||
}
|
||||
|
||||
class _StreamInfo extends StreamInfoProvider {
|
||||
// Json parsed map
|
||||
final Map<String, dynamic> _root;
|
||||
|
||||
_StreamInfo(this._root);
|
||||
|
||||
@override
|
||||
int get bitrate => _root['bitrate'];
|
||||
|
||||
@override
|
||||
String get container => mimeType.subtype;
|
||||
|
||||
@override
|
||||
int get contentLength =>
|
||||
_root['contentLength'] ??
|
||||
StreamInfoProvider.contentLenExp.firstMatch(url).group(1);
|
||||
|
||||
@override
|
||||
int get framerate => int.tryParse(_root['fps'] ?? '');
|
||||
|
||||
@override
|
||||
String get signature => Uri.splitQueryString(_root.get('cipher') ?? '')['s'];
|
||||
|
||||
@override
|
||||
String get signatureParameter =>
|
||||
Uri.splitQueryString(_root['cipher'] ?? '')['sp'];
|
||||
|
||||
@override
|
||||
int get tag => int.parse(_root['itag']);
|
||||
|
||||
@override
|
||||
String get url =>
|
||||
_root?.get('url') ??
|
||||
Uri.splitQueryString(_root?.get('cipher') ?? '')['s'];
|
||||
|
||||
@override
|
||||
// TODO: implement videoCodec, gotta debug how the mimeType is formatted
|
||||
String get videoCodec => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
// TODO: implement videoHeight, gotta debug how the mimeType is formatted
|
||||
int get videoHeight => _root['height'];
|
||||
|
||||
@override
|
||||
// TODO: implement videoQualityLabel
|
||||
String get videoQualityLabel => _root['qualityLabel'];
|
||||
|
||||
@override
|
||||
// TODO: implement videoWidth
|
||||
int get videoWidth => _root['width'];
|
||||
|
||||
// TODO: implement audioOnly, gotta debug how the mimeType is formatted
|
||||
bool get audioOnly => throw UnimplementedError();
|
||||
|
||||
MediaType get mimeType => MediaType.parse(_root['mimeType']);
|
||||
|
||||
String get codecs => mimeType?.parameters['codecs']?.toLowerCase();
|
||||
|
||||
@override
|
||||
// TODO: Finish implementing this, gotta debug how the mimeType is formatted
|
||||
String get audioCodec => audioOnly ? codecs : throw UnimplementedError();
|
||||
}
|
||||
|
||||
///
|
||||
extension GetOrNull<K, V> on Map<K, V> {
|
||||
V get(K key) {
|
||||
var v = this[key];
|
||||
if (v == null) {
|
||||
return null;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
import 'package:youtube_explode_dart/src/exceptions/exceptions.dart';
|
||||
import 'package:youtube_explode_dart/src/retry.dart';
|
||||
import 'package:youtube_explode_dart/src/reverse_engineering/cipher/cipher_operations.dart';
|
||||
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
|
||||
|
||||
class PlayerSource {
|
||||
final RegExp _statIndexExp = RegExp(r'\(\w+,(\d+)\)');
|
||||
|
||||
final RegExp _funcBodyExp = RegExp(
|
||||
r'(\w+)=function\(\w+\){(\w+)=\2\.split\(\x22{2}\);.*?return\s+\2\.join\(\x22{2}\)}');
|
||||
|
||||
final RegExp _funcNameExp = RegExp(r'(\w+).\w+\(\w+,\d+\);');
|
||||
|
||||
final RegExp _calledFuncNameExp =
|
||||
RegExp(r'\w+(?:.|\[)(\""?\w+(?:\"")?)\]?\("');
|
||||
|
||||
final String _root;
|
||||
|
||||
PlayerSource(this._root);
|
||||
|
||||
String get sts {
|
||||
var val = RegExp(r'(?<=invalid namespace.*?;var \w\s*=)\d+')
|
||||
.stringMatch(_root)
|
||||
.nullIfWhitespace;
|
||||
if (val == null) {
|
||||
throw FatalFailureException('Could not find sts in player source.');
|
||||
}
|
||||
}
|
||||
|
||||
Iterable<CipherOperation> getCiperOperations() sync* {
|
||||
var funcBody = _getDeciphererFuncBody();
|
||||
|
||||
if (funcBody == null) {
|
||||
throw FatalFailureException(
|
||||
'Could not find signature decipherer function body.');
|
||||
}
|
||||
|
||||
var definitionBody = _getDeciphererDefinitionBody(funcBody);
|
||||
if (definitionBody == null) {
|
||||
throw FatalFailureException(
|
||||
'Could not find signature decipherer definition body.');
|
||||
}
|
||||
|
||||
for (var statement in funcBody.split(';')) {
|
||||
var calledFuncName = _calledFuncNameExp.firstMatch(statement).group(1);
|
||||
if (calledFuncName.isNullOrWhiteSpace) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var escapedFuncName = RegExp.escape(calledFuncName);
|
||||
// Slice
|
||||
var exp = RegExp('$escapedFuncName'
|
||||
r':\bfunction\b\(\w+\,\w\).\bvar\b.\bc=a\b');
|
||||
|
||||
if (exp.hasMatch(calledFuncName)) {
|
||||
var index = int.parse(_statIndexExp.firstMatch(statement).group(1));
|
||||
yield SliceCipherOperation(index);
|
||||
}
|
||||
|
||||
// Swap
|
||||
exp = RegExp(
|
||||
'$escapedFuncName' r':\bfunction\b\(\w+\,\w\).\bvar\b.\bc=a\b');
|
||||
if (exp.hasMatch(calledFuncName)) {
|
||||
var index = int.parse(_statIndexExp.firstMatch(statement).group(1));
|
||||
yield SwapCipherOperation(index);
|
||||
}
|
||||
|
||||
// Reverse
|
||||
exp = RegExp('$escapedFuncName' r':\bfunction\b\(\w+\)');
|
||||
if (exp.hasMatch(calledFuncName)) {
|
||||
yield const ReverseCipherOperation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _getDeciphererFuncBody() {
|
||||
var funcName = _funcBodyExp.firstMatch(_root).group(1);
|
||||
|
||||
var exp = RegExp(
|
||||
r'(?!h\.)' '${RegExp.escape(funcName)}' r'=function\(\w+\)\{{(.*?)\}}');
|
||||
return exp.firstMatch(_root).group(1).nullIfWhitespace;
|
||||
}
|
||||
|
||||
String _getDeciphererDefinitionBody(String deciphererFuncBody) {
|
||||
var funcName = _funcNameExp.firstMatch(deciphererFuncBody).group(1);
|
||||
|
||||
var exp = RegExp(r'var\s+'
|
||||
'${RegExp.escape(funcName)}'
|
||||
r'=\{{(\w+:function\(\w+(,\w+)?\)\{{(.*?)\}}),?\}};');
|
||||
return exp.firstMatch(_root).group(0).nullIfWhitespace;
|
||||
}
|
||||
|
||||
// Same as default constructor
|
||||
PlayerSource.parse(this._root);
|
||||
|
||||
Future<PlayerSource> get(YoutubeHttpClient httpClient, String url) {
|
||||
return retry(() async {
|
||||
var raw = await httpClient.getString(url);
|
||||
return PlayerSource.parse(raw);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:youtube_explode_dart/src/exceptions/exceptions.dart';
|
||||
import 'package:youtube_explode_dart/src/retry.dart';
|
||||
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
|
||||
|
||||
class PlaylistResponse {
|
||||
// Json parsed map
|
||||
final Map<String, dynamic> _root;
|
||||
|
||||
PlaylistResponse(this._root);
|
||||
|
||||
String get title => _root['title'];
|
||||
|
||||
String get author => _root['author'];
|
||||
|
||||
String get description => _root['description'];
|
||||
|
||||
int get viewCount => int.tryParse(_root['views'] ?? '');
|
||||
|
||||
int get likeCount => int.tryParse(_root['likes']);
|
||||
|
||||
int get dislikeCount => int.tryParse(_root['dislikes']);
|
||||
|
||||
Iterable<Video> get videos =>
|
||||
_root['video']?.map((e) => Video(e)) ?? const [];
|
||||
|
||||
PlaylistResponse.parse(String raw) : _root = json.tryDecode(raw) {
|
||||
if (_root == null) {
|
||||
throw TransientFailureException('Playerlist response is broken.');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<PlaylistResponse> get(YoutubeHttpClient httpClient, String id,
|
||||
{int index = 0}) {
|
||||
var url =
|
||||
'https://youtube.com/list_ajax?style=json&action_get_list=1&list=$id&index=$index&hl=en';
|
||||
return retry(() async {
|
||||
var raw = await httpClient.getString(url);
|
||||
return PlaylistResponse.parse(raw);
|
||||
});
|
||||
}
|
||||
|
||||
static Future<PlaylistResponse> searchResults(
|
||||
YoutubeHttpClient httpClient, String query,
|
||||
{int page = 0}) {
|
||||
var url = 'https://youtube.com/search_ajax?style=json&search_query='
|
||||
'${Uri.encodeQueryComponent(query)}&page=$page&hl=en';
|
||||
return retry(() async {
|
||||
var raw = await httpClient.getString(url, validate: false);
|
||||
return PlaylistResponse.parse(raw);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class Video {
|
||||
// Json parsed map
|
||||
final Map<String, dynamic> _root;
|
||||
|
||||
Video(this._root);
|
||||
|
||||
String get id => _root['encrypted_id'];
|
||||
|
||||
String get author => _root['author'];
|
||||
|
||||
//TODO: Check if date is correctS
|
||||
DateTime get uploadDate =>
|
||||
DateTime.fromMillisecondsSinceEpoch(_root['time_created'] * 1000);
|
||||
|
||||
String get title => _root['title'];
|
||||
|
||||
String get description => _root['description'];
|
||||
|
||||
Duration get duration => Duration(seconds: _root['length_seconds']);
|
||||
|
||||
int get viewCount => int.parse(_root['views'].stripNonDigits());
|
||||
|
||||
int get likes => int.parse(_root['likes']);
|
||||
|
||||
int get dislikes => int.parse(_root['dislikes']);
|
||||
|
||||
Iterable<String> get keywords => RegExp(r'"[^\"]+"|\S+')
|
||||
.allMatches(_root['keywords'])
|
||||
.map((e) => e.group(0))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
extension on String {
|
||||
static final _exp = RegExp(r'\D');
|
||||
|
||||
/// Strips out all non digit characters.
|
||||
String stripNonDigits() => replaceAll(_exp, '');
|
||||
}
|
||||
|
||||
extension on JsonCodec {
|
||||
dynamic tryDecode(String source) {
|
||||
try {
|
||||
return json.decode(source);
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import 'package:http_parser/http_parser.dart';
|
||||
|
||||
abstract class StreamInfoProvider {
|
||||
static final contentLenExp = RegExp(r'clen=(\d+)');
|
||||
|
||||
int get tag;
|
||||
|
||||
String get url;
|
||||
|
||||
// Can be null
|
||||
String get signature => null;
|
||||
|
||||
// Can be null
|
||||
String get signatureParameter => null;
|
||||
|
||||
// Can be null
|
||||
int get contentLength => null;
|
||||
|
||||
int get bitrate;
|
||||
|
||||
String get container;
|
||||
|
||||
// Can be null
|
||||
String get audioCodec => null;
|
||||
|
||||
// Can be null
|
||||
String get videoCodec => null;
|
||||
|
||||
// Can be null
|
||||
String get videoQualityLabel => null;
|
||||
|
||||
// Can be null
|
||||
int get videoWidth => null;
|
||||
|
||||
// Can be null
|
||||
int get videoHeight => null;
|
||||
|
||||
// Can be null
|
||||
int get framerate => null;
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:youtube_explode_dart/src/reverse_engineering/responses/player_response.dart';
|
||||
import 'package:youtube_explode_dart/src/reverse_engineering/responses/stream_info_provider.dart';
|
||||
|
||||
class VideoInfoResponse {
|
||||
final Map<String, String> _root;
|
||||
|
||||
VideoInfoResponse(this._root);
|
||||
|
||||
String get status => _root['status'];
|
||||
|
||||
bool get isVideoAvailable => status.toLowerCase() == 'fail';
|
||||
|
||||
PlayerResponse get playerResponse =>
|
||||
PlayerResponse.parse(_root['player_response']);
|
||||
|
||||
Iterable<_StreamInfo> get muxedStreams =>
|
||||
_root['url_encoded_fmt_stream_map']
|
||||
?.split(',')
|
||||
?.map(Uri.splitQueryString)
|
||||
?.map((e) => _StreamInfo(e)) ??
|
||||
const [];
|
||||
|
||||
Iterable<_StreamInfo> get adaptiveStreams =>
|
||||
_root['adaptive_fmts']
|
||||
?.split(',')
|
||||
?.map(Uri.splitQueryString)
|
||||
?.map((e) => _StreamInfo(e)) ??
|
||||
const [];
|
||||
|
||||
Iterable<_StreamInfo> get streams => [...muxedStreams, ...adaptiveStreams];
|
||||
}
|
||||
|
||||
class _StreamInfo extends StreamInfoProvider {
|
||||
final Map<String, String> _root;
|
||||
|
||||
_StreamInfo(this._root);
|
||||
|
||||
@override
|
||||
int get tag => int.parse(_root['itag']);
|
||||
|
||||
@override
|
||||
String get url => _root['url'];
|
||||
|
||||
@override
|
||||
String get signature => _root['s'];
|
||||
|
||||
@override
|
||||
String get signatureParameter => _root['sp'];
|
||||
|
||||
@override
|
||||
int get contentLength => int.tryParse(_root['clen'] ??
|
||||
StreamInfoProvider.contentLenExp.firstMatch(url).group(1));
|
||||
|
||||
@override
|
||||
int get bitrate => int.parse(_root['bitrate']);
|
||||
|
||||
MediaType get mimeType => MediaType.parse(_root["type"]);
|
||||
|
||||
@override
|
||||
String get container => mimeType.subtype;
|
||||
|
||||
List<String> get codecs =>
|
||||
mimeType.parameters['codecs'].split(',').map((e) => e.trim());
|
||||
|
||||
@override
|
||||
String get audioCodec => codecs.last;
|
||||
|
||||
@override
|
||||
String get videoCodec => isAudioOnly ? null : codecs.first;
|
||||
|
||||
bool get isAudioOnly => mimeType.type == 'audio';
|
||||
|
||||
@override
|
||||
// TODO: implement videoQualityLabel
|
||||
String get videoQualityLabel => _root['quality_label'];
|
||||
|
||||
List<int> get _size =>
|
||||
_root['size'].split(',').map((e) => int.tryParse(e ?? ''));
|
||||
|
||||
@override
|
||||
int get videoWidth => _size.first;
|
||||
|
||||
@override
|
||||
int get videoHeight => _size.last;
|
||||
|
||||
@override
|
||||
int get framerate => int.tryParse(_root['fps'] ?? '');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
String substringUntil(String separator) => substring(0, indexOf(separator));
|
||||
|
||||
String substringAfter(String separator) =>
|
||||
substring(indexOf(separator) + length);
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import 'package:html/dom.dart';
|
||||
import 'package:youtube_explode_dart/src/reverse_engineering/responses/embed_page.dart';
|
||||
|
||||
class VideoPage {
|
||||
final RegExp _videoLikeExp = RegExp(r'label""\s*:\s*""([\d,\.]+) likes');
|
||||
final RegExp _videoDislikeExp =
|
||||
RegExp(r'label""\s*:\s*""([\d,\.]+) dislikes');
|
||||
|
||||
final Document _root;
|
||||
|
||||
VideoPage(this._root);
|
||||
|
||||
bool get isOk => _root.body.querySelector('#player') != null;
|
||||
|
||||
bool get isVideoAvailable =>
|
||||
_root.querySelector('meta[property="og:url"]') != null;
|
||||
|
||||
int get videoLikeCount => int.tryParse(_videoLikeExp
|
||||
.firstMatch(_root.text)
|
||||
.group(1)
|
||||
.nullIfWhitespace
|
||||
?.stripNonDigits() ??
|
||||
'');
|
||||
|
||||
int get videoDislikeCount => int.tryParse(_videoDislikeExp
|
||||
.firstMatch(_root.text)
|
||||
.group(1)
|
||||
.nullIfWhitespace
|
||||
?.stripNonDigits() ??
|
||||
'');
|
||||
|
||||
_PlayerConfig get playerConfig => _PlayerConfig.parse(_root.getElementsByTagName('script').map((e) => e.text).map((e) => _extractJson(e)).firstWhere((e) => e != null));
|
||||
|
||||
String _extractJson(String str) {
|
||||
var startIndex = str.indexOf('ytplayer.config =');
|
||||
var endIndex = str.indexOf(';ytplayer.load =');
|
||||
if (startIndex == -1 || endIndex == -1)
|
||||
return null;
|
||||
|
||||
return str.substring(startIndex + 17, endIndex);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerConfig {
|
||||
|
||||
}
|
||||
extension on String {
|
||||
static final _exp = RegExp(r'\D');
|
||||
|
||||
/// Strips out all non digit characters.
|
||||
String stripNonDigits() => replaceAll(_exp, '');
|
||||
|
||||
String get nullIfWhitespace => trim().isEmpty ? null : this;
|
||||
|
||||
bool get isNullOrWhiteSpace {
|
||||
if (this == null) {
|
||||
return true;
|
||||
}
|
||||
if (trim().isEmpty) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
//void main() {
|
||||
// e().catchError(
|
||||
// (onError) {
|
||||
// print("called when there is an error catches error");
|
||||
// return Future.error('error');
|
||||
// },
|
||||
// ).then((value) {
|
||||
// print("called with value = null");
|
||||
// }).whenComplete(() {
|
||||
// print("called when future completes");
|
||||
// });
|
||||
//}
|
||||
|
||||
void main() async {
|
||||
try {
|
||||
await someFuture();
|
||||
} catch (e) {
|
||||
print("called when there is an error catches error: $e");
|
||||
try {
|
||||
print("called with value = null");
|
||||
} finally {
|
||||
print("called when future completes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future f() {
|
||||
return Future.value(5);
|
||||
}
|
||||
|
||||
Future someFuture() {
|
||||
return Future.error('Error occured');
|
||||
}
|
Loading…
Reference in New Issue