More on v5

This commit is contained in:
Hexah 2020-05-31 23:36:23 +02:00
parent 447f7f27d7
commit 911712cfa1
25 changed files with 1048 additions and 102 deletions

View File

@ -13,6 +13,7 @@ linter:
- prefer_const_literals_to_create_immutables
- prefer_constructors_over_static_methods
- prefer_contains
- annotate_overrides
analyzer:
exclude:

View File

@ -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';

View File

@ -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.

View File

@ -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';
}

View File

@ -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';
}

View File

@ -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');
}

View File

@ -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.';
}

View File

@ -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';
}

View File

@ -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.';
}

View File

@ -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';
}

View File

@ -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;

View File

@ -21,3 +21,4 @@ export 'playlist_type.dart';
export 'statistics.dart';
export 'thumbnail_set.dart';
export 'video.dart';
export 'video_id.dart';

View File

@ -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;
}
}

36
lib/src/retry.dart Normal file
View File

@ -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;
}

View File

@ -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;
});
}
}

View File

@ -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));
}

View File

@ -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'));
}

View File

@ -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']}';
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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;
}
}

33
tools/test.dart Normal file
View File

@ -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');
}