Update for v5

This commit is contained in:
Hexah 2020-06-05 16:17:08 +02:00
parent c7bbbf0d24
commit 407ac50f22
50 changed files with 754 additions and 261 deletions

View File

@ -14,6 +14,7 @@ linter:
- prefer_constructors_over_static_methods
- prefer_contains
- annotate_overrides
- await_futures
analyzer:
exclude:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,7 +37,7 @@ class PlaylistClient {
var videoId = video.id;
// Already added
if (encounteredVideoIds.add(videoId)) {
if (!encounteredVideoIds.add(videoId)) {
continue;
}

View File

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

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -50,6 +50,5 @@ class FileSize extends Comparable<FileSize> with EquatableMixin {
String toString() => '${_getLargestValue()} ${_getLargestSymbol()}';
@override
// TODO: implement props
List<Object> get props => [totalBytes];
}

View File

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

View File

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

View File

@ -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) =>

View File

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

View File

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

View File

@ -1,7 +1,5 @@
import 'package:equatable/equatable.dart';
/// Width and height of a video.
class VideoResolution {
class VideoResolution {
/// Viewport width.
final int width;

View File

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

View File

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

View File

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

View File

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

35
test/channel_id_test.dart Normal file
View File

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

74
test/channel_test.dart Normal file
View File

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

View File

@ -0,0 +1 @@
//TODO: Implement this

View File

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

83
test/playlist_test.dart Normal file
View File

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

1
test/search_test.dart Normal file
View File

@ -0,0 +1 @@
//TODO: Implement this

70
test/streams_test.dart Normal file
View File

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

View File

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

35
test/user_name_test.dart Normal file
View File

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

41
test/video_id_test.dart Normal file
View File

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

57
test/video_test.dart Normal file
View File

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

5
tools/t2.dart Normal file
View File

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