Update for v5
This commit is contained in:
parent
c7bbbf0d24
commit
407ac50f22
|
@ -14,6 +14,7 @@ linter:
|
|||
- prefer_constructors_over_static_methods
|
||||
- prefer_contains
|
||||
- annotate_overrides
|
||||
- await_futures
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
|
|
|
@ -3,8 +3,10 @@ import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
|||
Future<void> main() async {
|
||||
var yt = YoutubeExplode();
|
||||
var video = await yt.videos.get(VideoId('https://www.youtube.com/watch?v=bo_efYhYU2A'));
|
||||
var streamManifest = await yt.videos.streamsClient.getManifest(VideoId('https://www.youtube.com/watch?v=bo_efYhYU2A'));
|
||||
|
||||
print('Title: ${video.title}');
|
||||
print(streamManifest.streams());
|
||||
|
||||
// Close the YoutubeExplode's http client.
|
||||
yt.close();
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import '../extensions/helpers_extension.dart';
|
||||
import '../playlists/playlists.dart';
|
||||
import '../reverse_engineering/responses/responses.dart';
|
||||
import '../reverse_engineering/youtube_http_client.dart';
|
||||
import '../videos/video.dart';
|
||||
import '../videos/video_id.dart';
|
||||
import 'channel.dart';
|
||||
import 'channel_id.dart';
|
||||
|
@ -40,8 +42,8 @@ class ChannelClient {
|
|||
}
|
||||
|
||||
/// Enumerates videos uploaded by the specified channel.
|
||||
void getUploads(ChannelId id) async {
|
||||
var playlist = 'UU${id.value.substringAfter('UC')}';
|
||||
//TODO: Finish this after playlist
|
||||
Stream<Video> getUploads(ChannelId id) {
|
||||
var playlistId = 'UU${id.value.substringAfter('UC')}';
|
||||
return PlaylistClient(_httpClient).getVideos(PlaylistId(playlistId));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,9 +8,11 @@ class ChannelId extends Equatable {
|
|||
final String value;
|
||||
|
||||
/// Initializes an instance of [ChannelId]
|
||||
ChannelId(String value)
|
||||
: value = parseChannelId(value) ??
|
||||
ArgumentError.value(value, 'value', 'Invalid channel id.');
|
||||
ChannelId(String value) : value = parseChannelId(value) {
|
||||
if (this.value == null) {
|
||||
throw ArgumentError.value(value, 'value', 'Invalid channel id');
|
||||
}
|
||||
}
|
||||
|
||||
static bool validateChannelId(String id) {
|
||||
if (id.isNullOrWhiteSpace) {
|
||||
|
@ -48,6 +50,9 @@ class ChannelId extends Equatable {
|
|||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '$value';
|
||||
|
||||
@override
|
||||
List<Object> get props => [value];
|
||||
}
|
||||
|
|
|
@ -6,13 +6,15 @@ class Username {
|
|||
final String value;
|
||||
|
||||
/// Initializes an instance of [Username].
|
||||
Username(String urlOrUsername)
|
||||
: value = parseUsername(urlOrUsername) ??
|
||||
ArgumentError.value(
|
||||
urlOrUsername, 'urlOrUsername', 'Invalid username');
|
||||
Username(String urlOrUsername) : value = parseUsername(urlOrUsername) {
|
||||
if (value == null) {
|
||||
throw ArgumentError.value(
|
||||
urlOrUsername, 'urlOrUsername', 'Invalid username');
|
||||
}
|
||||
}
|
||||
|
||||
static bool validateUsername(String name) {
|
||||
if (!name.isNullOrWhiteSpace) {
|
||||
if (name.isNullOrWhiteSpace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -20,7 +22,7 @@ class Username {
|
|||
return false;
|
||||
}
|
||||
|
||||
return RegExp('[^0-9a-zA-Z]').hasMatch(name);
|
||||
return !RegExp('[^0-9a-zA-Z]').hasMatch(name);
|
||||
}
|
||||
|
||||
static String parseUsername(String nameOrUrl) {
|
||||
|
@ -35,7 +37,7 @@ class Username {
|
|||
var regMatch = RegExp(r'youtube\..+?/user/(.*?)(?:\?|&|/|$)')
|
||||
.firstMatch(nameOrUrl)
|
||||
?.group(1);
|
||||
if (regMatch.isNullOrWhiteSpace && validateUsername(regMatch)) {
|
||||
if (!regMatch.isNullOrWhiteSpace && validateUsername(regMatch)) {
|
||||
return regMatch;
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -4,8 +4,8 @@ import 'youtube_explode_exception.dart';
|
|||
|
||||
/// Exception thrown when a fatal failure occurs.
|
||||
class FatalFailureException implements YoutubeExplodeException {
|
||||
|
||||
/// Description message
|
||||
@override
|
||||
final String message;
|
||||
|
||||
/// Initializes an instance of [FatalFailureException]
|
||||
|
@ -18,7 +18,7 @@ Failed to perform an HTTP request to YouTube due to a fatal failure.
|
|||
In most cases, this error indicates that YouTube most likely changed something, which broke the library.
|
||||
If this issue persists, please report it on the project's GitHub page.
|
||||
Request: ${response.request}
|
||||
Response: $response
|
||||
Response: (${response.statusCode})
|
||||
''';
|
||||
|
||||
@override
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'youtube_explode_exception.dart';
|
|||
|
||||
/// Exception thrown when a fatal failure occurs.
|
||||
class TransientFailureException implements YoutubeExplodeException {
|
||||
@override
|
||||
final String message;
|
||||
|
||||
/// Initializes an instance of [TransientFailureException]
|
||||
|
|
|
@ -6,6 +6,7 @@ import 'exceptions.dart';
|
|||
/// is private, or due to other reasons.
|
||||
class VideoUnavailableException implements VideoUnplayableException {
|
||||
/// Description message
|
||||
@override
|
||||
final String message;
|
||||
|
||||
/// Initializes an instance of [VideoUnavailableException]
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'youtube_explode_exception.dart';
|
|||
/// Exception thrown when the requested video is unplayable.
|
||||
class VideoUnplayableException implements YoutubeExplodeException {
|
||||
/// Description message
|
||||
@override
|
||||
final String message;
|
||||
|
||||
/// Initializes an instance of [VideoUnplayableException]
|
||||
|
|
|
@ -23,7 +23,7 @@ extension StringUtility on String {
|
|||
|
||||
///
|
||||
String substringAfter(String separator) =>
|
||||
substring(indexOf(separator) + length);
|
||||
substring(indexOf(separator) + separator.length);
|
||||
|
||||
static final _exp = RegExp(r'\D');
|
||||
|
||||
|
@ -65,3 +65,25 @@ extension UriUtility on Uri {
|
|||
return replace(queryParameters: query);
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
extension GetOrNull<K, V> on Map<K, V> {
|
||||
V getValue(K key) {
|
||||
var v = this[key];
|
||||
if (v == null) {
|
||||
return null;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
extension GetOrNullMap on Map {
|
||||
Map<String, dynamic> get(String key) {
|
||||
var v = this[key];
|
||||
if (v == null) {
|
||||
return null;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ class PlaylistClient {
|
|||
var videoId = video.id;
|
||||
|
||||
// Already added
|
||||
if (encounteredVideoIds.add(videoId)) {
|
||||
if (!encounteredVideoIds.add(videoId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
@ -13,13 +13,15 @@ class PlaylistId {
|
|||
final String value;
|
||||
|
||||
/// Initializes an instance of [PlaylistId]
|
||||
PlaylistId(String idOrUrl)
|
||||
: value = parsePlaylistId(idOrUrl) ??
|
||||
ArgumentError.value(idOrUrl, 'idOrUrl', 'Invalid url.');
|
||||
PlaylistId(String idOrUrl) : value = parsePlaylistId(idOrUrl) {
|
||||
if (value == null) {
|
||||
throw ArgumentError.value(idOrUrl, 'idOrUrl', 'Invalid url');
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the given [playlistId] is valid.
|
||||
static bool validatePlaylistId(String playlistId) {
|
||||
playlistId = playlistId.toLowerCase();
|
||||
playlistId = playlistId.toUpperCase();
|
||||
|
||||
if (playlistId.isNullOrWhiteSpace) {
|
||||
return false;
|
||||
|
@ -60,6 +62,10 @@ class PlaylistId {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (validatePlaylistId(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
var regMatch = _regMatchExp.firstMatch(url)?.group(1);
|
||||
if (!regMatch.isNullOrWhiteSpace && validatePlaylistId(regMatch)) {
|
||||
return regMatch;
|
||||
|
@ -84,4 +90,7 @@ class PlaylistId {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
}
|
||||
|
|
|
@ -177,7 +177,7 @@ extension VideoQualityUtil on VideoQuality {
|
|||
}
|
||||
|
||||
var framerateRounded = (framerate / 10).ceil() * 10;
|
||||
return '${getLabel}$framerateRounded';
|
||||
return '${getLabel()}$framerateRounded';
|
||||
}
|
||||
|
||||
static String getLabelFromTagWithFramerate(int itag, double framerate) {
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:html/dom.dart';
|
|||
import 'package:html/parser.dart' as parser;
|
||||
|
||||
import '../../exceptions/exceptions.dart';
|
||||
import '../../extensions/helpers_extension.dart';
|
||||
import '../../retry.dart';
|
||||
import '../youtube_http_client.dart';
|
||||
|
||||
|
@ -13,7 +14,7 @@ class ChannelPage {
|
|||
String get channelUrl =>
|
||||
_root.querySelector('meta[property="og:url"]')?.attributes['content'];
|
||||
|
||||
String get channelId => channelId.substringAfter('channel/');
|
||||
String get channelId => channelUrl.substringAfter('channel/');
|
||||
|
||||
String get channelTitle =>
|
||||
_root.querySelector('meta[property="og:title"]')?.attributes['content'];
|
||||
|
@ -54,8 +55,3 @@ class ChannelPage {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension on String {
|
||||
String substringAfter(String separator) =>
|
||||
substring(indexOf(separator) + length);
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ class DashManifest {
|
|||
DashManifest.parse(String raw) : _root = xml.parse(raw);
|
||||
|
||||
static Future<DashManifest> get(YoutubeHttpClient httpClient, dynamic url) {
|
||||
retry(() async {
|
||||
return retry(() async {
|
||||
var raw = await httpClient.getString(url);
|
||||
return DashManifest.parse(raw);
|
||||
});
|
||||
|
@ -34,35 +34,45 @@ class DashManifest {
|
|||
}
|
||||
|
||||
class _StreamInfo extends StreamInfoProvider {
|
||||
static final _contentLenExp = RegExp(r'clen[/=](\d+)');
|
||||
static final _contentLenExp = RegExp(r'[/\?]clen[/=](\d+)');
|
||||
static final _containerExp = RegExp(r'mime[/=]\w*%2F([\w\d]*)');
|
||||
|
||||
final xml.XmlElement _root;
|
||||
|
||||
_StreamInfo(this._root);
|
||||
|
||||
@override
|
||||
int get tag => int.parse(_root.getAttribute('id'));
|
||||
|
||||
@override
|
||||
String get url => _root.getAttribute('BaseURL');
|
||||
|
||||
@override
|
||||
int get contentLength => int.parse(_root.getAttribute('contentLength') ??
|
||||
_contentLenExp.firstMatch(url).group(1));
|
||||
|
||||
@override
|
||||
int get bitrate => int.parse(_root.getAttribute('bandwidth'));
|
||||
|
||||
@override
|
||||
String get container =>
|
||||
Uri.decodeFull(_containerExp.firstMatch(url).group(1));
|
||||
|
||||
bool get isAudioOnly =>
|
||||
_root.findElements('AudioChannelConfiguration').isNotEmpty;
|
||||
|
||||
@override
|
||||
String get audioCodec => isAudioOnly ? null : _root.getAttribute('codecs');
|
||||
|
||||
@override
|
||||
String get videoCodec => isAudioOnly ? _root.getAttribute('codecs') : null;
|
||||
|
||||
@override
|
||||
int get videoWidth => int.parse(_root.getAttribute('width'));
|
||||
|
||||
@override
|
||||
int get videoHeight => int.parse(_root.getAttribute('height'));
|
||||
|
||||
@override
|
||||
int get framerate => int.parse(_root.getAttribute('framerate'));
|
||||
}
|
||||
|
|
|
@ -2,9 +2,9 @@ import 'dart:convert';
|
|||
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:html/parser.dart' as parser;
|
||||
import '../../retry.dart';
|
||||
|
||||
import '../../extensions/helpers_extension.dart';
|
||||
import '../../retry.dart';
|
||||
import '../youtube_http_client.dart';
|
||||
|
||||
class EmbedPage {
|
||||
|
@ -26,7 +26,7 @@ class EmbedPage {
|
|||
String get _playerConfigJson => _root
|
||||
.getElementsByTagName('script')
|
||||
.map((e) => e.text)
|
||||
.map((e) => _playerConfigExp.firstMatch(e).group(1))
|
||||
.map((e) => _playerConfigExp.firstMatch(e)?.group(1))
|
||||
.firstWhere((e) => !e.isNullOrWhiteSpace, orElse: () => null);
|
||||
|
||||
EmbedPage.parse(String raw) : _root = parser.parse(raw);
|
||||
|
@ -46,5 +46,5 @@ class _PlayerConfig {
|
|||
|
||||
_PlayerConfig(this._root);
|
||||
|
||||
String get sourceUrl => 'https://youtube.com ${_root['assets']['js']}';
|
||||
String get sourceUrl => 'https://youtube.com${_root['assets']['js']}';
|
||||
}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:youtube_explode_dart/src/reverse_engineering/responses/stream_info_provider.dart';
|
||||
|
||||
import '../../extensions/helpers_extension.dart';
|
||||
import 'stream_info_provider.dart';
|
||||
|
||||
class PlayerResponse {
|
||||
// Json parsed map
|
||||
|
@ -11,15 +13,14 @@ class PlayerResponse {
|
|||
|
||||
String get playabilityStatus => _root['playabilityStatus']['status'];
|
||||
|
||||
bool get isVideoAvailable => playabilityStatus != 'error';
|
||||
bool get isVideoAvailable => playabilityStatus.toLowerCase() != 'error';
|
||||
|
||||
bool get isVideoPlayable => playabilityStatus == 'ok';
|
||||
bool get isVideoPlayable => playabilityStatus.toLowerCase() == 'ok';
|
||||
|
||||
String get videoTitle => _root['videoDetails']['title'];
|
||||
|
||||
String get videoAuthor => _root['videoDetails']['author'];
|
||||
|
||||
//TODO: Check how this is formatted.
|
||||
DateTime get videoUploadDate => DateTime.parse(
|
||||
_root['microformat']['playerMicroformatRenderer']['uploadDate']);
|
||||
|
||||
|
@ -29,7 +30,7 @@ class PlayerResponse {
|
|||
Duration(seconds: int.parse(_root['videoDetails']['lengthSeconds']));
|
||||
|
||||
Iterable<String> get videoKeywords =>
|
||||
_root['videoDetails']['keywords'].cast<String>() ?? const [];
|
||||
_root['videoDetails']['keywords']?.cast<String>() ?? const [];
|
||||
|
||||
String get videoDescription => _root['videoDetails']['shortDescription'];
|
||||
|
||||
|
@ -41,35 +42,40 @@ class PlayerResponse {
|
|||
.get('playabilityStatus')
|
||||
?.get('errorScreen')
|
||||
?.get('playerLegacyDesktopYpcTrailerRenderer')
|
||||
?.get('trailerVideoId') ??
|
||||
?.getValue('trailerVideoId') ??
|
||||
Uri.splitQueryString(_root
|
||||
.get('playabilityStatus')
|
||||
?.get('errorScreen')
|
||||
?.get('')
|
||||
?.get('ypcTrailerRenderer')
|
||||
?.get('playerVars') ??
|
||||
?.getValue('playerVars') ??
|
||||
'')['video_id'];
|
||||
|
||||
bool get isLive => _root['videoDetails'].get('isLive') ?? false;
|
||||
bool get isLive => _root.get('videoDetails')?.getValue('isLive') ?? false;
|
||||
|
||||
// Can be null
|
||||
String get hlsManifestUrl =>
|
||||
_root.get('streamingData')?.get('hlsManifestUrl');
|
||||
_root.get('streamingData')?.getValue('hlsManifestUrl');
|
||||
|
||||
// Can be null
|
||||
String get dashManifestUrl =>
|
||||
_root.get('streamingData')?.get('dashManifestUrl');
|
||||
_root.get('streamingData')?.getValue('dashManifestUrl');
|
||||
|
||||
Iterable<StreamInfoProvider> get muxedStreams =>
|
||||
_root?.get('streamingData')?.get('formats')?.map((e) => _StreamInfo(e)) ??
|
||||
const [];
|
||||
_root
|
||||
?.get('streamingData')
|
||||
?.getValue('formats')
|
||||
?.map((e) => _StreamInfo(e))
|
||||
?.cast<StreamInfoProvider>() ??
|
||||
const <StreamInfoProvider>[];
|
||||
|
||||
Iterable<StreamInfoProvider> get adaptiveStreams =>
|
||||
_root
|
||||
?.get('streamingData')
|
||||
?.get('adaptiveFormats')
|
||||
?.map((e) => _StreamInfo(e)) ??
|
||||
const [];
|
||||
?.getValue('adaptiveFormats')
|
||||
?.map((e) => _StreamInfo(e))
|
||||
?.cast<StreamInfoProvider>() ??
|
||||
const <StreamInfoProvider>[];
|
||||
|
||||
Iterable<StreamInfoProvider> get streams =>
|
||||
[...muxedStreams, ...adaptiveStreams];
|
||||
|
@ -78,12 +84,12 @@ class PlayerResponse {
|
|||
_root
|
||||
.get('captions')
|
||||
?.get('playerCaptionsTracklistRenderer')
|
||||
?.get('captionTracks')
|
||||
?.getValue('captionTracks')
|
||||
?.map((e) => ClosedCaptionTrack(e)) ??
|
||||
const [];
|
||||
|
||||
String getVideoPlayabilityError() =>
|
||||
_root.get('playabilityStatus')?.get('reason');
|
||||
_root.get('playabilityStatus')?.getValue('reason');
|
||||
|
||||
PlayerResponse.parse(String raw) : _root = json.decode(raw);
|
||||
}
|
||||
|
@ -115,64 +121,58 @@ class _StreamInfo extends StreamInfoProvider {
|
|||
@override
|
||||
String get container => mimeType.subtype;
|
||||
|
||||
static final _contentLenExp = RegExp(r'[\?&]clen=(\d+)');
|
||||
|
||||
@override
|
||||
int get contentLength =>
|
||||
_root['contentLength'] ??
|
||||
StreamInfoProvider.contentLenExp.firstMatch(url).group(1);
|
||||
int.tryParse(_root['contentLength'] ?? '') ??
|
||||
_contentLenExp.firstMatch(url)?.group(1);
|
||||
|
||||
@override
|
||||
int get framerate => int.tryParse(_root['fps'] ?? '');
|
||||
int get framerate => _root['fps'];
|
||||
|
||||
@override
|
||||
String get signature => Uri.splitQueryString(_root.get('cipher') ?? '')['s'];
|
||||
String get signature =>
|
||||
Uri.splitQueryString(_root['signatureCipher'] ?? '')['s'];
|
||||
|
||||
@override
|
||||
String get signatureParameter =>
|
||||
Uri.splitQueryString(_root['cipher'] ?? '')['sp'];
|
||||
Uri.splitQueryString(_root['cipher'] ?? '')['sp'] ??
|
||||
Uri.splitQueryString(_root['signatureCipher'] ?? '')['sp'];
|
||||
|
||||
@override
|
||||
int get tag => int.parse(_root['itag']);
|
||||
int get tag => _root['itag'];
|
||||
|
||||
@override
|
||||
String get url =>
|
||||
_root?.get('url') ??
|
||||
Uri.splitQueryString(_root?.get('cipher') ?? '')['s'];
|
||||
String get url => _getUrl();
|
||||
|
||||
String _getUrl() {
|
||||
var url = _root['url'];
|
||||
url ??= Uri.splitQueryString(_root['cipher'] ?? '')['url'];
|
||||
url ??= Uri.splitQueryString(_root['signatureCipher'] ?? '')['url'];
|
||||
return url;
|
||||
}
|
||||
|
||||
@override
|
||||
// TODO: implement videoCodec, gotta debug how the mimeType is formatted
|
||||
String get videoCodec => throw UnimplementedError();
|
||||
String get videoCodec =>
|
||||
isAudioOnly ? null : codecs.split(',').first.trim().nullIfWhitespace;
|
||||
|
||||
@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();
|
||||
bool get isAudioOnly => mimeType.type == 'audio';
|
||||
|
||||
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;
|
||||
}
|
||||
String get audioCodec =>
|
||||
isAudioOnly ? codecs : codecs.split(',').last.trim().nullIfWhitespace;
|
||||
}
|
||||
|
|
|
@ -19,12 +19,13 @@ class PlayerSource {
|
|||
PlayerSource(this._root);
|
||||
|
||||
String get sts {
|
||||
var val = RegExp(r'(?<=invalid namespace.*?;var \w\s*=)\d+')
|
||||
var val = RegExp(r'(?<=invalid namespace.*?;\w+\s*=)\d+')
|
||||
.stringMatch(_root)
|
||||
.nullIfWhitespace;
|
||||
?.nullIfWhitespace;
|
||||
if (val == null) {
|
||||
throw FatalFailureException('Could not find sts in player source.');
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
Iterable<CipherOperation> getCiperOperations() sync* {
|
||||
|
@ -42,7 +43,7 @@ class PlayerSource {
|
|||
}
|
||||
|
||||
for (var statement in funcBody.split(';')) {
|
||||
var calledFuncName = _calledFuncNameExp.firstMatch(statement).group(1);
|
||||
var calledFuncName = _calledFuncNameExp.firstMatch(statement)?.group(1);
|
||||
if (calledFuncName.isNullOrWhiteSpace) {
|
||||
continue;
|
||||
}
|
||||
|
@ -77,16 +78,18 @@ class PlayerSource {
|
|||
var funcName = _funcBodyExp.firstMatch(_root).group(1);
|
||||
|
||||
var exp = RegExp(
|
||||
r'(?!h\.)' '${RegExp.escape(funcName)}' r'=function\(\w+\)\{{(.*?)\}}');
|
||||
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+'
|
||||
var exp = RegExp(
|
||||
r'var\s+'
|
||||
'${RegExp.escape(funcName)}'
|
||||
r'=\{{(\w+:function\(\w+(,\w+)?\)\{{(.*?)\}}),?\}};');
|
||||
r'=\{(\w+:function\(\w+(,\w+)?\)\{(.*?)\}),?\};',
|
||||
dotAll: true);
|
||||
return exp.firstMatch(_root).group(0).nullIfWhitespace;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import '../../exceptions/exceptions.dart';
|
||||
import '../../extensions/helpers_extension.dart';
|
||||
import '../../retry.dart';
|
||||
import '../youtube_http_client.dart';
|
||||
|
||||
|
@ -16,14 +17,14 @@ class PlaylistResponse {
|
|||
|
||||
String get description => _root['description'];
|
||||
|
||||
int get viewCount => int.tryParse(_root['views'] ?? '');
|
||||
int get viewCount => _root['views'];
|
||||
|
||||
int get likeCount => int.tryParse(_root['likes']);
|
||||
int get likeCount => _root['likes'];
|
||||
|
||||
int get dislikeCount => int.tryParse(_root['dislikes']);
|
||||
int get dislikeCount => _root['dislikes'];
|
||||
|
||||
Iterable<_Video> get videos =>
|
||||
_root['video']?.map((e) => _Video(e)) ?? const [];
|
||||
_root['video']?.map((e) => _Video(e))?.cast<_Video>() ?? const <_Video>[];
|
||||
|
||||
PlaylistResponse.parse(String raw) : _root = json.tryDecode(raw) {
|
||||
if (_root == null) {
|
||||
|
@ -73,11 +74,11 @@ class _Video {
|
|||
|
||||
Duration get duration => Duration(seconds: _root['length_seconds']);
|
||||
|
||||
int get viewCount => int.parse(_root['views'].stripNonDigits());
|
||||
int get viewCount => int.parse((_root['views'] as String).stripNonDigits());
|
||||
|
||||
int get likes => int.parse(_root['likes']);
|
||||
int get likes => _root['likes'];
|
||||
|
||||
int get dislikes => int.parse(_root['dislikes']);
|
||||
int get dislikes => _root['dislikes'];
|
||||
|
||||
Iterable<String> get keywords => RegExp(r'"[^\"]+"|\S+')
|
||||
.allMatches(_root['keywords'])
|
||||
|
@ -85,13 +86,6 @@ class _Video {
|
|||
.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 {
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import 'package:http_parser/http_parser.dart';
|
||||
|
||||
abstract class StreamInfoProvider {
|
||||
static final contentLenExp = RegExp(r'clen=(\d+)');
|
||||
static final RegExp contentLenExp = RegExp(r'clen=(\d+)');
|
||||
|
||||
int get tag;
|
||||
|
||||
|
|
|
@ -13,9 +13,8 @@ import 'player_response.dart';
|
|||
import 'stream_info_provider.dart';
|
||||
|
||||
class WatchPage {
|
||||
final RegExp _videoLikeExp = RegExp(r'label""\s*:\s*""([\d,\.]+) likes');
|
||||
final RegExp _videoDislikeExp =
|
||||
RegExp(r'label""\s*:\s*""([\d,\.]+) dislikes');
|
||||
final RegExp _videoLikeExp = RegExp(r'"label"\s*:\s*"([\d,\.]+) likes"');
|
||||
final RegExp _videoDislikeExp = RegExp(r'"label"\s*:\s*"([\d,\.]+) dislikes');
|
||||
|
||||
final Document _root;
|
||||
|
||||
|
@ -26,27 +25,49 @@ class WatchPage {
|
|||
bool get isVideoAvailable =>
|
||||
_root.querySelector('meta[property="og:url"]') != null;
|
||||
|
||||
//TODO: This does not work.
|
||||
int get videoLikeCount => int.tryParse(_videoLikeExp
|
||||
.firstMatch(_root.outerHtml)
|
||||
?.group(1)
|
||||
?.nullIfWhitespace
|
||||
?.stripNonDigits() ??
|
||||
'');
|
||||
//TODO: Update this to the new "parsing method" w/ regex "label"\s*:\s*"([\d,\.]+) likes"
|
||||
int get videoLikeCount => int.parse(_root
|
||||
.querySelector('.like-button-renderer-like-button')
|
||||
?.text
|
||||
?.stripNonDigits()
|
||||
?.nullIfWhitespace ??
|
||||
'0');
|
||||
|
||||
//TODO: This does not work.
|
||||
int get videoDislikeCount => int.tryParse(_videoDislikeExp
|
||||
.firstMatch(_root.outerHtml)
|
||||
?.group(1)
|
||||
?.nullIfWhitespace
|
||||
?.stripNonDigits() ??
|
||||
'');
|
||||
//TODO: Update this to the new "parsing method" w/ regex "label"\s*:\s*"([\d,\.]+) dislikes"
|
||||
int get videoDislikeCount => int.parse(_root
|
||||
.querySelector('.like-button-renderer-dislike-button')
|
||||
?.text
|
||||
?.stripNonDigits()
|
||||
?.nullIfWhitespace ??
|
||||
'0');
|
||||
|
||||
_PlayerConfig get playerConfig => _PlayerConfig(json.decode(_root
|
||||
.getElementsByTagName('script')
|
||||
.map((e) => e.text)
|
||||
.map(_extractJson)
|
||||
.firstWhere((e) => e != null)));
|
||||
_PlayerConfig get playerConfig => _PlayerConfig(json.decode(
|
||||
_matchJson(_extractJson(_root.getElementsByTagName('html').first.text))));
|
||||
|
||||
final String configSep = 'ytplayer.config = ';
|
||||
|
||||
String _extractJson(String html) {
|
||||
return _matchJson(
|
||||
html.substring(html.indexOf(configSep) + configSep.length));
|
||||
}
|
||||
|
||||
String _matchJson(String str) {
|
||||
var bracketCount = 0;
|
||||
int lastI;
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
lastI = i;
|
||||
if (str[i] == '{') {
|
||||
bracketCount++;
|
||||
} else if (str[i] == '}') {
|
||||
bracketCount--;
|
||||
} else if (str[i] == ';') {
|
||||
if (bracketCount == 0) {
|
||||
return str.substring(0, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return str.substring(0, lastI+1);
|
||||
}
|
||||
|
||||
WatchPage.parse(String raw) : _root = parser.parse(raw);
|
||||
|
||||
|
@ -67,14 +88,6 @@ class WatchPage {
|
|||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
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 _StreamInfo extends StreamInfoProvider {
|
||||
|
@ -149,28 +162,20 @@ class _PlayerConfig {
|
|||
PlayerResponse.parse(_root['args']['player_response']);
|
||||
|
||||
List<_StreamInfo> get muxedStreams =>
|
||||
_root['args']
|
||||
.get('url_encoded_fmt_stream_map')
|
||||
_root
|
||||
.get('args')
|
||||
?.getValue('url_encoded_fmt_stream_map')
|
||||
?.split(',')
|
||||
?.map((e) => _StreamInfo(Uri.splitQueryString(e))) ??
|
||||
const [];
|
||||
|
||||
List<_StreamInfo> get adaptiveStreams =>
|
||||
_root['args']
|
||||
.get('adaptive_fmts')
|
||||
_root
|
||||
.get('args')
|
||||
?.getValue('adaptive_fmts')
|
||||
?.split(',')
|
||||
?.map((e) => _StreamInfo(Uri.splitQueryString(e))) ??
|
||||
const [];
|
||||
|
||||
List<_StreamInfo> get streams => [...muxedStreams, ...adaptiveStreams];
|
||||
}
|
||||
|
||||
extension _GetOrNull<K, V> on Map<K, V> {
|
||||
V get(K key) {
|
||||
var v = this[key];
|
||||
if (v == null) {
|
||||
return null;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import 'package:http/http.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:youtube_explode_dart/src/videos/streams/streams.dart';
|
||||
|
||||
import '../exceptions/exceptions.dart';
|
||||
|
||||
|
@ -53,17 +55,29 @@ class YoutubeHttpClient {
|
|||
|
||||
Stream<List<int>> getStream(dynamic url,
|
||||
{Map<String, String> headers,
|
||||
int from,
|
||||
int to,
|
||||
@required StreamInfo streamInfo,
|
||||
bool validate = true}) async* {
|
||||
var request = Request('get', url);
|
||||
request.headers['range'] = 'bytes=$from-$to';
|
||||
request.headers.addAll(_userAgent);
|
||||
var response = await request.send();
|
||||
if (validate) {
|
||||
_validateResponse(response, response.statusCode);
|
||||
if (!streamInfo.isRateLimited()) {
|
||||
var request = Request('get', url);
|
||||
request.headers.addAll(_userAgent);
|
||||
var response = await request.send();
|
||||
if (validate) {
|
||||
_validateResponse(response, response.statusCode);
|
||||
}
|
||||
yield* response.stream;
|
||||
return;
|
||||
} else {
|
||||
for (var i = 0; i < streamInfo.size.totalBytes; i += 9898989) {
|
||||
var request = Request('get', url);
|
||||
request.headers['range'] = 'bytes=$i-${i + 9898989}';
|
||||
request.headers.addAll(_userAgent);
|
||||
var response = await request.send();
|
||||
if (validate) {
|
||||
_validateResponse(response, response.statusCode);
|
||||
}
|
||||
yield* response.stream;
|
||||
}
|
||||
}
|
||||
yield* response.stream;
|
||||
}
|
||||
|
||||
Future<int> getContentLength(dynamic url,
|
||||
|
@ -74,7 +88,7 @@ class YoutubeHttpClient {
|
|||
_validateResponse(response, response.statusCode);
|
||||
}
|
||||
|
||||
return int.parse(response.headers['content-length']);
|
||||
return int.tryParse(response.headers['content-length'] ?? '');
|
||||
}
|
||||
|
||||
/// Closes the [Client] assigned to this [YoutubeHttpClient].
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'dart:collection';
|
||||
|
||||
import 'package:youtube_explode_dart/src/videos/closed_captions/closed_caption_track_info.dart';
|
||||
import 'closed_caption_track_info.dart';
|
||||
|
||||
/// Manifest that contains information about available closed caption tracks
|
||||
/// in a specific video.
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
import 'package:youtube_explode_dart/src/videos/streams/audio_stream_info.dart';
|
||||
import 'package:youtube_explode_dart/src/videos/streams/bitrate.dart';
|
||||
import 'package:youtube_explode_dart/src/videos/streams/container.dart';
|
||||
import 'package:youtube_explode_dart/src/videos/streams/filesize.dart';
|
||||
import 'streams.dart';
|
||||
|
||||
/// YouTube media stream that only contains audio.
|
||||
class AudioOnlyStreamInfo implements AudioStreamInfo {
|
||||
|
|
|
@ -1,8 +1,4 @@
|
|||
import 'package:youtube_explode_dart/src/videos/streams/bitrate.dart';
|
||||
import 'package:youtube_explode_dart/src/videos/streams/container.dart';
|
||||
import 'package:youtube_explode_dart/src/videos/streams/filesize.dart';
|
||||
|
||||
import 'stream_info.dart';
|
||||
import 'streams.dart';
|
||||
|
||||
/// YouTube media stream that contains audio.
|
||||
abstract class AudioStreamInfo extends StreamInfo {
|
||||
|
|
|
@ -7,8 +7,10 @@ class Bitrate extends Comparable<Bitrate> with EquatableMixin {
|
|||
|
||||
/// Kilobits per second.
|
||||
double get kiloBitsPerSecond => bitsPerSecond / 1024;
|
||||
|
||||
/// Megabits per second.
|
||||
double get megaBitsPerSecond => kiloBitsPerSecond / 1024;
|
||||
|
||||
/// Gigabits per second.
|
||||
double get gigaBitsPerSecond => megaBitsPerSecond / 1024;
|
||||
|
||||
|
@ -16,7 +18,7 @@ class Bitrate extends Comparable<Bitrate> with EquatableMixin {
|
|||
Bitrate(this.bitsPerSecond);
|
||||
|
||||
@override
|
||||
int compareTo(Bitrate other) => null;
|
||||
int compareTo(Bitrate other) => bitsPerSecond.compareTo(other.bitsPerSecond);
|
||||
|
||||
@override
|
||||
List<Object> get props => [bitsPerSecond];
|
||||
|
@ -49,4 +51,4 @@ class Bitrate extends Comparable<Bitrate> with EquatableMixin {
|
|||
|
||||
@override
|
||||
String toString() => '${_getLargestValue()} ${_getLargestSymbol()}';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,6 +50,5 @@ class FileSize extends Comparable<FileSize> with EquatableMixin {
|
|||
String toString() => '${_getLargestValue()} ${_getLargestSymbol()}';
|
||||
|
||||
@override
|
||||
// TODO: implement props
|
||||
List<Object> get props => [totalBytes];
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import 'package:equatable/equatable.dart';
|
|||
/// Encapsulates framerate.
|
||||
class Framerate extends Comparable<Framerate> with EquatableMixin {
|
||||
/// Framerate as frames per second
|
||||
final double framesPerSecond;
|
||||
final num framesPerSecond;
|
||||
|
||||
/// Initialize an instance of [Framerate]
|
||||
Framerate(this.framesPerSecond);
|
||||
|
@ -23,8 +23,4 @@ class Framerate extends Comparable<Framerate> with EquatableMixin {
|
|||
@override
|
||||
int compareTo(Framerate other) =>
|
||||
framesPerSecond.compareTo(other.framesPerSecond);
|
||||
}
|
||||
|
||||
void t() {
|
||||
var t = Framerate(1.1) > Framerate(2.2);
|
||||
}
|
||||
}
|
|
@ -9,6 +9,7 @@ import 'video_stream_info.dart';
|
|||
|
||||
/// YouTube media stream that contains both audio and video.
|
||||
class MuxedStreamInfo implements AudioStreamInfo, VideoStreamInfo {
|
||||
@override
|
||||
final int tag;
|
||||
|
||||
@override
|
||||
|
|
|
@ -28,7 +28,7 @@ abstract class StreamInfo {
|
|||
extension StreamInfoExt on StreamInfo {
|
||||
static final _exp = RegExp('ratebypass[=/]yes');
|
||||
|
||||
bool _isRateLimited() => _exp.hasMatch(url.toString());
|
||||
bool isRateLimited() => _exp.hasMatch(url.toString());
|
||||
|
||||
/// Gets the stream with highest bitrate.
|
||||
static StreamInfo getHighestBitrate(List<StreamInfo> streams) =>
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
import 'dart:collection';
|
||||
|
||||
import 'package:youtube_explode_dart/src/videos/streams/audio_only_stream_info.dart';
|
||||
import 'package:youtube_explode_dart/src/videos/streams/muxed_stream_info.dart';
|
||||
import 'package:youtube_explode_dart/src/videos/streams/video_only_stream_info.dart';
|
||||
import 'package:youtube_explode_dart/src/videos/streams/video_stream_info.dart';
|
||||
|
||||
import 'audio_stream_info.dart';
|
||||
import 'stream_info.dart';
|
||||
import 'streams.dart';
|
||||
|
||||
/// Manifest that contains information about available media streams
|
||||
/// in a specific video.
|
||||
|
|
|
@ -58,13 +58,17 @@ class StreamsClient {
|
|||
reason: playerResponse.getVideoPlayabilityError());
|
||||
}
|
||||
|
||||
if (playerResponse.isLive) {
|
||||
throw VideoUnplayableException.liveStream(videoId);
|
||||
}
|
||||
|
||||
var streamInfoProviders = <StreamInfoProvider>[
|
||||
...videoInfoReponse.streams,
|
||||
...playerResponse.streams
|
||||
];
|
||||
|
||||
var dashManifestUrl = playerResponse.dashManifestUrl;
|
||||
if (dashManifestUrl.isNullOrWhiteSpace) {
|
||||
if (!dashManifestUrl.isNullOrWhiteSpace) {
|
||||
var dashManifest =
|
||||
await _getDashManifest(Uri.parse(dashManifestUrl), cipherOperations);
|
||||
streamInfoProviders.addAll(dashManifest.streams);
|
||||
|
@ -106,7 +110,7 @@ class StreamsClient {
|
|||
];
|
||||
|
||||
var dashManifestUrl = playerResponse.dashManifestUrl;
|
||||
if (dashManifestUrl.isNullOrWhiteSpace) {
|
||||
if (!dashManifestUrl.isNullOrWhiteSpace) {
|
||||
var dashManifest =
|
||||
await _getDashManifest(Uri.parse(dashManifestUrl), cipherOperations);
|
||||
streamInfoProviders.addAll(dashManifest.streams);
|
||||
|
@ -243,7 +247,7 @@ class StreamsClient {
|
|||
//TODO: Test this
|
||||
/// Gets the actual stream which is identified by the specified metadata.
|
||||
Stream<List<int>> get(StreamInfo streamInfo) {
|
||||
return _httpClient.getStream(streamInfo.url);
|
||||
return _httpClient.getStream(streamInfo.url, streamInfo: streamInfo);
|
||||
}
|
||||
|
||||
//TODO: Implement CopyToAsync
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Width and height of a video.
|
||||
class VideoResolution {
|
||||
class VideoResolution {
|
||||
/// Viewport width.
|
||||
final int width;
|
||||
|
||||
|
|
|
@ -1,11 +1,4 @@
|
|||
import 'package:youtube_explode_dart/src/videos/streams/bitrate.dart';
|
||||
import 'package:youtube_explode_dart/src/videos/streams/container.dart';
|
||||
import 'package:youtube_explode_dart/src/videos/streams/filesize.dart';
|
||||
|
||||
import 'framerate.dart';
|
||||
import 'stream_info.dart';
|
||||
import 'video_quality.dart';
|
||||
import 'video_resolution.dart';
|
||||
import 'streams.dart';
|
||||
|
||||
/// YouTube media stream that contains video.
|
||||
abstract class VideoStreamInfo extends StreamInfo {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import 'dart:collection';
|
||||
|
||||
import 'package:youtube_explode_dart/src/common/common.dart';
|
||||
|
||||
import '../common/common.dart';
|
||||
import 'video_id.dart';
|
||||
|
||||
/// YouTube video metadata.
|
||||
|
|
|
@ -12,10 +12,12 @@ class VideoId extends Equatable {
|
|||
final String value;
|
||||
|
||||
/// Initializes an instance of [VideoId] with a url or video id.
|
||||
VideoId(String urlOrUrl)
|
||||
: value = parseVideoId(urlOrUrl) ??
|
||||
ArgumentError.value(
|
||||
urlOrUrl, 'urlOrUrl', 'Invalid YouTube video ID or URL.');
|
||||
VideoId(String idOrUrl) : value = parseVideoId(idOrUrl) {
|
||||
if (value == null) {
|
||||
throw ArgumentError.value(
|
||||
idOrUrl, 'urlOrUrl', 'Invalid YouTube video ID or URL');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
|
|
@ -14,10 +14,6 @@ dependencies:
|
|||
equatable: ^1.1.0
|
||||
|
||||
dev_dependencies:
|
||||
quick_log: ^0.4.1
|
||||
|
||||
dart_benchmark:
|
||||
path: ../repos/dart_benchmark
|
||||
effective_dart: ^1.2.1
|
||||
dart_console: ^0.5.0
|
||||
test: ^1.12.0
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:test/test.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
group('ChannelId', () {
|
||||
test('ValidChannelId', () {
|
||||
var channel1 = ChannelId('UCEnBXANsKmyj2r9xVyKoDiQ');
|
||||
var channel2 = ChannelId('UC46807r_RiRjH8IU-h_DrDQ');
|
||||
|
||||
expect(channel1.value, 'UCEnBXANsKmyj2r9xVyKoDiQ');
|
||||
expect(channel2.value, 'UC46807r_RiRjH8IU-h_DrDQ');
|
||||
});
|
||||
test('ValidChannelUrl', () {
|
||||
var channel1 = ChannelId('youtube.com/channel/UC3xnGqlcL3y-GXz5N3wiTJQ');
|
||||
var channel2 = ChannelId('youtube.com/channel/UCkQO3QsgTpNTsOw6ujimT5Q');
|
||||
var channel3 = ChannelId('youtube.com/channel/UCQtjJDOYluum87LA4sI6xcg');
|
||||
|
||||
expect(channel1.value, 'UC3xnGqlcL3y-GXz5N3wiTJQ');
|
||||
expect(channel2.value, 'UCkQO3QsgTpNTsOw6ujimT5Q');
|
||||
expect(channel3.value, 'UCQtjJDOYluum87LA4sI6xcg');
|
||||
});
|
||||
test('InvalidChannelId', () {
|
||||
expect(() => ChannelId(''), throwsArgumentError);
|
||||
expect(() => ChannelId('UC3xnGqlcL3y-GXz5N3wiTJ'), throwsArgumentError);
|
||||
expect(() => ChannelId('UC3xnGqlcL y-GXz5N3wiTJQ'), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('InvalidChannelUrl', () {
|
||||
expect(() => ChannelId('youtube.com/?channel=UCUC3xnGqlcL3y-GXz5N3wiTJQ'),
|
||||
throwsArgumentError);
|
||||
expect(() => ChannelId('youtube.com/channel/asd'), throwsArgumentError);
|
||||
expect(() => ChannelId('youtube.com/'), throwsArgumentError);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
import 'package:test/test.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
group('Channel', () {
|
||||
YoutubeExplode yt;
|
||||
setUp(() {
|
||||
yt = YoutubeExplode();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
yt.close();
|
||||
});
|
||||
|
||||
test('GetMetadataOfChannel', () async {
|
||||
var channelUrl =
|
||||
'https://www.youtube.com/channel/UCEnBXANsKmyj2r9xVyKoDiQ';
|
||||
var channel = await yt.channels.get(ChannelId(channelUrl));
|
||||
expect(channel.url, channelUrl);
|
||||
expect(channel.title, 'Tyrrrz');
|
||||
expect(channel.logoUrl, isNotNull);
|
||||
expect(channel.logoUrl, isNot(equalsIgnoringWhitespace('')));
|
||||
});
|
||||
|
||||
test('GetMetadataOfAnyChannel', () async {
|
||||
var channelId = ChannelId('UC46807r_RiRjH8IU-h_DrDQ');
|
||||
var channel = await yt.channels.get(channelId);
|
||||
expect(channel.id, channelId);
|
||||
|
||||
channelId = ChannelId('UCJ6td3C9QlPO9O_J5dF4ZzA');
|
||||
channel = await yt.channels.get(channelId);
|
||||
expect(channel.id, channelId);
|
||||
|
||||
channelId = ChannelId('UCiGm_E4ZwYSHV3bcW1pnSeQ');
|
||||
channel = await yt.channels.get(channelId);
|
||||
expect(channel.id, channelId);
|
||||
});
|
||||
|
||||
test('GetMetadataOfAnyChannelByUser', () async {
|
||||
var channel = await yt.channels.getByUsername(Username('TheTyrrr'));
|
||||
expect(channel.id.value, 'UCEnBXANsKmyj2r9xVyKoDiQ');
|
||||
});
|
||||
|
||||
test('GetMetadataOfAnyChannelByVideo', () async {
|
||||
var channel = await yt.channels.getByVideo(VideoId('5NmxuoNyDss'));
|
||||
expect(channel.id.value, 'UCEnBXANsKmyj2r9xVyKoDiQ');
|
||||
});
|
||||
|
||||
test('GetVideosOfYoutubeChannel', () async {
|
||||
var videos = await yt.channels
|
||||
.getUploads(ChannelId(
|
||||
'https://www.youtube.com/channel/UCEnBXANsKmyj2r9xVyKoDiQ'))
|
||||
.toList();
|
||||
expect(videos.length, greaterThanOrEqualTo(80));
|
||||
});
|
||||
|
||||
test('GetVideosOfAnyYoutubeChannel', () async {
|
||||
var videos = await yt.channels
|
||||
.getUploads(ChannelId('UC46807r_RiRjH8IU-h_DrDQ'))
|
||||
.toList();
|
||||
expect(videos, isNotEmpty);
|
||||
|
||||
videos = await yt.channels
|
||||
.getUploads(ChannelId('UCJ6td3C9QlPO9O_J5dF4ZzA'))
|
||||
.toList();
|
||||
expect(videos, isNotEmpty);
|
||||
|
||||
videos = await yt.channels
|
||||
.getUploads(ChannelId('UCiGm_E4ZwYSHV3bcW1pnSeQ'))
|
||||
.toList();
|
||||
expect(videos, isNotEmpty);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
//TODO: Implement this
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:test/test.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
group('PlaylistId', () {
|
||||
test('ValidPlaylistId', () {
|
||||
var data = const {
|
||||
'PL601B2E69B03FAB9D',
|
||||
'PLI5YfMzCfRtZ8eV576YoY3vIYrHjyVm_e',
|
||||
'PLWwAypAcFRgKFlxtLbn_u14zddtDJj3mk',
|
||||
'OLAK5uy_mtOdjCW76nDvf5yOzgcAVMYpJ5gcW5uKU',
|
||||
'RD1hu8-y6fKg0',
|
||||
'RDMMU-ty-2B02VY',
|
||||
'RDCLAK5uy_lf8okgl2ygD075nhnJVjlfhwp8NsUgEbs',
|
||||
'ULl6WWX-BgIiE',
|
||||
'UUTMt7iMWa7jy0fNXIktwyLA',
|
||||
'OLAK5uy_lLeonUugocG5J0EUAEDmbskX4emejKwcM',
|
||||
'FLEnBXANsKmyj2r9xVyKoDiQ'
|
||||
};
|
||||
// ignore: avoid_function_literals_in_foreach_calls
|
||||
data.forEach((playlistId) {
|
||||
var playlist = PlaylistId(playlistId);
|
||||
expect(playlist.value, playlistId);
|
||||
});
|
||||
});
|
||||
test('ValidPlaylistUrl', () {
|
||||
var data = const {
|
||||
'youtube.com/playlist?list=PLOU2XLYxmsIJGErt5rrCqaSGTMyyqNt2H':
|
||||
'PLOU2XLYxmsIJGErt5rrCqaSGTMyyqNt2H',
|
||||
'youtube.com/watch?v=b8m9zhNAgKs&list=PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr':
|
||||
'PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr',
|
||||
'youtu.be/b8m9zhNAgKs/?list=PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr':
|
||||
'PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr',
|
||||
'youtube.com/embed/b8m9zhNAgKs/?list=PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr':
|
||||
'PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr',
|
||||
'youtube.com/watch?v=x2ZRoWQ0grU&list=RDEMNJhLy4rECJ_fG8NL-joqsg':
|
||||
'RDEMNJhLy4rECJ_fG8NL-joqsg'
|
||||
};
|
||||
data.forEach((url, playlistId) {
|
||||
var playlist = PlaylistId(playlistId);
|
||||
expect(playlist.value, playlistId);
|
||||
});
|
||||
});
|
||||
test('InvalidPlaylistId', () {
|
||||
expect(() => PlaylistId('PLm_3vnTS-pvmZFuF L1Pyhqf8kTTYVKjW'),
|
||||
throwsArgumentError);
|
||||
expect(() => PlaylistId('PLm_3vnTS-pvmZFuF3L=Pyhqf8kTTYVKjW'),
|
||||
throwsArgumentError);
|
||||
});
|
||||
test('InvalidPlaylistUrl', () {
|
||||
expect(() => PlaylistId('youtube.com/playlist?lisp=PLOU2XLYxmsIJGErt5rrCqaSGTMyyqNt2H'),
|
||||
throwsArgumentError);
|
||||
expect(() => PlaylistId('youtube.com/playlist?list=asd'),
|
||||
throwsArgumentError);
|
||||
expect(() => PlaylistId('youtube.com/'),
|
||||
throwsArgumentError);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
import 'package:test/test.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
group('Playlist', () {
|
||||
YoutubeExplode yt;
|
||||
setUp(() {
|
||||
yt = YoutubeExplode();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
yt.close();
|
||||
});
|
||||
|
||||
test('GetMetadataOfPlaylist', () async {
|
||||
var playlistUrl =
|
||||
'https://www.youtube.com/playlist?list=PLr-IftNTIujSF-8tlGbZBQyGIT6TCF6Yd';
|
||||
var playlist = await yt.playlists.get(PlaylistId(playlistUrl));
|
||||
expect(playlist.id.value, 'PLr-IftNTIujSF-8tlGbZBQyGIT6TCF6Yd');
|
||||
expect(playlist.url, playlistUrl);
|
||||
expect(playlist.title, 'osu! Highlights');
|
||||
expect(playlist.author, 'Tyrrrz');
|
||||
expect(playlist.description, 'My best osu! plays');
|
||||
expect(playlist.engagement.viewCount, greaterThanOrEqualTo(133));
|
||||
expect(playlist.engagement.likeCount, greaterThanOrEqualTo(0));
|
||||
expect(playlist.engagement.dislikeCount, greaterThanOrEqualTo(0));
|
||||
});
|
||||
test('GetMetadataOfAnyPlaylist', () async {
|
||||
var data = {
|
||||
'PLI5YfMzCfRtZ8eV576YoY3vIYrHjyVm_e',
|
||||
'RD1hu8-y6fKg0',
|
||||
'RDMMU-ty-2B02VY',
|
||||
'RDCLAK5uy_lf8okgl2ygD075nhnJVjlfhwp8NsUgEbs',
|
||||
'OLAK5uy_lLeonUugocG5J0EUAEDmbskX4emejKwcM',
|
||||
'PL601B2E69B03FAB9D'
|
||||
};
|
||||
for (var playlistId in data) {
|
||||
var playlist = await yt.playlists.get(PlaylistId(playlistId));
|
||||
expect(playlist.id.value, playlistId);
|
||||
}
|
||||
});
|
||||
test('GetVideosInPlaylist', () async {
|
||||
var videos = await yt.playlists
|
||||
.getVideos(PlaylistId(
|
||||
'https://www.youtube.com/playlist?list=PLr-IftNTIujSF-8tlGbZBQyGIT6TCF6Yd'))
|
||||
.toList();
|
||||
expect(videos.length, greaterThanOrEqualTo(20));
|
||||
expect(
|
||||
videos.map((e) => e.id.value).toList(),
|
||||
containsAll([
|
||||
'B6N8-_rBTh8',
|
||||
'F1bvjgTckMc',
|
||||
'kMBzljXOb9g',
|
||||
'LsNPjFXIPT8',
|
||||
'fXYPMPglYTs',
|
||||
'AI7ULzgf8RU',
|
||||
'VoGpvg3xXoE',
|
||||
'Qzu-fTdjeFY'
|
||||
]));
|
||||
});
|
||||
test('GetVideosInAnyPlaylist', () async {
|
||||
var data = const {
|
||||
'PL601B2E69B03FAB9D',
|
||||
'PLI5YfMzCfRtZ8eV576YoY3vIYrHjyVm_e',
|
||||
'PLWwAypAcFRgKFlxtLbn_u14zddtDJj3mk',
|
||||
'OLAK5uy_mtOdjCW76nDvf5yOzgcAVMYpJ5gcW5uKU',
|
||||
'RD1hu8-y6fKg0',
|
||||
'RDMMU-ty-2B02VY',
|
||||
'RDCLAK5uy_lf8okgl2ygD075nhnJVjlfhwp8NsUgEbs',
|
||||
'ULl6WWX-BgIiE',
|
||||
'UUTMt7iMWa7jy0fNXIktwyLA',
|
||||
'OLAK5uy_lLeonUugocG5J0EUAEDmbskX4emejKwcM',
|
||||
'FLEnBXANsKmyj2r9xVyKoDiQ'
|
||||
};
|
||||
|
||||
for (var playlistId in data) {
|
||||
var videos =
|
||||
await yt.playlists.getVideos(PlaylistId(playlistId)).toList();
|
||||
expect(videos, isNotEmpty);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
//TODO: Implement this
|
|
@ -0,0 +1,70 @@
|
|||
import 'package:test/test.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
group('Streams', () {
|
||||
YoutubeExplode yt;
|
||||
setUp(() {
|
||||
yt = YoutubeExplode();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
yt.close();
|
||||
});
|
||||
|
||||
test('GetStreamsOfAnyVideo', () async {
|
||||
var data = {
|
||||
'9bZkp7q19f0',
|
||||
'SkRSXFQerZs',
|
||||
'hySoCSoH-g8',
|
||||
'_kmeFXjjGfk',
|
||||
'MeJVWBSsPAY',
|
||||
'5VGm0dczmHc',
|
||||
'ZGdLIwrGHG8',
|
||||
'rsAAeyAr-9Y',
|
||||
'AI7ULzgf8RU'
|
||||
};
|
||||
for (var videoId in data) {
|
||||
var manifest =
|
||||
await yt.videos.streamsClient.getManifest(VideoId(videoId));
|
||||
expect(manifest.streams, isNotEmpty);
|
||||
}
|
||||
}, skip: 'Working on it.');
|
||||
|
||||
test('GetStreamOfUnplayableVideo', () async {
|
||||
expect(yt.videos.streamsClient.getManifest(VideoId('5qap5aO4i9A')),
|
||||
throwsA(const TypeMatcher<VideoUnplayableException>()));
|
||||
});
|
||||
test('GetStreamOfPurchaseVideo', () async {
|
||||
expect(yt.videos.streamsClient.getManifest(VideoId('p3dDcKOFXQg')),
|
||||
throwsA(const TypeMatcher<VideoRequiresPurchaseException>()));
|
||||
});
|
||||
//TODO: Fix this with VideoRequiresPurchaseException.
|
||||
test('GetStreamOfPurchaseVideo', () async {
|
||||
expect(yt.videos.streamsClient.getManifest(VideoId('qld9w0b-1ao')),
|
||||
throwsA(const TypeMatcher<VideoUnavailableException>()));
|
||||
expect(yt.videos.streamsClient.getManifest(VideoId('pb_hHv3fByo')),
|
||||
throwsA(const TypeMatcher<VideoUnavailableException>()));
|
||||
});
|
||||
test('GetStreamOfAnyPlayableVideo', () async {
|
||||
var data = {
|
||||
'9bZkp7q19f0',
|
||||
'SkRSXFQerZs',
|
||||
'hySoCSoH-g8',
|
||||
'_kmeFXjjGfk',
|
||||
'MeJVWBSsPAY',
|
||||
'5VGm0dczmHc',
|
||||
'ZGdLIwrGHG8',
|
||||
'rsAAeyAr-9Y',
|
||||
};
|
||||
for (var videoId in data) {
|
||||
var manifest =
|
||||
await yt.videos.streamsClient.getManifest(VideoId(videoId));
|
||||
for (var streamInfo in manifest.streams) {
|
||||
var stream = await yt.videos.streamsClient.get(streamInfo).toList();
|
||||
expect(stream, isNotEmpty);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
import 'package:test/test.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
test('Parse valid video id', () {
|
||||
var id = 'en2D_5TzXCA';
|
||||
expect(YoutubeExplode.parseVideoId(id), equals('en2D_5TzXCA'));
|
||||
});
|
||||
|
||||
test('Parse id from youtube url', () {
|
||||
var url = 'https://www.youtube.com/watch?v=en2D_5TzXCA';
|
||||
expect(YoutubeExplode.parseVideoId(url), equals('en2D_5TzXCA'));
|
||||
});
|
||||
|
||||
test('Get video title', () async {
|
||||
var yt = YoutubeExplode();
|
||||
var video = await yt.getVideo('en2D_5TzXCA');
|
||||
expect(video.title, equals('Lady Gaga - Million Reasons'));
|
||||
yt.close();
|
||||
});
|
||||
|
||||
test('Parse invalid id', () {
|
||||
var id = 'aaa';
|
||||
expect(YoutubeExplode.parseVideoId(id), isNull);
|
||||
});
|
||||
|
||||
test('Get video media stream', () async {
|
||||
var yt = YoutubeExplode();
|
||||
expect(await yt.getVideoMediaStream('en2D_5TzXCA'), isNotNull);
|
||||
yt.close();
|
||||
});
|
||||
|
||||
test('Get video media stream with invalid id', () async {
|
||||
var yt = YoutubeExplode();
|
||||
try {
|
||||
await yt.getVideoMediaStream('aaa');
|
||||
neverCalled();
|
||||
// ignore: avoid_catches_without_on_clauses
|
||||
} catch (e) {
|
||||
expect(e, isArgumentError);
|
||||
} finally {
|
||||
yt.close();
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Implement more tests
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:test/test.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
group('Username', () {
|
||||
test('ValidUsername', () {
|
||||
var data = const {'TheTyrrr', 'KannibalenRecords', 'JClayton1994'};
|
||||
// ignore: avoid_function_literals_in_foreach_calls
|
||||
data.forEach((usernameStr) {
|
||||
var username = Username(usernameStr);
|
||||
expect(username.value, usernameStr);
|
||||
});
|
||||
});
|
||||
test('ValidUsernameUrl', () {
|
||||
var data = const {
|
||||
'youtube.com/user/ProZD': 'ProZD',
|
||||
'youtube.com/user/TheTyrrr': 'TheTyrrr',
|
||||
};
|
||||
data.forEach((url, usernameStr) {
|
||||
var username = Username(url);
|
||||
expect(username.value, usernameStr);
|
||||
});
|
||||
});
|
||||
test('InvalidUsername', () {
|
||||
expect(() => Username('The_Tyrrr'), throwsArgumentError);
|
||||
expect(() => Username('0123456789ABCDEFGHIJK'), throwsArgumentError);
|
||||
expect(() => Username('A1B2C3-'), throwsArgumentError);
|
||||
expect(() => Username('=0123456789ABCDEF'), throwsArgumentError);
|
||||
});
|
||||
test('InvalidUsernameUrl', () {
|
||||
expect(() => Username('youtube.com/user/P_roZD'), throwsArgumentError);
|
||||
expect(() => Username('youtube.com/user/P_roZD'), throwsArgumentError);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import 'package:test/test.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
group('VideoId', () {
|
||||
test('ValidVideoId', () {
|
||||
var data = const {
|
||||
'9bZkp7q19f0',
|
||||
'_kmeFXjjGfk',
|
||||
'AI7ULzgf8RU',
|
||||
};
|
||||
// ignore: avoid_function_literals_in_foreach_calls
|
||||
data.forEach((videoId) {
|
||||
var video = VideoId(videoId);
|
||||
expect(video.value, videoId);
|
||||
});
|
||||
});
|
||||
test('ValidVideoUrl', () {
|
||||
var data = const {
|
||||
'youtube.com/watch?v=yIVRs6YSbOM': 'yIVRs6YSbOM',
|
||||
'youtu.be/yIVRs6YSbOM': 'yIVRs6YSbOM',
|
||||
'youtube.com/embed/yIVRs6YSbOM': 'yIVRs6YSbOM',
|
||||
};
|
||||
data.forEach((url, videoId) {
|
||||
var video = VideoId(url);
|
||||
expect(video.value, videoId);
|
||||
});
|
||||
});
|
||||
test('InvalidVideoId', () {
|
||||
expect(() => VideoId(''), throwsArgumentError);
|
||||
expect(() => VideoId('pI2I2zqzeK'), throwsArgumentError);
|
||||
expect(() => VideoId('pI2I2z zeKg'), throwsArgumentError);
|
||||
});
|
||||
test('InvalidVideoUrl', () {
|
||||
expect(() => VideoId('youtube.com/xxx?v=pI2I2zqzeKg'),
|
||||
throwsArgumentError);
|
||||
expect(() => VideoId('youtu.be/watch?v=xxx'), throwsArgumentError);
|
||||
expect(() => VideoId('youtube.com/embed/'), throwsArgumentError);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import 'package:test/test.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
void main() {
|
||||
group('Video', () {
|
||||
YoutubeExplode yt;
|
||||
setUp(() {
|
||||
yt = YoutubeExplode();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
yt.close();
|
||||
});
|
||||
|
||||
test('GetMetadataOfVideo', () async {
|
||||
var videoUrl = 'https://www.youtube.com/watch?v=AI7ULzgf8RU';
|
||||
var video = await yt.videos.get(VideoId(videoUrl));
|
||||
expect(video.id.value, 'AI7ULzgf8RU');
|
||||
expect(video.url, videoUrl);
|
||||
expect(video.title, 'Aka no Ha [Another] +HDHR');
|
||||
expect(video.author, 'Tyrrrz');
|
||||
expect(video.uploadDate, DateTime(2017, 09, 30));
|
||||
expect(video.description, contains('246pp'));
|
||||
expect(video.duration, const Duration(minutes: 1, seconds: 48));
|
||||
expect(video.thumbnails.lowResUrl, isNotNull);
|
||||
expect(video.thumbnails.mediumResUrl, isNotNull);
|
||||
expect(video.thumbnails.highResUrl, isNotNull);
|
||||
expect(video.thumbnails.standardResUrl, isNotNull);
|
||||
expect(video.thumbnails.maxResUrl, isNotNull);
|
||||
expect(video.keywords, orderedEquals(["osu", "mouse", "rhythm game"]));
|
||||
expect(video.engagement.viewCount, greaterThanOrEqualTo(134));
|
||||
expect(video.engagement.likeCount, greaterThanOrEqualTo(5));
|
||||
expect(video.engagement.dislikeCount, greaterThanOrEqualTo(0));
|
||||
});
|
||||
|
||||
test('GetMetadataOfAnyVideo', () async {
|
||||
var data = {
|
||||
'9bZkp7q19f0',
|
||||
'SkRSXFQerZs',
|
||||
'5VGm0dczmHc',
|
||||
'ZGdLIwrGHG8',
|
||||
'5qap5aO4i9A'
|
||||
};
|
||||
for (var videoId in data) {
|
||||
var video = await yt.videos.get(VideoId(videoId));
|
||||
expect(video.id.value, videoId);
|
||||
}
|
||||
});
|
||||
|
||||
test('GetMetadataOfInvalidVideo', () async {
|
||||
expect(() async => await yt.videos.get(VideoId('qld9w0b-1ao')),
|
||||
throwsA(const TypeMatcher<VideoUnplayableException>()));
|
||||
expect(() async => await yt.videos.get(VideoId('pb_hHv3fByo')),
|
||||
throwsA(const TypeMatcher<VideoUnplayableException>()));
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import 'package:youtube_explode_dart/src/channels/channels.dart';
|
||||
|
||||
void main() {
|
||||
var u = Username('youtube.com/user/ProZD');
|
||||
}
|
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue