Progress on v5
This commit is contained in:
parent
911712cfa1
commit
0cfdb5e575
|
@ -69,4 +69,8 @@
|
||||||
|
|
||||||
## 0.0.16
|
## 0.0.16
|
||||||
|
|
||||||
- When a video is not available(403) a `VideoStreamUnavailableException`
|
- When a video is not available(403) a `VideoStreamUnavailableException`
|
||||||
|
|
||||||
|
## 0.0.17
|
||||||
|
|
||||||
|
- Fixed bug in #23
|
|
@ -0,0 +1,22 @@
|
||||||
|
import 'channel_id.dart';
|
||||||
|
|
||||||
|
/// YouTube channel metadata.
|
||||||
|
class Channel {
|
||||||
|
/// Channel ID.
|
||||||
|
final ChannelId id;
|
||||||
|
|
||||||
|
/// Channel URL.
|
||||||
|
String get url => 'https://www.youtube.com/channel/$id';
|
||||||
|
|
||||||
|
/// Channel title.
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// URL of the channel's logo image.
|
||||||
|
final String logoUrl;
|
||||||
|
|
||||||
|
/// Initializes an instance of [Channel]
|
||||||
|
Channel(this.id, this.title, this.logoUrl);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'Channel ($title)';
|
||||||
|
}
|
|
@ -1,3 +1,46 @@
|
||||||
|
import '../reverse_engineering/reverse_engineering.dart';
|
||||||
|
import '../videos/video_id.dart';
|
||||||
|
import 'channel.dart';
|
||||||
|
import 'channel_id.dart';
|
||||||
|
import 'username.dart';
|
||||||
|
import '../extensions/helpers_extension.dart';
|
||||||
|
|
||||||
|
/// Queries related to YouTube channels.
|
||||||
class ChannelClient {
|
class ChannelClient {
|
||||||
ChannelClient._();
|
final YoutubeHttpClient _httpClient;
|
||||||
}
|
|
||||||
|
/// Initializes an instance of [ChannelClient]
|
||||||
|
ChannelClient(this._httpClient);
|
||||||
|
|
||||||
|
/// Gets the metadata associated with the specified channel.
|
||||||
|
Future<Channel> get(ChannelId id) async {
|
||||||
|
var channelPage = await ChannelPage.get(_httpClient, id.value);
|
||||||
|
|
||||||
|
return Channel(id, channelPage.channelTitle, channelPage.channelLogoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the metadata associated with the channel of the specified user.
|
||||||
|
Future<Channel> getByUsername(Username username) async {
|
||||||
|
var channelPage =
|
||||||
|
await ChannelPage.getByUsername(_httpClient, username.value);
|
||||||
|
return Channel(ChannelId(channelPage.channelId), channelPage.channelTitle,
|
||||||
|
channelPage.channelLogoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the metadata associated with the channel
|
||||||
|
/// that uploaded the specified video.
|
||||||
|
Future<Channel> getByVideo(VideoId videoId) async {
|
||||||
|
var videoInfoResponse =
|
||||||
|
await VideoInfoResponse.get(_httpClient, videoId.value);
|
||||||
|
var playerReponse = videoInfoResponse.playerResponse;
|
||||||
|
|
||||||
|
var channelId = playerReponse.videoChannelId;
|
||||||
|
return await get(ChannelId(channelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enumerates videos uploaded by the specified channel.
|
||||||
|
void getUploads(ChannelId id) async {
|
||||||
|
var playlist = 'UU${id.value.substringAfter('UC')}';
|
||||||
|
//TODO: Finish this after playlist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../extensions/helpers_extension.dart';
|
||||||
|
|
||||||
|
/// Encapsulates a valid YouTube channel ID.
|
||||||
|
class ChannelId extends Equatable {
|
||||||
|
/// ID as a string.
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
/// Initializes an instance of [ChannelId]
|
||||||
|
ChannelId(String value)
|
||||||
|
: value = parseChannelId(value) ??
|
||||||
|
ArgumentError.value(value, 'value', 'Invalid channel id.');
|
||||||
|
|
||||||
|
static bool validateChannelId(String id) {
|
||||||
|
if (id.isNullOrWhiteSpace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id.startsWith('UC')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id.length != 24) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !RegExp('[^0-9a-zA-Z_\-]').hasMatch(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a channel id from an url.
|
||||||
|
/// Returns null if the username is not found.
|
||||||
|
static String parseChannelId(String url) {
|
||||||
|
if (url.isNullOrWhiteSpace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validateChannelId(url)) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
var regMatch = RegExp(r'youtube\..+?/channel/(.*?)(?:\?|&|/|$)')
|
||||||
|
.firstMatch(url)
|
||||||
|
?.group(1);
|
||||||
|
if (!regMatch.isNullOrWhiteSpace && validateChannelId(regMatch)) {
|
||||||
|
return regMatch;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [value];
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
import '../extensions/helpers_extension.dart';
|
||||||
|
|
||||||
|
/// Encapsulates a valid YouTube user name.
|
||||||
|
class Username {
|
||||||
|
/// User name as string.
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
/// Initializes an instance of [Username].
|
||||||
|
Username(String urlOrUsername)
|
||||||
|
: value = parseUsername(urlOrUsername) ??
|
||||||
|
ArgumentError.value(
|
||||||
|
urlOrUsername, 'urlOrUsername', 'Invalid username');
|
||||||
|
|
||||||
|
static bool validateUsername(String name) {
|
||||||
|
if (!name.isNullOrWhiteSpace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length > 20) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RegExp('[^0-9a-zA-Z]').hasMatch(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String parseUsername(String nameOrUrl) {
|
||||||
|
if (nameOrUrl.isNullOrWhiteSpace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validateUsername(nameOrUrl)) {
|
||||||
|
return nameOrUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
var regMatch = RegExp(r'youtube\..+?/user/(.*?)(?:\?|&|/|$)')
|
||||||
|
.firstMatch(nameOrUrl)
|
||||||
|
?.group(1);
|
||||||
|
if (regMatch.isNullOrWhiteSpace && validateUsername(regMatch)) {
|
||||||
|
return regMatch;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,24 +1,18 @@
|
||||||
import '../models/models.dart';
|
import '../videos/video_id.dart';
|
||||||
|
|
||||||
import 'video_unplayable_exception.dart';
|
import 'video_unplayable_exception.dart';
|
||||||
|
|
||||||
/// Exception thrown when the requested video requires purchase.
|
/// Exception thrown when the requested video requires purchase.
|
||||||
class VideoRequiresPurchaseException implements VideoUnplayableException {
|
class VideoRequiresPurchaseException implements VideoUnplayableException {
|
||||||
/// Description message
|
/// Description message
|
||||||
|
@override
|
||||||
final String message;
|
final String message;
|
||||||
|
|
||||||
/// VideoId instance
|
/// VideoId instance
|
||||||
final VideoId previewVideoId;
|
final VideoId previewVideoId;
|
||||||
|
|
||||||
/// Initializes an instance of [VideoRequiresPurchaseException]
|
/// Initializes an instance of [VideoRequiresPurchaseException].
|
||||||
VideoRequiresPurchaseException(this.message, this.previewVideoId);
|
VideoRequiresPurchaseException.preview(VideoId videoId, this.previewVideoId)
|
||||||
|
: message = 'Video `$videoId` is unplayable because it requires purchase.'
|
||||||
/// Initializes an instance of [VideoUnplayableException] with a [VideoId]
|
'Streams are not available for this video.'
|
||||||
VideoRequiresPurchaseException.unavailable(this.previewVideoId)
|
'There is a preview video available: `$previewVideoId`.';
|
||||||
: message = 'Video \'$previewVideoId\' is unavailable.\n'
|
|
||||||
'In most cases, this error indicates that the video doesn\'t exist, ' // ignore: lines_longer_than_80_chars
|
|
||||||
'is private, or has been taken down.\n'
|
|
||||||
'If you can however open this video in your browser in incognito mode, ' // ignore: lines_longer_than_80_chars
|
|
||||||
'it most likely means that YouTube changed something, which broke this library.\n' // ignore: lines_longer_than_80_chars
|
|
||||||
'Please report this issue on GitHub in that case.';
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import '../models/models.dart';
|
import '../videos/video_id.dart';
|
||||||
import 'youtube_explode_exception.dart';
|
import 'youtube_explode_exception.dart';
|
||||||
|
|
||||||
/// Exception thrown when the requested video is unplayable.
|
/// Exception thrown when the requested video is unplayable.
|
||||||
|
|
|
@ -2,6 +2,9 @@ import '../reverse_engineering/cipher/cipher_operations.dart';
|
||||||
|
|
||||||
/// Utility for Strings.
|
/// Utility for Strings.
|
||||||
extension StringUtility on String {
|
extension StringUtility on String {
|
||||||
|
/// Returns null if this string is whitespace.
|
||||||
|
String get nullIfWhitespace => trim().isEmpty ? null : this;
|
||||||
|
|
||||||
/// Returns true if the string is null or empty.
|
/// Returns true if the string is null or empty.
|
||||||
bool get isNullOrWhiteSpace {
|
bool get isNullOrWhiteSpace {
|
||||||
if (this == null) {
|
if (this == null) {
|
||||||
|
@ -13,14 +16,21 @@ extension StringUtility on String {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns null if this string is a whitespace.
|
||||||
|
String substringUntil(String separator) => substring(0, indexOf(separator));
|
||||||
|
|
||||||
|
///
|
||||||
|
String substringAfter(String separator) =>
|
||||||
|
substring(indexOf(separator) + length);
|
||||||
|
|
||||||
static final _exp = RegExp(r'\D');
|
static final _exp = RegExp(r'\D');
|
||||||
|
|
||||||
/// Strips out all non digit characters.
|
/// Strips out all non digit characters.
|
||||||
String get stripNonDigits => replaceAll(_exp, '');
|
String stripNonDigits() => replaceAll(_exp, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List decipher utility.
|
/// List decipher utility.
|
||||||
extension ListDecipher on List<CipherOperation> {
|
extension ListDecipher on Iterable<CipherOperation> {
|
||||||
/// Apply every CipherOperation on the [signature]
|
/// Apply every CipherOperation on the [signature]
|
||||||
String decipher(String signature) {
|
String decipher(String signature) {
|
||||||
for (var operation in this) {
|
for (var operation in this) {
|
||||||
|
@ -41,3 +51,15 @@ extension ListFirst<E> on List<E> {
|
||||||
return first;
|
return first;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Uri utility
|
||||||
|
extension UriUtility on Uri {
|
||||||
|
/// Returns a new Uri with the new query parameters set.
|
||||||
|
Uri setQueryParam(String key, String value) {
|
||||||
|
var query = Map<String, String>.from(queryParameters);
|
||||||
|
|
||||||
|
query[key] = value;
|
||||||
|
|
||||||
|
return replace(queryParameters: query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
import '../extensions/extensions.dart';
|
import '../extensions/extensions.dart';
|
||||||
|
|
||||||
|
/// Encapsulates a valid YouTube video ID.
|
||||||
class VideoId extends Equatable {
|
class VideoId extends Equatable {
|
||||||
static final _regMatchExp = RegExp(r'youtube\..+?/watch.*?v=(.*?)(?:&|/|$)');
|
static final _regMatchExp = RegExp(r'youtube\..+?/watch.*?v=(.*?)(?:&|/|$)');
|
||||||
static final _shortMatchExp = RegExp(r'youtu\.be/(.*?)(?:\?|&|/|$)');
|
static final _shortMatchExp = RegExp(r'youtu\.be/(.*?)(?:\?|&|/|$)');
|
||||||
|
@ -15,6 +16,7 @@ class VideoId extends Equatable {
|
||||||
: value = parseVideoId(url) ??
|
: value = parseVideoId(url) ??
|
||||||
ArgumentError('Invalid YouTube video ID or URL: $url.');
|
ArgumentError('Invalid YouTube video ID or URL: $url.');
|
||||||
|
|
||||||
|
@override
|
||||||
String toString() => value;
|
String toString() => value;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,196 @@
|
||||||
|
import '../extensions/helpers_extension.dart';
|
||||||
|
import '../videos/streams/video_quality.dart';
|
||||||
|
import '../videos/streams/video_resolution.dart';
|
||||||
|
|
||||||
|
const _qualityMap = <int, VideoQuality>{
|
||||||
|
5: VideoQuality.low144,
|
||||||
|
6: VideoQuality.low240,
|
||||||
|
13: VideoQuality.low144,
|
||||||
|
17: VideoQuality.low144,
|
||||||
|
18: VideoQuality.medium360,
|
||||||
|
22: VideoQuality.high720,
|
||||||
|
34: VideoQuality.medium360,
|
||||||
|
35: VideoQuality.medium480,
|
||||||
|
36: VideoQuality.low240,
|
||||||
|
37: VideoQuality.high1080,
|
||||||
|
38: VideoQuality.high3072,
|
||||||
|
43: VideoQuality.medium360,
|
||||||
|
44: VideoQuality.medium480,
|
||||||
|
45: VideoQuality.high720,
|
||||||
|
46: VideoQuality.high1080,
|
||||||
|
59: VideoQuality.medium480,
|
||||||
|
78: VideoQuality.medium480,
|
||||||
|
82: VideoQuality.medium360,
|
||||||
|
83: VideoQuality.medium480,
|
||||||
|
84: VideoQuality.high720,
|
||||||
|
85: VideoQuality.high1080,
|
||||||
|
91: VideoQuality.low144,
|
||||||
|
92: VideoQuality.low240,
|
||||||
|
93: VideoQuality.medium360,
|
||||||
|
94: VideoQuality.medium480,
|
||||||
|
95: VideoQuality.high720,
|
||||||
|
96: VideoQuality.high1080,
|
||||||
|
100: VideoQuality.medium360,
|
||||||
|
101: VideoQuality.medium480,
|
||||||
|
102: VideoQuality.high720,
|
||||||
|
132: VideoQuality.low240,
|
||||||
|
151: VideoQuality.low144,
|
||||||
|
133: VideoQuality.low240,
|
||||||
|
134: VideoQuality.medium360,
|
||||||
|
135: VideoQuality.medium480,
|
||||||
|
136: VideoQuality.high720,
|
||||||
|
137: VideoQuality.high1080,
|
||||||
|
138: VideoQuality.high4320,
|
||||||
|
160: VideoQuality.low144,
|
||||||
|
212: VideoQuality.medium480,
|
||||||
|
213: VideoQuality.medium480,
|
||||||
|
214: VideoQuality.high720,
|
||||||
|
215: VideoQuality.high720,
|
||||||
|
216: VideoQuality.high1080,
|
||||||
|
217: VideoQuality.high1080,
|
||||||
|
264: VideoQuality.high1440,
|
||||||
|
266: VideoQuality.high2160,
|
||||||
|
298: VideoQuality.high720,
|
||||||
|
299: VideoQuality.high1080,
|
||||||
|
399: VideoQuality.high1080,
|
||||||
|
398: VideoQuality.high720,
|
||||||
|
397: VideoQuality.medium480,
|
||||||
|
396: VideoQuality.medium360,
|
||||||
|
395: VideoQuality.low240,
|
||||||
|
394: VideoQuality.low144,
|
||||||
|
167: VideoQuality.medium360,
|
||||||
|
168: VideoQuality.medium480,
|
||||||
|
169: VideoQuality.high720,
|
||||||
|
170: VideoQuality.high1080,
|
||||||
|
218: VideoQuality.medium480,
|
||||||
|
219: VideoQuality.medium480,
|
||||||
|
242: VideoQuality.low240,
|
||||||
|
243: VideoQuality.medium360,
|
||||||
|
244: VideoQuality.medium480,
|
||||||
|
245: VideoQuality.medium480,
|
||||||
|
246: VideoQuality.medium480,
|
||||||
|
247: VideoQuality.high720,
|
||||||
|
248: VideoQuality.high1080,
|
||||||
|
271: VideoQuality.high1440,
|
||||||
|
272: VideoQuality.high2160,
|
||||||
|
278: VideoQuality.low144,
|
||||||
|
302: VideoQuality.high720,
|
||||||
|
303: VideoQuality.high1080,
|
||||||
|
308: VideoQuality.high1440,
|
||||||
|
313: VideoQuality.high2160,
|
||||||
|
315: VideoQuality.high2160,
|
||||||
|
330: VideoQuality.low144,
|
||||||
|
331: VideoQuality.low240,
|
||||||
|
332: VideoQuality.medium360,
|
||||||
|
333: VideoQuality.medium480,
|
||||||
|
334: VideoQuality.high720,
|
||||||
|
335: VideoQuality.high1080,
|
||||||
|
336: VideoQuality.high1440,
|
||||||
|
337: VideoQuality.high2160,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _resolutionMap = <VideoQuality, VideoResolution>{
|
||||||
|
VideoQuality.low144: VideoResolution(256, 144),
|
||||||
|
VideoQuality.low240: VideoResolution(426, 240),
|
||||||
|
VideoQuality.medium360: VideoResolution(640, 360),
|
||||||
|
VideoQuality.medium480: VideoResolution(854, 480),
|
||||||
|
VideoQuality.high720: VideoResolution(1280, 720),
|
||||||
|
VideoQuality.high1080: VideoResolution(1920, 1080),
|
||||||
|
VideoQuality.high1440: VideoResolution(2560, 1440),
|
||||||
|
VideoQuality.high2160: VideoResolution(3840, 2160),
|
||||||
|
VideoQuality.high2880: VideoResolution(5120, 2880),
|
||||||
|
VideoQuality.high3072: VideoResolution(4096, 3072),
|
||||||
|
VideoQuality.high4320: VideoResolution(7680, 4320),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Utilities for [VideoQuality]
|
||||||
|
extension VideoQualityUtil on VideoQuality {
|
||||||
|
/// Parses the itag as [VideoQuality]
|
||||||
|
/// Throws an [ArgumentError] if the itag matches no video quality.
|
||||||
|
static VideoQuality fromTag(int itag) {
|
||||||
|
var q = _qualityMap[itag];
|
||||||
|
if (q == null) {
|
||||||
|
throw ArgumentError.value(itag, 'itag', 'Unrecognized itag');
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the label as [VideoQuality]
|
||||||
|
/// Throws an [ArgumentError] if the string matches no video quality.
|
||||||
|
static VideoQuality fromLabel(String label) {
|
||||||
|
label = label.toLowerCase();
|
||||||
|
|
||||||
|
if (label.startsWith('144')) {
|
||||||
|
return VideoQuality.low144;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (label.startsWith('240')) {
|
||||||
|
return VideoQuality.low144;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (label.startsWith('360')) {
|
||||||
|
return VideoQuality.medium360;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (label.startsWith('480')) {
|
||||||
|
return VideoQuality.medium480;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (label.startsWith('720')) {
|
||||||
|
return VideoQuality.high720;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (label.startsWith('1080')) {
|
||||||
|
return VideoQuality.high1080;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (label.startsWith('1440')) {
|
||||||
|
return VideoQuality.high1440;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (label.startsWith('2160')) {
|
||||||
|
return VideoQuality.high2160;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (label.startsWith('2880')) {
|
||||||
|
return VideoQuality.high2880;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (label.startsWith('3072')) {
|
||||||
|
return VideoQuality.high3072;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (label.startsWith('4320')) {
|
||||||
|
return VideoQuality.high4320;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ArgumentError.value(
|
||||||
|
label, 'label', 'Unrecognized video quality label');
|
||||||
|
}
|
||||||
|
|
||||||
|
String getLabel() => '${toString().stripNonDigits()}p';
|
||||||
|
|
||||||
|
String getLabelWithFramerate(double framerate) {
|
||||||
|
// Framerate appears only if it's above 30
|
||||||
|
if (framerate <= 30) {
|
||||||
|
return getLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
var framerateRounded = (framerate / 10).ceil() * 10;
|
||||||
|
return '${getLabel}$framerateRounded';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String getLabelFromTagWithFramerate(int itag, double framerate) {
|
||||||
|
var videoQuality = fromTag(itag);
|
||||||
|
return getLabelWithFramerate(framerate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a [VideoResolution] from its [VideoQuality]
|
||||||
|
VideoResolution toVideoResolution() {
|
||||||
|
var r = _resolutionMap[this];
|
||||||
|
if (r == null) {
|
||||||
|
throw ArgumentError.value(this, 'quality', 'Unrecognized video quality');
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,8 @@
|
||||||
import 'package:http_parser/http_parser.dart';
|
|
||||||
import 'package:xml/xml.dart' as xml;
|
import 'package:xml/xml.dart' as xml;
|
||||||
import 'package:youtube_explode_dart/src/retry.dart';
|
|
||||||
import 'package:youtube_explode_dart/src/reverse_engineering/responses/stream_info_provider.dart';
|
import '../../retry.dart';
|
||||||
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
|
import '../reverse_engineering.dart';
|
||||||
|
import 'stream_info_provider.dart';
|
||||||
|
|
||||||
class DashManifest {
|
class DashManifest {
|
||||||
static final _urlSignatureExp = RegExp(r'/s/(.*?)(?:/|$)');
|
static final _urlSignatureExp = RegExp(r'/s/(.*?)(?:/|$)');
|
||||||
|
@ -22,15 +22,15 @@ class DashManifest {
|
||||||
|
|
||||||
DashManifest.parse(String raw) : _root = xml.parse(raw);
|
DashManifest.parse(String raw) : _root = xml.parse(raw);
|
||||||
|
|
||||||
Future<DashManifest> get(YoutubeHttpClient httpClient, String url) {
|
static Future<DashManifest> get(YoutubeHttpClient httpClient, dynamic url) {
|
||||||
retry(() async {
|
retry(() async {
|
||||||
var raw = await httpClient.getString(url);
|
var raw = await httpClient.getString(url);
|
||||||
return DashManifest.parse(raw);
|
return DashManifest.parse(raw);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
String getSignatureFromUrl(String url) =>
|
static String getSignatureFromUrl(String url) =>
|
||||||
_urlSignatureExp.firstMatch(url).group(1);
|
_urlSignatureExp.firstMatch(url)?.group(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StreamInfo extends StreamInfoProvider {
|
class _StreamInfo extends StreamInfoProvider {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'package:html/dom.dart';
|
||||||
import 'package:html/parser.dart' as parser;
|
import 'package:html/parser.dart' as parser;
|
||||||
import 'package:youtube_explode_dart/src/retry.dart';
|
import 'package:youtube_explode_dart/src/retry.dart';
|
||||||
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
|
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
|
||||||
|
|
||||||
import '../../extensions/extensions.dart';
|
import '../../extensions/extensions.dart';
|
||||||
|
|
||||||
class EmbedPage {
|
class EmbedPage {
|
||||||
|
@ -14,17 +15,23 @@ class EmbedPage {
|
||||||
|
|
||||||
EmbedPage(this._root);
|
EmbedPage(this._root);
|
||||||
|
|
||||||
_PlayerConfig get playerconfig => _PlayerConfig(json.decode(_playerConfigJson));
|
_PlayerConfig get playerconfig {
|
||||||
|
var playerConfigJson = _playerConfigJson;
|
||||||
|
if (playerConfigJson == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _PlayerConfig(json.decode(playerConfigJson));
|
||||||
|
}
|
||||||
|
|
||||||
String get _playerConfigJson => _root
|
String get _playerConfigJson => _root
|
||||||
.getElementsByTagName('script')
|
.getElementsByTagName('script')
|
||||||
.map((e) => e.text)
|
.map((e) => e.text)
|
||||||
.map((e) => _playerConfigExp.firstMatch(e).group(1))
|
.map((e) => _playerConfigExp.firstMatch(e).group(1))
|
||||||
.firstWhere((e) => !e.isNullOrWhiteSpace);
|
.firstWhere((e) => !e.isNullOrWhiteSpace, orElse: () => null);
|
||||||
|
|
||||||
EmbedPage.parse(String raw) : _root = parser.parse(raw);
|
EmbedPage.parse(String raw) : _root = parser.parse(raw);
|
||||||
|
|
||||||
Future<EmbedPage> get(YoutubeHttpClient httpClient, String videoId) {
|
static Future<EmbedPage> get(YoutubeHttpClient httpClient, String videoId) {
|
||||||
var url = 'https://youtube.com/embed/$videoId?hl=en';
|
var url = 'https://youtube.com/embed/$videoId?hl=en';
|
||||||
return retry(() async {
|
return retry(() async {
|
||||||
var raw = await httpClient.getString(url);
|
var raw = await httpClient.getString(url);
|
||||||
|
|
|
@ -11,10 +11,6 @@ class PlayerResponse {
|
||||||
|
|
||||||
String get playabilityStatus => _root['playabilityStatus']['status'];
|
String get playabilityStatus => _root['playabilityStatus']['status'];
|
||||||
|
|
||||||
// Can be null
|
|
||||||
String get getVideoPlayabilityError =>
|
|
||||||
_root.get('playabilityStatus')?.get('reason');
|
|
||||||
|
|
||||||
bool get isVideoAvailable => playabilityStatus != 'error';
|
bool get isVideoAvailable => playabilityStatus != 'error';
|
||||||
|
|
||||||
bool get isVideoPlayable => playabilityStatus == 'ok';
|
bool get isVideoPlayable => playabilityStatus == 'ok';
|
||||||
|
@ -57,6 +53,10 @@ class PlayerResponse {
|
||||||
|
|
||||||
bool get isLive => _root['videoDetails'].get('isLive') ?? false;
|
bool get isLive => _root['videoDetails'].get('isLive') ?? false;
|
||||||
|
|
||||||
|
// Can be null
|
||||||
|
String get hlsManifestUrl =>
|
||||||
|
_root.get('streamingData')?.get('hlsManifestUrl');
|
||||||
|
|
||||||
// Can be null
|
// Can be null
|
||||||
String get dashManifestUrl =>
|
String get dashManifestUrl =>
|
||||||
_root.get('streamingData')?.get('dashManifestUrl');
|
_root.get('streamingData')?.get('dashManifestUrl');
|
||||||
|
@ -83,6 +83,9 @@ class PlayerResponse {
|
||||||
?.map((e) => ClosedCaptionTrack(e)) ??
|
?.map((e) => ClosedCaptionTrack(e)) ??
|
||||||
const [];
|
const [];
|
||||||
|
|
||||||
|
String getVideoPlayabilityError() =>
|
||||||
|
_root.get('playabilityStatus')?.get('reason');
|
||||||
|
|
||||||
PlayerResponse.parse(String raw) : _root = json.decode(raw);
|
PlayerResponse.parse(String raw) : _root = json.decode(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -93,7 +93,7 @@ class PlayerSource {
|
||||||
// Same as default constructor
|
// Same as default constructor
|
||||||
PlayerSource.parse(this._root);
|
PlayerSource.parse(this._root);
|
||||||
|
|
||||||
Future<PlayerSource> get(YoutubeHttpClient httpClient, String url) {
|
static Future<PlayerSource> get(YoutubeHttpClient httpClient, String url) {
|
||||||
return retry(() async {
|
return retry(() async {
|
||||||
var raw = await httpClient.getString(url);
|
var raw = await httpClient.getString(url);
|
||||||
return PlayerSource.parse(raw);
|
return PlayerSource.parse(raw);
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
export 'channel_page.dart';
|
||||||
|
export 'closed_caption_track_response.dart';
|
||||||
|
export 'dash_manifest.dart';
|
||||||
|
export 'embed_page.dart';
|
||||||
|
export 'player_response.dart';
|
||||||
|
export 'player_source.dart';
|
||||||
|
export 'playerlist_response.dart';
|
||||||
|
export 'stream_info_provider.dart';
|
||||||
|
export 'video_info_response.dart';
|
||||||
|
export 'watch_page.dart';
|
|
@ -1,6 +1,9 @@
|
||||||
import 'package:http_parser/http_parser.dart';
|
import 'package:http_parser/http_parser.dart';
|
||||||
|
import 'package:youtube_explode_dart/src/exceptions/exceptions.dart';
|
||||||
|
import 'package:youtube_explode_dart/src/retry.dart';
|
||||||
import 'package:youtube_explode_dart/src/reverse_engineering/responses/player_response.dart';
|
import 'package:youtube_explode_dart/src/reverse_engineering/responses/player_response.dart';
|
||||||
import 'package:youtube_explode_dart/src/reverse_engineering/responses/stream_info_provider.dart';
|
import 'package:youtube_explode_dart/src/reverse_engineering/responses/stream_info_provider.dart';
|
||||||
|
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
|
||||||
|
|
||||||
class VideoInfoResponse {
|
class VideoInfoResponse {
|
||||||
final Map<String, String> _root;
|
final Map<String, String> _root;
|
||||||
|
@ -29,6 +32,25 @@ class VideoInfoResponse {
|
||||||
const [];
|
const [];
|
||||||
|
|
||||||
Iterable<_StreamInfo> get streams => [...muxedStreams, ...adaptiveStreams];
|
Iterable<_StreamInfo> get streams => [...muxedStreams, ...adaptiveStreams];
|
||||||
|
|
||||||
|
VideoInfoResponse.parse(String raw) : _root = Uri.splitQueryString(raw);
|
||||||
|
|
||||||
|
static Future<VideoInfoResponse> get(
|
||||||
|
YoutubeHttpClient httpClient, String videoId,
|
||||||
|
[String sts]) {
|
||||||
|
var eurl = Uri.encodeFull('https://youtube.googleapis.com/v/$videoId');
|
||||||
|
var url =
|
||||||
|
'https://youtube.com/get_video_info?video_id=$videoId&el=embedded&eurl=$eurl&hl=en&sts=$sts';
|
||||||
|
return retry(() async {
|
||||||
|
var raw = await httpClient.getString(url);
|
||||||
|
var result = VideoInfoResponse.parse(raw);
|
||||||
|
|
||||||
|
if (!result.isVideoAvailable || !result.playerResponse.isVideoAvailable) {
|
||||||
|
throw VideoUnplayableException(videoId);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StreamInfo extends StreamInfoProvider {
|
class _StreamInfo extends StreamInfoProvider {
|
||||||
|
@ -72,7 +94,6 @@ class _StreamInfo extends StreamInfoProvider {
|
||||||
bool get isAudioOnly => mimeType.type == 'audio';
|
bool get isAudioOnly => mimeType.type == 'audio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// TODO: implement videoQualityLabel
|
|
||||||
String get videoQualityLabel => _root['quality_label'];
|
String get videoQualityLabel => _root['quality_label'];
|
||||||
|
|
||||||
List<int> get _size =>
|
List<int> get _size =>
|
||||||
|
|
|
@ -1,14 +1,23 @@
|
||||||
import 'package:html/dom.dart';
|
import 'dart:convert';
|
||||||
import 'package:youtube_explode_dart/src/reverse_engineering/responses/embed_page.dart';
|
|
||||||
|
|
||||||
class VideoPage {
|
import 'package:html/dom.dart';
|
||||||
|
import 'package:html/parser.dart' as parser;
|
||||||
|
import 'package:http_parser/http_parser.dart';
|
||||||
|
|
||||||
|
import '../../../youtube_explode_dart.dart';
|
||||||
|
import '../../extensions/helpers_extension.dart';
|
||||||
|
import '../../retry.dart';
|
||||||
|
import '../reverse_engineering.dart';
|
||||||
|
import 'player_response.dart';
|
||||||
|
|
||||||
|
class WatchPage {
|
||||||
final RegExp _videoLikeExp = RegExp(r'label""\s*:\s*""([\d,\.]+) likes');
|
final RegExp _videoLikeExp = RegExp(r'label""\s*:\s*""([\d,\.]+) likes');
|
||||||
final RegExp _videoDislikeExp =
|
final RegExp _videoDislikeExp =
|
||||||
RegExp(r'label""\s*:\s*""([\d,\.]+) dislikes');
|
RegExp(r'label""\s*:\s*""([\d,\.]+) dislikes');
|
||||||
|
|
||||||
final Document _root;
|
final Document _root;
|
||||||
|
|
||||||
VideoPage(this._root);
|
WatchPage(this._root);
|
||||||
|
|
||||||
bool get isOk => _root.body.querySelector('#player') != null;
|
bool get isOk => _root.body.querySelector('#player') != null;
|
||||||
|
|
||||||
|
@ -29,36 +38,135 @@ class VideoPage {
|
||||||
?.stripNonDigits() ??
|
?.stripNonDigits() ??
|
||||||
'');
|
'');
|
||||||
|
|
||||||
_PlayerConfig get playerConfig => _PlayerConfig.parse(_root.getElementsByTagName('script').map((e) => e.text).map((e) => _extractJson(e)).firstWhere((e) => e != null));
|
_PlayerConfig get playerConfig => _PlayerConfig(json.decode(_root
|
||||||
|
.getElementsByTagName('script')
|
||||||
|
.map((e) => e.text)
|
||||||
|
.map(_extractJson)
|
||||||
|
.firstWhere((e) => e != null)));
|
||||||
|
|
||||||
|
WatchPage.parse(String raw) : _root = parser.parse(raw);
|
||||||
|
|
||||||
|
static Future<WatchPage> get(YoutubeHttpClient httpClient, String videoId) {
|
||||||
|
final url = 'https://youtube.com/watch?v=$videoId&bpctr=9999999999&hl=en';
|
||||||
|
return retry(() async {
|
||||||
|
var raw = await httpClient.getString(url);
|
||||||
|
|
||||||
|
var result = WatchPage.parse(raw);
|
||||||
|
|
||||||
|
if (!result.isOk) {
|
||||||
|
throw TransientFailureException("Video watch page is broken.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.isVideoAvailable) {
|
||||||
|
throw VideoUnavailableException.unavailable(VideoId(videoId));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
String _extractJson(String str) {
|
String _extractJson(String str) {
|
||||||
var startIndex = str.indexOf('ytplayer.config =');
|
var startIndex = str.indexOf('ytplayer.config =');
|
||||||
var endIndex = str.indexOf(';ytplayer.load =');
|
var endIndex = str.indexOf(';ytplayer.load =');
|
||||||
if (startIndex == -1 || endIndex == -1)
|
if (startIndex == -1 || endIndex == -1) return null;
|
||||||
return null;
|
|
||||||
|
|
||||||
return str.substring(startIndex + 17, endIndex);
|
return str.substring(startIndex + 17, endIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlayerConfig {
|
class _StreamInfo extends StreamInfoProvider {
|
||||||
|
final Map<String, String> _root;
|
||||||
|
|
||||||
|
_StreamInfo(this._root);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get bitrate => int.parse(_root['bitrate']);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get tag => int.parse(_root['itag']);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get url => _root['url'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get signature => _root['s'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get signatureParameter => _root['sp'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get contentLength => int.tryParse(_root['clen'] ??
|
||||||
|
StreamInfoProvider.contentLenExp
|
||||||
|
.firstMatch(url)
|
||||||
|
.group(1)
|
||||||
|
.nullIfWhitespace ??
|
||||||
|
'');
|
||||||
|
|
||||||
|
MediaType get mimeType => MediaType.parse(_root['mimeType']);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get container => mimeType.subtype;
|
||||||
|
|
||||||
|
bool get isAudioOnly => mimeType.type == 'audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioCodec => codecs.last;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get videoCodec => isAudioOnly ? null : codecs.first;
|
||||||
|
|
||||||
|
List<String> get codecs =>
|
||||||
|
mimeType.parameters['codecs'].split(',').map((e) => e.trim());
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get videoQualityLabel => _root['quality_label'];
|
||||||
|
|
||||||
|
List<int> get _size =>
|
||||||
|
_root['size'].split(',').map((e) => int.tryParse(e ?? ''));
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get videoWidth => _size.first;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get videoHeight => _size.last;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get framerate => int.tryParse(_root['fps'] ?? '');
|
||||||
}
|
}
|
||||||
extension on String {
|
|
||||||
static final _exp = RegExp(r'\D');
|
|
||||||
|
|
||||||
/// Strips out all non digit characters.
|
class _PlayerConfig {
|
||||||
String stripNonDigits() => replaceAll(_exp, '');
|
// Json parsed map
|
||||||
|
final Map<String, dynamic> _root;
|
||||||
|
|
||||||
String get nullIfWhitespace => trim().isEmpty ? null : this;
|
_PlayerConfig(this._root);
|
||||||
|
|
||||||
bool get isNullOrWhiteSpace {
|
String get sourceUrl => 'https://youtube.com${_root['assets']['js']}';
|
||||||
if (this == null) {
|
|
||||||
return true;
|
PlayerResponse get playerResponse =>
|
||||||
|
PlayerResponse.parse(_root['args']['player_response']);
|
||||||
|
|
||||||
|
List<_StreamInfo> get muxedStreams =>
|
||||||
|
_root['args']
|
||||||
|
.get('url_encoded_fmt_stream_map')
|
||||||
|
?.split(',')
|
||||||
|
?.map((e) => _StreamInfo(Uri.splitQueryString(e))) ??
|
||||||
|
const [];
|
||||||
|
|
||||||
|
List<_StreamInfo> get adaptiveStreams =>
|
||||||
|
_root['args']
|
||||||
|
.get('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;
|
||||||
}
|
}
|
||||||
if (trim().isEmpty) {
|
return v;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +1,4 @@
|
||||||
export 'youtube_http_client.dart';
|
export 'cipher/cipher_operations.dart';
|
||||||
|
export 'heuristics.dart';
|
||||||
|
export 'responses/responses.dart';
|
||||||
|
export 'youtube_http_client.dart';
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
/// YouTube media stream that only contains audio.
|
||||||
|
class AudioOnlyStreamInfo implements AudioStreamInfo {
|
||||||
|
@override
|
||||||
|
final int tag;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final Uri url;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final Container container;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final FileSize size;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final Bitrate bitrate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String audioCodec;
|
||||||
|
|
||||||
|
/// Initializes an instance of [AudioOnlyStreamInfo]
|
||||||
|
AudioOnlyStreamInfo(this.tag, this.url, this.container, this.size,
|
||||||
|
this.bitrate, this.audioCodec);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'Audio-only ($tag | $container)';
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
/// YouTube media stream that contains audio.
|
||||||
|
abstract class AudioStreamInfo extends StreamInfo {
|
||||||
|
/// Audio codec.
|
||||||
|
final String audioCodec;
|
||||||
|
|
||||||
|
///
|
||||||
|
AudioStreamInfo(int tag, Uri url, Container container, FileSize size,
|
||||||
|
Bitrate bitrate, this.audioCodec)
|
||||||
|
: super(tag, url, container, size, bitrate);
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Encapsulates bitrate.
|
||||||
|
class Bitrate extends Comparable<Bitrate> with EquatableMixin {
|
||||||
|
/// Bits per second.
|
||||||
|
final int bitsPerSecond;
|
||||||
|
|
||||||
|
/// Kilobits per second.
|
||||||
|
double get kiloBitsPerSecond => bitsPerSecond / 1024;
|
||||||
|
/// Megabits per second.
|
||||||
|
double get megaBitsPerSecond => kiloBitsPerSecond / 1024;
|
||||||
|
/// Gigabits per second.
|
||||||
|
double get gigaBitsPerSecond => megaBitsPerSecond / 1024;
|
||||||
|
|
||||||
|
/// Initializes an instance of [Bitrate]
|
||||||
|
Bitrate(this.bitsPerSecond);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int compareTo(Bitrate other) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [bitsPerSecond];
|
||||||
|
|
||||||
|
String _getLargestSymbol() {
|
||||||
|
if (gigaBitsPerSecond.abs() >= 1) {
|
||||||
|
return 'Gbit/s';
|
||||||
|
}
|
||||||
|
if (megaBitsPerSecond.abs() >= 1) {
|
||||||
|
return 'Mbit/s';
|
||||||
|
}
|
||||||
|
if (kiloBitsPerSecond.abs() >= 1) {
|
||||||
|
return 'Kbit/s';
|
||||||
|
}
|
||||||
|
return 'Bit/s';
|
||||||
|
}
|
||||||
|
|
||||||
|
num _getLargestValue() {
|
||||||
|
if (gigaBitsPerSecond.abs() >= 1) {
|
||||||
|
return gigaBitsPerSecond;
|
||||||
|
}
|
||||||
|
if (megaBitsPerSecond.abs() >= 1) {
|
||||||
|
return megaBitsPerSecond;
|
||||||
|
}
|
||||||
|
if (kiloBitsPerSecond.abs() >= 1) {
|
||||||
|
return kiloBitsPerSecond;
|
||||||
|
}
|
||||||
|
return bitsPerSecond;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '${_getLargestValue()} ${_getLargestSymbol()}';
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Stream container.
|
||||||
|
class Container with EquatableMixin {
|
||||||
|
/// Container name.
|
||||||
|
/// Can be used as file extension
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// Initializes an instance of [Container]
|
||||||
|
Container._(this.name);
|
||||||
|
|
||||||
|
/// MPEG-4 Part 14 (.mp4).
|
||||||
|
static final Container mp4 = Container._('mp4');
|
||||||
|
|
||||||
|
/// Web Media (.webm).
|
||||||
|
static final Container webM = Container._('webm');
|
||||||
|
|
||||||
|
/// 3rd Generation Partnership Project (.3gpp).
|
||||||
|
static final Container tgpp = Container._('3gpp');
|
||||||
|
|
||||||
|
/// Parse a container from name.
|
||||||
|
static Container parse(String name) {
|
||||||
|
if (name.toLowerCase() == 'mp4') {
|
||||||
|
return Container.mp4;
|
||||||
|
}
|
||||||
|
if (name.toLowerCase() == 'webm') {
|
||||||
|
return Container.webM;
|
||||||
|
}
|
||||||
|
if (name.toLowerCase() == '3gpp') {
|
||||||
|
return Container.tgpp;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ArgumentError.value(name, 'name', 'Valid values: mp4, webm, 3gpp');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [name];
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Encapsulates file size.
|
||||||
|
class FileSize extends Comparable<FileSize> with EquatableMixin {
|
||||||
|
/// Total bytes.
|
||||||
|
final int totalBytes;
|
||||||
|
|
||||||
|
/// Total kilobytes.
|
||||||
|
double get totalKiloBytes => totalBytes / 1024;
|
||||||
|
|
||||||
|
/// Total megabytes.
|
||||||
|
double get totalMegaBytes => totalKiloBytes / 1024;
|
||||||
|
|
||||||
|
/// Total gigabytes.
|
||||||
|
double get totalGigaBytes => totalMegaBytes / 1024;
|
||||||
|
|
||||||
|
/// Initializes an instance of [FileSize]
|
||||||
|
FileSize(this.totalBytes);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int compareTo(FileSize other) => totalBytes.compareTo(other.totalBytes);
|
||||||
|
|
||||||
|
String _getLargestSymbol() {
|
||||||
|
if (totalGigaBytes.abs() >= 1) {
|
||||||
|
return 'GB';
|
||||||
|
}
|
||||||
|
if (totalMegaBytes.abs() >= 1) {
|
||||||
|
return 'MB';
|
||||||
|
}
|
||||||
|
if (totalKiloBytes.abs() >= 1) {
|
||||||
|
return 'KB';
|
||||||
|
}
|
||||||
|
return 'B';
|
||||||
|
}
|
||||||
|
|
||||||
|
num _getLargestValue() {
|
||||||
|
if (totalGigaBytes.abs() >= 1) {
|
||||||
|
return totalGigaBytes;
|
||||||
|
}
|
||||||
|
if (totalMegaBytes.abs() >= 1) {
|
||||||
|
return totalMegaBytes;
|
||||||
|
}
|
||||||
|
if (totalKiloBytes.abs() >= 1) {
|
||||||
|
return totalKiloBytes;
|
||||||
|
}
|
||||||
|
return totalBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '${_getLargestValue()} ${_getLargestSymbol()}';
|
||||||
|
|
||||||
|
@override
|
||||||
|
// TODO: implement props
|
||||||
|
List<Object> get props => [totalBytes];
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Encapsulates framerate.
|
||||||
|
class Framerate extends Comparable<Framerate> with EquatableMixin {
|
||||||
|
/// Framerate as frames per second
|
||||||
|
final double framesPerSecond;
|
||||||
|
|
||||||
|
/// Initialize an instance of [Framerate]
|
||||||
|
Framerate(this.framesPerSecond);
|
||||||
|
|
||||||
|
///
|
||||||
|
bool operator >(Framerate other) => framesPerSecond > other.framesPerSecond;
|
||||||
|
|
||||||
|
///
|
||||||
|
bool operator <(Framerate other) => framesPerSecond < other.framesPerSecond;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$framesPerSecond FPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [framesPerSecond];
|
||||||
|
|
||||||
|
@override
|
||||||
|
int compareTo(Framerate other) =>
|
||||||
|
framesPerSecond.compareTo(other.framesPerSecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
void t() {
|
||||||
|
var t = Framerate(1.1) > Framerate(2.2);
|
||||||
|
}
|
|
@ -0,0 +1,64 @@
|
||||||
|
import 'audio_stream_info.dart';
|
||||||
|
import 'bitrate.dart';
|
||||||
|
import 'container.dart';
|
||||||
|
import 'filesize.dart';
|
||||||
|
import 'framerate.dart';
|
||||||
|
import 'video_quality.dart';
|
||||||
|
import 'video_resolution.dart';
|
||||||
|
import 'video_stream_info.dart';
|
||||||
|
|
||||||
|
/// YouTube media stream that contains both audio and video.
|
||||||
|
class MuxedStreamInfo implements AudioStreamInfo, VideoStreamInfo {
|
||||||
|
final int tag;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final Uri url;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final Container container;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final FileSize size;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final Bitrate bitrate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String audioCodec;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String videoCodec;
|
||||||
|
|
||||||
|
/// Video quality label, as seen on YouTube.
|
||||||
|
@override
|
||||||
|
final String videoQualityLabel;
|
||||||
|
|
||||||
|
/// Video quality.
|
||||||
|
@override
|
||||||
|
final VideoQuality videoQuality;
|
||||||
|
|
||||||
|
/// Video resolution.
|
||||||
|
@override
|
||||||
|
final VideoResolution videoResolution;
|
||||||
|
|
||||||
|
/// Video framerate.
|
||||||
|
@override
|
||||||
|
final Framerate framerate;
|
||||||
|
|
||||||
|
/// Initializes an instance of [MuxedStreamInfo]
|
||||||
|
MuxedStreamInfo(
|
||||||
|
this.tag,
|
||||||
|
this.url,
|
||||||
|
this.container,
|
||||||
|
this.size,
|
||||||
|
this.bitrate,
|
||||||
|
this.audioCodec,
|
||||||
|
this.videoCodec,
|
||||||
|
this.videoQualityLabel,
|
||||||
|
this.videoQuality,
|
||||||
|
this.videoResolution,
|
||||||
|
this.framerate);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'Muxed ($tag | $videoQualityLabel | $container';
|
||||||
|
}
|
|
@ -0,0 +1,254 @@
|
||||||
|
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_resolution.dart';
|
||||||
|
|
||||||
|
import '../../exceptions/exceptions.dart';
|
||||||
|
import '../../extensions/helpers_extension.dart';
|
||||||
|
import '../../reverse_engineering/reverse_engineering.dart';
|
||||||
|
import '../video_id.dart';
|
||||||
|
import 'bitrate.dart';
|
||||||
|
import 'container.dart';
|
||||||
|
import 'filesize.dart';
|
||||||
|
import 'framerate.dart';
|
||||||
|
import 'stream_context.dart';
|
||||||
|
import 'stream_info.dart';
|
||||||
|
import 'stream_manifest.dart';
|
||||||
|
|
||||||
|
/// Queries related to media streams of YouTube videos.
|
||||||
|
class StreamClient {
|
||||||
|
final YoutubeHttpClient _httpClient;
|
||||||
|
|
||||||
|
/// Initializes an instance of [StreamsClient]
|
||||||
|
StreamClient._(this._httpClient);
|
||||||
|
|
||||||
|
Future<DashManifest> _getDashManifest(
|
||||||
|
Uri dashManifestUrl, Iterable<CipherOperation> cipherOperations) {
|
||||||
|
var signature =
|
||||||
|
DashManifest.getSignatureFromUrl(dashManifestUrl.toString());
|
||||||
|
if (!signature.isNullOrWhiteSpace) {
|
||||||
|
signature = cipherOperations.decipher(signature);
|
||||||
|
dashManifestUrl = dashManifestUrl.setQueryParam('signature', signature);
|
||||||
|
}
|
||||||
|
return DashManifest.get(_httpClient, dashManifestUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StreamContext> _getStreamContextFromVideoInfo(VideoId videoId) async {
|
||||||
|
var embedPage = await EmbedPage.get(_httpClient, videoId.toString());
|
||||||
|
var playerConfig = embedPage.playerconfig;
|
||||||
|
if (playerConfig == null) {
|
||||||
|
throw VideoUnplayableException.unplayable(videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var playerSource =
|
||||||
|
await PlayerSource.get(_httpClient, playerConfig.sourceUrl);
|
||||||
|
var cipherOperations = playerSource.getCiperOperations();
|
||||||
|
|
||||||
|
var videoInfoReponse = await VideoInfoResponse.get(
|
||||||
|
_httpClient, videoId.toString(), playerSource.sts);
|
||||||
|
var playerResponse = videoInfoReponse.playerResponse;
|
||||||
|
|
||||||
|
var previewVideoId = playerResponse.previewVideoId;
|
||||||
|
if (!previewVideoId.isNullOrWhiteSpace) {
|
||||||
|
throw VideoRequiresPurchaseException.preview(
|
||||||
|
videoId, VideoId(previewVideoId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playerResponse.isVideoPlayable) {
|
||||||
|
throw VideoUnplayableException.unplayable(videoId,
|
||||||
|
reason: playerResponse.getVideoPlayabilityError());
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamInfoProviders = <StreamInfoProvider>[
|
||||||
|
...videoInfoReponse.streams,
|
||||||
|
...playerResponse.streams
|
||||||
|
];
|
||||||
|
|
||||||
|
var dashManifestUrl = playerResponse.dashManifestUrl;
|
||||||
|
if (dashManifestUrl.isNullOrWhiteSpace) {
|
||||||
|
var dashManifest =
|
||||||
|
await _getDashManifest(Uri.parse(dashManifestUrl), cipherOperations);
|
||||||
|
streamInfoProviders.addAll(dashManifest.streams);
|
||||||
|
}
|
||||||
|
return StreamContext(streamInfoProviders, cipherOperations);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StreamContext> _getStreamContextFromWatchPage(VideoId videoId) async {
|
||||||
|
var watchPage = await WatchPage.get(_httpClient, videoId.toString());
|
||||||
|
var playerConfig = watchPage.playerConfig;
|
||||||
|
if (playerConfig == null) {
|
||||||
|
throw VideoUnplayableException.unplayable(videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var playerResponse = playerConfig.playerResponse;
|
||||||
|
|
||||||
|
var previewVideoId = playerResponse.previewVideoId;
|
||||||
|
if (!previewVideoId.isNullOrWhiteSpace) {
|
||||||
|
throw VideoRequiresPurchaseException.preview(
|
||||||
|
videoId, VideoId(previewVideoId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var playerSource =
|
||||||
|
await PlayerSource.get(_httpClient, playerConfig.sourceUrl);
|
||||||
|
var cipherOperations = playerSource.getCiperOperations();
|
||||||
|
|
||||||
|
if (!playerResponse.isVideoPlayable) {
|
||||||
|
throw VideoUnplayableException.unplayable(videoId,
|
||||||
|
reason: playerResponse.getVideoPlayabilityError());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerResponse.isLive) {
|
||||||
|
throw VideoUnplayableException.liveStream(videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamInfoProviders = <StreamInfoProvider>[
|
||||||
|
...playerConfig.streams,
|
||||||
|
...playerResponse.streams
|
||||||
|
];
|
||||||
|
|
||||||
|
var dashManifestUrl = playerResponse.dashManifestUrl;
|
||||||
|
if (dashManifestUrl.isNullOrWhiteSpace) {
|
||||||
|
var dashManifest =
|
||||||
|
await _getDashManifest(Uri.parse(dashManifestUrl), cipherOperations);
|
||||||
|
streamInfoProviders.addAll(dashManifest.streams);
|
||||||
|
}
|
||||||
|
return StreamContext(streamInfoProviders, cipherOperations);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StreamManifest> _getManifest(StreamContext streamContext) async {
|
||||||
|
// To make sure there are no duplicates streams, group them by tag
|
||||||
|
var streams = <int, StreamInfo>{};
|
||||||
|
|
||||||
|
for (var streamInfo in streamContext.streamInfoProviders) {
|
||||||
|
var tag = streamInfo.tag;
|
||||||
|
var url = Uri.parse(streamInfo.url);
|
||||||
|
|
||||||
|
// Signature
|
||||||
|
var signature = streamInfo.signature;
|
||||||
|
var signatureParameters = streamInfo.signatureParameter;
|
||||||
|
|
||||||
|
if (!signature.isNullOrWhiteSpace) {
|
||||||
|
signature = streamContext.cipherOperations.decipher(signature);
|
||||||
|
url = url.setQueryParam(signatureParameters, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content length
|
||||||
|
var contentLength = streamInfo.contentLength ??
|
||||||
|
await _httpClient.getContentLength(url, validate: false) ??
|
||||||
|
0;
|
||||||
|
|
||||||
|
if (contentLength <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common
|
||||||
|
var container = Container.parse(streamInfo.container);
|
||||||
|
var fileSize = FileSize(contentLength);
|
||||||
|
var bitrate = Bitrate(streamInfo.bitrate);
|
||||||
|
|
||||||
|
var audioCodec = streamInfo.audioCodec;
|
||||||
|
var videoCodec = streamInfo.videoCodec;
|
||||||
|
|
||||||
|
// Muxed or Video-only
|
||||||
|
if (!videoCodec.isNullOrWhiteSpace) {
|
||||||
|
var framerate = Framerate(streamInfo.framerate ?? 24);
|
||||||
|
var videoQualityLabel = streamInfo.videoQualityLabel ??
|
||||||
|
VideoQualityUtil.getLabelFromTagWithFramerate(
|
||||||
|
tag, framerate.framesPerSecond);
|
||||||
|
|
||||||
|
var videoQuality = VideoQualityUtil.fromLabel(videoQualityLabel);
|
||||||
|
|
||||||
|
var videoWidth = streamInfo.videoWidth;
|
||||||
|
var videoHeight = streamInfo.videoHeight;
|
||||||
|
var videoResolution = videoWidth != null && videoHeight != null
|
||||||
|
? VideoResolution(videoWidth, videoHeight)
|
||||||
|
: videoQuality.toVideoResolution();
|
||||||
|
|
||||||
|
// Muxed
|
||||||
|
if (!audioCodec.isNullOrWhiteSpace) {
|
||||||
|
streams[tag] = MuxedStreamInfo(
|
||||||
|
tag,
|
||||||
|
url,
|
||||||
|
container,
|
||||||
|
fileSize,
|
||||||
|
bitrate,
|
||||||
|
audioCodec,
|
||||||
|
videoCodec,
|
||||||
|
videoQualityLabel,
|
||||||
|
videoQuality,
|
||||||
|
videoResolution,
|
||||||
|
framerate);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video only
|
||||||
|
streams[tag] = VideoOnlyStreamInfo(
|
||||||
|
tag,
|
||||||
|
url,
|
||||||
|
container,
|
||||||
|
fileSize,
|
||||||
|
bitrate,
|
||||||
|
videoCodec,
|
||||||
|
videoQualityLabel,
|
||||||
|
videoQuality,
|
||||||
|
videoResolution,
|
||||||
|
framerate);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Audio-only
|
||||||
|
if (audioCodec.isNullOrWhiteSpace) {
|
||||||
|
streams[tag] = AudioOnlyStreamInfo(
|
||||||
|
tag, url, container, fileSize, bitrate, audioCodec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #if DEBUG
|
||||||
|
// throw FatalFailureException("Stream info doesn't contain audio/video codec information.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamManifest(streams.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the manifest that contains information
|
||||||
|
/// about available streams in the specified video.
|
||||||
|
Future<StreamManifest> getManifest(VideoId videoId) async {
|
||||||
|
// We can try to extract the manifest from two sources:
|
||||||
|
// get_video_info and the video watch page.
|
||||||
|
// In some cases one works, in some cases another does.
|
||||||
|
try {
|
||||||
|
var context = await _getStreamContextFromVideoInfo(videoId);
|
||||||
|
return _getManifest(context);
|
||||||
|
} on YoutubeExplodeException {
|
||||||
|
var context = await _getStreamContextFromWatchPage(videoId);
|
||||||
|
return _getManifest(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the HTTP Live Stream (HLS) manifest URL
|
||||||
|
/// for the specified video (if it's a live video stream).
|
||||||
|
Future<String> getHttpLiveStreamUrl(VideoId videoId) async {
|
||||||
|
var videoInfoResponse =
|
||||||
|
await VideoInfoResponse.get(_httpClient, videoId.toString());
|
||||||
|
var playerResponse = videoInfoResponse.playerResponse;
|
||||||
|
if (!playerResponse.isVideoPlayable) {
|
||||||
|
throw VideoUnplayableException.unplayable(videoId,
|
||||||
|
reason: playerResponse.getVideoPlayabilityError());
|
||||||
|
}
|
||||||
|
|
||||||
|
var hlsManifest = playerResponse.hlsManifestUrl;
|
||||||
|
if (hlsManifest == null) {
|
||||||
|
throw VideoUnplayableException.notLiveStream(videoId);
|
||||||
|
}
|
||||||
|
return hlsManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//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);
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: Implement CopyToAsync
|
||||||
|
|
||||||
|
//TODO: Implement DownloadAsync
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import '../../reverse_engineering/reverse_engineering.dart';
|
||||||
|
|
||||||
|
///
|
||||||
|
class StreamContext {
|
||||||
|
///
|
||||||
|
final Iterable<StreamInfoProvider> streamInfoProviders;
|
||||||
|
|
||||||
|
///
|
||||||
|
final Iterable<CipherOperation> cipherOperations;
|
||||||
|
|
||||||
|
///
|
||||||
|
StreamContext(this.streamInfoProviders, this.cipherOperations);
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
import 'bitrate.dart';
|
||||||
|
import 'container.dart';
|
||||||
|
import 'filesize.dart';
|
||||||
|
|
||||||
|
/// Generic YouTube media stream.
|
||||||
|
abstract class StreamInfo {
|
||||||
|
/// Stream tag.
|
||||||
|
/// Uniquely identifies a stream inside a manifest.
|
||||||
|
final int tag;
|
||||||
|
|
||||||
|
/// Stream URL.
|
||||||
|
final Uri url;
|
||||||
|
|
||||||
|
/// Stream container.
|
||||||
|
final Container container;
|
||||||
|
|
||||||
|
/// Stream size.
|
||||||
|
final FileSize size;
|
||||||
|
|
||||||
|
/// Stream bitrate.
|
||||||
|
final Bitrate bitrate;
|
||||||
|
|
||||||
|
///
|
||||||
|
StreamInfo(this.tag, this.url, this.container, this.size, this.bitrate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extensions for [StreamInfo]
|
||||||
|
extension StreamInfoExt on StreamInfo {
|
||||||
|
static final _exp = RegExp('ratebypass[=/]yes');
|
||||||
|
|
||||||
|
bool _isRateLimited() => _exp.hasMatch(url.toString());
|
||||||
|
|
||||||
|
/// Gets the stream with highest bitrate.
|
||||||
|
static StreamInfo getHighestBitrate(List<StreamInfo> streams) =>
|
||||||
|
(streams..sort((a, b) => a.bitrate.compareTo(b.bitrate))).last;
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
/// Manifest that contains information about available media streams
|
||||||
|
/// in a specific video.
|
||||||
|
class StreamManifest {
|
||||||
|
/// Available streams.
|
||||||
|
final UnmodifiableListView<StreamInfo> streams;
|
||||||
|
|
||||||
|
/// Initializes an instance of [StreamManifest]
|
||||||
|
StreamManifest(Iterable<StreamInfo> streams)
|
||||||
|
: streams = UnmodifiableListView(streams);
|
||||||
|
|
||||||
|
/// Gets streams that contain audio
|
||||||
|
/// (which includes muxed and audio-only streams).
|
||||||
|
Iterable<AudioStreamInfo> getAudio() => streams.whereType<AudioStreamInfo>();
|
||||||
|
|
||||||
|
/// Gets streams that contain video
|
||||||
|
/// (which includes muxed and video-only streams).
|
||||||
|
Iterable<VideoStreamInfo> getVideo() => streams.whereType<VideoStreamInfo>();
|
||||||
|
|
||||||
|
/// Gets muxed streams (contain both audio and video).
|
||||||
|
/// Note that muxed streams are limited in quality and don't go beyond 720p30.
|
||||||
|
Iterable<MuxedStreamInfo> getMuxed() => streams.whereType<MuxedStreamInfo>();
|
||||||
|
|
||||||
|
/// Gets audio-only streams (no video).
|
||||||
|
Iterable<AudioOnlyStreamInfo> getAudioOnly() =>
|
||||||
|
streams.whereType<AudioOnlyStreamInfo>();
|
||||||
|
|
||||||
|
/// Gets video-only streams (no audio).
|
||||||
|
/// These streams have the widest range of qualities available.
|
||||||
|
Iterable<VideoOnlyStreamInfo> getVideoOnly() =>
|
||||||
|
streams.whereType<VideoOnlyStreamInfo>();
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
export 'audio_only_stream_info.dart';
|
||||||
|
export 'audio_stream_info.dart';
|
||||||
|
export 'bitrate.dart';
|
||||||
|
export 'container.dart';
|
||||||
|
export 'filesize.dart';
|
||||||
|
export 'framerate.dart';
|
||||||
|
export 'muxed_stream_info.dart';
|
||||||
|
export 'stream_client.dart';
|
||||||
|
export 'stream_context.dart';
|
||||||
|
export 'stream_info.dart';
|
||||||
|
export 'stream_manifest.dart';
|
||||||
|
export 'video_only_stream_info.dart';
|
||||||
|
export 'video_quality.dart';
|
||||||
|
export 'video_resolution.dart';
|
||||||
|
export 'video_stream_info.dart';
|
|
@ -0,0 +1,56 @@
|
||||||
|
import 'bitrate.dart';
|
||||||
|
import 'container.dart';
|
||||||
|
import 'filesize.dart';
|
||||||
|
import 'framerate.dart';
|
||||||
|
import 'video_quality.dart';
|
||||||
|
import 'video_resolution.dart';
|
||||||
|
import 'video_stream_info.dart';
|
||||||
|
|
||||||
|
/// YouTube media stream that only contains video.
|
||||||
|
class VideoOnlyStreamInfo implements VideoStreamInfo {
|
||||||
|
@override
|
||||||
|
final int tag;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final Uri url;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final Container container;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final FileSize size;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final Bitrate bitrate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String videoCodec;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String videoQualityLabel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final VideoQuality videoQuality;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final VideoResolution videoResolution;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final Framerate framerate;
|
||||||
|
|
||||||
|
/// Initializes an instance of [VideoOnlyStreamInfo]
|
||||||
|
VideoOnlyStreamInfo(
|
||||||
|
this.tag,
|
||||||
|
this.url,
|
||||||
|
this.container,
|
||||||
|
this.size,
|
||||||
|
this.bitrate,
|
||||||
|
this.videoCodec,
|
||||||
|
this.videoQualityLabel,
|
||||||
|
this.videoQuality,
|
||||||
|
this.videoResolution,
|
||||||
|
this.framerate);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'Video-only ($tag | $videoQualityLabel | $container';
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
|
||||||
|
/// Video quality.
|
||||||
|
enum VideoQuality {
|
||||||
|
/// Low quality (144p).
|
||||||
|
low144,
|
||||||
|
|
||||||
|
/// Low quality (240p).
|
||||||
|
low240,
|
||||||
|
|
||||||
|
/// Medium quality (360p).
|
||||||
|
medium360,
|
||||||
|
|
||||||
|
/// Medium quality (480p).
|
||||||
|
medium480,
|
||||||
|
|
||||||
|
/// High quality (720p).
|
||||||
|
high720,
|
||||||
|
|
||||||
|
/// High quality (1080p).
|
||||||
|
high1080,
|
||||||
|
|
||||||
|
/// High quality (1440p).
|
||||||
|
high1440,
|
||||||
|
|
||||||
|
/// High quality (2160p).
|
||||||
|
high2160,
|
||||||
|
|
||||||
|
/// High quality (2880p).
|
||||||
|
high2880,
|
||||||
|
|
||||||
|
/// High quality (3072p).
|
||||||
|
high3072,
|
||||||
|
|
||||||
|
/// High quality (4320p).
|
||||||
|
high4320
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Width and height of a video.
|
||||||
|
class VideoResolution {
|
||||||
|
/// Viewport width.
|
||||||
|
final int width;
|
||||||
|
|
||||||
|
/// Viewport height.
|
||||||
|
final int height;
|
||||||
|
|
||||||
|
/// Initializes an instance of [VideoResolution]
|
||||||
|
const VideoResolution(this.width, this.height);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '${width}x$height';
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
/// YouTube media stream that contains video.
|
||||||
|
abstract class VideoStreamInfo extends StreamInfo {
|
||||||
|
/// Video codec.
|
||||||
|
final String videoCodec;
|
||||||
|
|
||||||
|
/// Video quality label, as seen on YouTube.
|
||||||
|
final String videoQualityLabel;
|
||||||
|
|
||||||
|
/// Video quality.
|
||||||
|
final VideoQuality videoQuality;
|
||||||
|
|
||||||
|
/// Video resolution.
|
||||||
|
final VideoResolution videoResolution;
|
||||||
|
|
||||||
|
/// Video framerate.
|
||||||
|
final Framerate framerate;
|
||||||
|
|
||||||
|
///
|
||||||
|
VideoStreamInfo(
|
||||||
|
int tag,
|
||||||
|
Uri url,
|
||||||
|
Container container,
|
||||||
|
FileSize size,
|
||||||
|
Bitrate bitrate,
|
||||||
|
this.videoCodec,
|
||||||
|
this.videoQualityLabel,
|
||||||
|
this.videoQuality,
|
||||||
|
this.videoResolution,
|
||||||
|
this.framerate)
|
||||||
|
: super(tag, url, container, size, bitrate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement VideoStreamExtension
|
||||||
|
// https://github.com/Tyrrrz/YoutubeExplode/blob/136b72bf8ca00fea7d6a686694dd91a485ca2c83/YoutubeExplode/Videos/Streams/IVideoStreamInfo.cs#L37-L60
|
||||||
|
/*
|
||||||
|
/// Extensions for [VideoStreamInfo[
|
||||||
|
extension VideoStreamInfoExtension on VideoStreamInfo {
|
||||||
|
|
||||||
|
}*/
|
|
@ -0,0 +1,69 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../extensions/extensions.dart';
|
||||||
|
|
||||||
|
/// Encapsulates a valid YouTube video ID.
|
||||||
|
class VideoId extends Equatable {
|
||||||
|
static final _regMatchExp = RegExp(r'youtube\..+?/watch.*?v=(.*?)(?:&|/|$)');
|
||||||
|
static final _shortMatchExp = RegExp(r'youtu\.be/(.*?)(?:\?|&|/|$)');
|
||||||
|
static final _embedMatchExp = RegExp(r'youtube\..+?/embed/(.*?)(?:\?|&|/|$)');
|
||||||
|
|
||||||
|
/// ID as string.
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
/// Initializes an instance of [VideoId] with a url or video id.
|
||||||
|
VideoId(String urlOrUrl)
|
||||||
|
: value = parseVideoId(urlOrUrl) ??
|
||||||
|
ArgumentError.value(
|
||||||
|
urlOrUrl, 'urlOrUrl', 'Invalid YouTube video ID or URL.');
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [value];
|
||||||
|
|
||||||
|
/// Returns true if the given [videoId] is valid.
|
||||||
|
static bool validateVideoId(String videoId) {
|
||||||
|
if (videoId.isNullOrWhiteSpace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoId.length != 11) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !RegExp(r'[^0-9a-zA-Z_\-]').hasMatch(videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a video id from url or if given a valid id as url returns itself.
|
||||||
|
/// Returns null if the id couldn't be extracted.
|
||||||
|
static String parseVideoId(String url) {
|
||||||
|
if (url.isNullOrWhiteSpace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validateVideoId(url)) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://www.youtube.com/watch?v=yIVRs6YSbOM
|
||||||
|
var regMatch = _regMatchExp.firstMatch(url)?.group(1);
|
||||||
|
if (!regMatch.isNullOrWhiteSpace && validateVideoId(regMatch)) {
|
||||||
|
return regMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://youtu.be/yIVRs6YSbOM
|
||||||
|
var shortMatch = _shortMatchExp.firstMatch(url)?.group(1);
|
||||||
|
if (!shortMatch.isNullOrWhiteSpace && validateVideoId(shortMatch)) {
|
||||||
|
return shortMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://www.youtube.com/embed/yIVRs6YSbOM
|
||||||
|
var embedMatch = _embedMatchExp.firstMatch(url)?.group(1);
|
||||||
|
if (!embedMatch.isNullOrWhiteSpace && validateVideoId(embedMatch)) {
|
||||||
|
return embedMatch;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export 'streams/streams.dart';
|
||||||
|
export 'video_id.dart';
|
|
@ -1,543 +0,0 @@
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:html/dom.dart';
|
|
||||||
import 'package:html/parser.dart' as html;
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:http_parser/http_parser.dart' show MediaType;
|
|
||||||
|
|
||||||
import 'cipher/cipher.dart';
|
|
||||||
import 'exceptions/exceptions.dart';
|
|
||||||
import 'extensions/extensions.dart';
|
|
||||||
import 'models/models.dart';
|
|
||||||
import 'parser.dart' as parser;
|
|
||||||
|
|
||||||
import 'channel/channel_client.dart';
|
|
||||||
|
|
||||||
/// YoutubeExplode entry class.
|
|
||||||
class YoutubeExplode {
|
|
||||||
static final _regMatchExp = RegExp(r'youtube\..+?/watch.*?v=(.*?)(?:&|/|$)');
|
|
||||||
static final _shortMatchExp = RegExp(r'youtu\.be/(.*?)(?:\?|&|/|$)');
|
|
||||||
static final _embedMatchExp = RegExp(r'youtube\..+?/embed/(.*?)(?:\?|&|/|$)');
|
|
||||||
static final _playerConfigExp = RegExp(
|
|
||||||
r"yt\.setConfig\({'PLAYER_CONFIG':(.*)}\);",
|
|
||||||
multiLine: true,
|
|
||||||
caseSensitive: false);
|
|
||||||
static final _contentLenExp = RegExp(r'clen=(\d+)');
|
|
||||||
|
|
||||||
/// HTTP Client.
|
|
||||||
// Visible only for extensions.
|
|
||||||
final http.Client client;
|
|
||||||
|
|
||||||
/// Initialize [YoutubeExplode] class and http client.
|
|
||||||
YoutubeExplode() : client = http.Client();
|
|
||||||
|
|
||||||
/// Returns a [Future] that completes with a [MediaStreamInfoSet]
|
|
||||||
/// Use this to extract the muxed, audio and video streams from a video.
|
|
||||||
Future<MediaStreamInfoSet> getVideoMediaStream(String videoId) async {
|
|
||||||
if (!validateVideoId(videoId)) {
|
|
||||||
throw ArgumentError.value(videoId, 'videoId', 'Invalid video id');
|
|
||||||
}
|
|
||||||
|
|
||||||
var playerConfiguration = await getPlayerConfiguration(videoId);
|
|
||||||
|
|
||||||
var muxedStreamInfoMap = <int, MuxedStreamInfo>{};
|
|
||||||
var audioStreamInfoMap = <int, AudioStreamInfo>{};
|
|
||||||
var videoStreamInfoMap = <int, VideoStreamInfo>{};
|
|
||||||
|
|
||||||
var muxedStreamInfoDics =
|
|
||||||
playerConfiguration.muxedStreamInfosUrlEncoded?.split(',');
|
|
||||||
if (muxedStreamInfoDics != null) {
|
|
||||||
// TODO: Implement muxedStreamInfoDics
|
|
||||||
throw UnsupportedError(
|
|
||||||
'muxedStreamInfoDics not null not implemented yet.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playerConfiguration.muxedStreamInfoJson != null) {
|
|
||||||
for (var streamInfoJson in playerConfiguration.muxedStreamInfoJson) {
|
|
||||||
var itag = streamInfoJson['itag'] as int;
|
|
||||||
var urlString = streamInfoJson['url'] as String;
|
|
||||||
Uri url;
|
|
||||||
|
|
||||||
if (urlString.isNullOrWhiteSpace &&
|
|
||||||
!playerConfiguration.playerSourceUrl.isNullOrWhiteSpace) {
|
|
||||||
var cipher = streamInfoJson['cipher'] as String;
|
|
||||||
url = await decipherUrl(
|
|
||||||
playerConfiguration.playerSourceUrl, cipher, client);
|
|
||||||
}
|
|
||||||
url ??= Uri.parse(urlString);
|
|
||||||
|
|
||||||
var contentLength = await _parseContentLength(
|
|
||||||
streamInfoJson['contentLength'],
|
|
||||||
url?.toString(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Extract container
|
|
||||||
var mimeType = MediaType.parse(streamInfoJson['mimeType'] as String);
|
|
||||||
|
|
||||||
var container = parser.stringToContainer(mimeType.subtype);
|
|
||||||
var codecs = mimeType.parameters['codecs'].split(',');
|
|
||||||
|
|
||||||
// Extract audio encoding
|
|
||||||
var audioEncoding = parser.audioEncodingFromString(codecs.last);
|
|
||||||
|
|
||||||
// Extract video encoding
|
|
||||||
var videoEncoding = parser.videoEncodingFromString(codecs.first);
|
|
||||||
|
|
||||||
// Extract video quality from itag.
|
|
||||||
var videoQuality = parser.videoQualityFromITag(itag);
|
|
||||||
|
|
||||||
// Get video quality label
|
|
||||||
var videoQualityLabel = parser.videoQualityToLabel(videoQuality);
|
|
||||||
|
|
||||||
// Get video resolution
|
|
||||||
var resolution = parser.videoQualityToResolution(videoQuality);
|
|
||||||
|
|
||||||
assert(url != null);
|
|
||||||
assert(contentLength != null && contentLength != -1);
|
|
||||||
muxedStreamInfoMap[itag] = MuxedStreamInfo(
|
|
||||||
itag,
|
|
||||||
url,
|
|
||||||
container,
|
|
||||||
contentLength,
|
|
||||||
audioEncoding,
|
|
||||||
videoEncoding,
|
|
||||||
videoQualityLabel,
|
|
||||||
videoQuality,
|
|
||||||
resolution);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var adaptiveStreamInfoDics =
|
|
||||||
playerConfiguration.adaptiveStreamInfosUrlEncoded?.split(',');
|
|
||||||
if (adaptiveStreamInfoDics != null) {
|
|
||||||
// TODO: Implement adaptiveStreamInfoDics
|
|
||||||
throw UnsupportedError(
|
|
||||||
'adaptiveStreamInfoDics not null not implemented yet.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playerConfiguration.adaptiveStreamInfosJson != null) {
|
|
||||||
for (var streamInfoJson in playerConfiguration.adaptiveStreamInfosJson) {
|
|
||||||
var itag = streamInfoJson['itag'] as int;
|
|
||||||
var urlString = streamInfoJson['url'] as String;
|
|
||||||
var bitrate = streamInfoJson['bitrate'] as int;
|
|
||||||
Uri url;
|
|
||||||
|
|
||||||
if (urlString.isNullOrWhiteSpace &&
|
|
||||||
!playerConfiguration.playerSourceUrl.isNullOrWhiteSpace) {
|
|
||||||
var cipher = streamInfoJson['cipher'] as String;
|
|
||||||
url = await decipherUrl(
|
|
||||||
playerConfiguration.playerSourceUrl, cipher, client);
|
|
||||||
}
|
|
||||||
url ??= Uri.parse(urlString);
|
|
||||||
|
|
||||||
var contentLength = await _parseContentLength(
|
|
||||||
streamInfoJson['contentLength'],
|
|
||||||
url?.toString(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Extract container
|
|
||||||
var mimeType = MediaType.parse(streamInfoJson['mimeType'] as String);
|
|
||||||
|
|
||||||
var container = parser.stringToContainer(mimeType.subtype);
|
|
||||||
var codecs = mimeType.parameters['codecs'].toLowerCase();
|
|
||||||
|
|
||||||
// Audio only
|
|
||||||
if (streamInfoJson['audioSampleRate'] != null) {
|
|
||||||
var audioEncoding = parser.audioEncodingFromString(codecs);
|
|
||||||
audioStreamInfoMap[itag] = AudioStreamInfo(
|
|
||||||
itag, url, container, contentLength, bitrate, audioEncoding);
|
|
||||||
} else {
|
|
||||||
// Video only
|
|
||||||
var videoEncoding = codecs == 'unknown'
|
|
||||||
? VideoEncoding.av1
|
|
||||||
: parser.videoEncodingFromString(codecs);
|
|
||||||
|
|
||||||
var videoQualityLabel = streamInfoJson['qualityLabel'] as String;
|
|
||||||
var videoQuality = parser.videoQualityFromLabel(videoQualityLabel);
|
|
||||||
|
|
||||||
var width = streamInfoJson['width'] as int;
|
|
||||||
var height = streamInfoJson['height'] as int;
|
|
||||||
var resolution = VideoResolution(width, height);
|
|
||||||
|
|
||||||
var framerate = streamInfoJson['fps'];
|
|
||||||
|
|
||||||
videoStreamInfoMap[itag] = VideoStreamInfo(
|
|
||||||
itag,
|
|
||||||
url,
|
|
||||||
container,
|
|
||||||
contentLength,
|
|
||||||
bitrate,
|
|
||||||
videoEncoding,
|
|
||||||
videoQualityLabel,
|
|
||||||
videoQuality,
|
|
||||||
resolution,
|
|
||||||
framerate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var sortedMuxed = muxedStreamInfoMap.values.toList()
|
|
||||||
..sort((a, b) => a.videoQuality.index.compareTo(b.videoQuality.index));
|
|
||||||
var sortedAudio = audioStreamInfoMap.values.toList()
|
|
||||||
..sort((a, b) => a.bitrate.compareTo(b.bitrate));
|
|
||||||
var sortedVideo = videoStreamInfoMap.values.toList()
|
|
||||||
..sort((a, b) => a.videoQuality.index.compareTo(b.videoQuality.index));
|
|
||||||
return MediaStreamInfoSet(
|
|
||||||
sortedMuxed,
|
|
||||||
sortedAudio,
|
|
||||||
sortedVideo,
|
|
||||||
playerConfiguration.hlsManifestUrl,
|
|
||||||
playerConfiguration.video,
|
|
||||||
playerConfiguration.validUntil);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the player configuration for a given video.
|
|
||||||
Future<PlayerConfiguration> getPlayerConfiguration(String videoId) async {
|
|
||||||
var playerConfiguration = await _getPlayerConfigEmbed(videoId);
|
|
||||||
|
|
||||||
// If still null try from the watch page.
|
|
||||||
playerConfiguration ??= await _getPlayerConfigWatchPage(videoId);
|
|
||||||
|
|
||||||
if (playerConfiguration == null) {
|
|
||||||
throw VideoUnavailableException(videoId);
|
|
||||||
}
|
|
||||||
return playerConfiguration;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<PlayerConfiguration> _getPlayerConfigEmbed(String videoId) async {
|
|
||||||
var req = await client.get('https://www.youtube.com/embed/$videoId?&hl=en');
|
|
||||||
if (req.statusCode != 200) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
var body = req.body;
|
|
||||||
var document = html.parse(body);
|
|
||||||
var playerConfigRaw = document
|
|
||||||
.getElementsByTagName('script')
|
|
||||||
.map((e) => e.innerHtml)
|
|
||||||
.map((e) => _playerConfigExp?.firstMatch(e)?.group(1))
|
|
||||||
.firstWhere((s) => s?.trim()?.isNotEmpty ?? false);
|
|
||||||
var playerConfigJson = json.decode(playerConfigRaw);
|
|
||||||
|
|
||||||
// Extract player source URL.
|
|
||||||
var playerSourceUrl =
|
|
||||||
'https://youtube.com${playerConfigJson['assets']['js']}';
|
|
||||||
|
|
||||||
// Get video info dictionary.
|
|
||||||
var videoInfoDic = await getVideoInfoDictionary(videoId);
|
|
||||||
|
|
||||||
var playerResponseJson = json.decode(videoInfoDic['player_response']);
|
|
||||||
var playAbility = playerResponseJson['playabilityStatus'];
|
|
||||||
|
|
||||||
if (playAbility['status'].toString().toLowerCase() == 'error') {
|
|
||||||
throw VideoUnavailableException(videoId);
|
|
||||||
}
|
|
||||||
|
|
||||||
var errorReason = playAbility['reason'] as String;
|
|
||||||
|
|
||||||
// Valid configuration
|
|
||||||
if (errorReason.isNullOrWhiteSpace) {
|
|
||||||
var videoInfo = playerResponseJson['videoDetails'];
|
|
||||||
var video = Video(
|
|
||||||
videoId,
|
|
||||||
videoInfo['author'],
|
|
||||||
null,
|
|
||||||
videoInfo['title'],
|
|
||||||
videoInfo['shortDescription'],
|
|
||||||
ThumbnailSet(videoId),
|
|
||||||
Duration(seconds: int.parse(videoInfo['lengthSeconds'])),
|
|
||||||
videoInfo['keywords']?.cast<String>() ?? const <String>[],
|
|
||||||
Statistics(int.parse(videoInfo['viewCount']), 0, 0));
|
|
||||||
|
|
||||||
// Extract if it is a live stream.
|
|
||||||
var isLiveStream = playerResponseJson['videoDetails']['isLive'] == true;
|
|
||||||
|
|
||||||
var streamingData = playerResponseJson['streamingData'];
|
|
||||||
var validUntil = DateTime.now()
|
|
||||||
.add(Duration(seconds: int.parse(streamingData['expiresInSeconds'])));
|
|
||||||
var hlsManifestUrl =
|
|
||||||
isLiveStream ? streamingData['hlsManifestUrl'] : null;
|
|
||||||
var dashManifestUrl =
|
|
||||||
isLiveStream ? null : streamingData['dashManifestUrl'];
|
|
||||||
var muxedStreamInfosUrlEncoded =
|
|
||||||
isLiveStream ? null : videoInfoDic['url_encoded_fmt_stream_map'];
|
|
||||||
var adaptiveStreamInfosUrlEncoded =
|
|
||||||
isLiveStream ? null : videoInfoDic['adaptive_fmts'];
|
|
||||||
var muxedStreamInfosJson = isLiveStream ? null : streamingData['formats'];
|
|
||||||
var adaptiveStreamInfosJson =
|
|
||||||
isLiveStream ? null : streamingData['adaptiveFormats'];
|
|
||||||
|
|
||||||
return PlayerConfiguration(
|
|
||||||
playerSourceUrl,
|
|
||||||
dashManifestUrl,
|
|
||||||
hlsManifestUrl,
|
|
||||||
muxedStreamInfosUrlEncoded,
|
|
||||||
adaptiveStreamInfosUrlEncoded,
|
|
||||||
muxedStreamInfosJson,
|
|
||||||
adaptiveStreamInfosJson,
|
|
||||||
video,
|
|
||||||
validUntil);
|
|
||||||
}
|
|
||||||
|
|
||||||
var previewVideoId = playAbility['errorScreen']
|
|
||||||
['playerLegacyDesktopYpcTrailerRenderer']['trailerVideoId'] as String;
|
|
||||||
if (!previewVideoId.isNullOrWhiteSpace) {
|
|
||||||
throw VideoRequiresPurchaseException(videoId, previewVideoId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the video requires purchase - throw (approach two)
|
|
||||||
var previewVideoInfoRaw = playAbility['errorScreen']['ypcTrailerRenderer']
|
|
||||||
['playerVars'] as String;
|
|
||||||
|
|
||||||
if (!previewVideoInfoRaw.isNullOrWhiteSpace) {
|
|
||||||
var previewVideoInfoDic = Uri.splitQueryString(previewVideoInfoRaw);
|
|
||||||
var previewVideoId = previewVideoInfoDic['video_id'];
|
|
||||||
|
|
||||||
throw VideoRequiresPurchaseException(videoId, previewVideoId);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<PlayerConfiguration> _getPlayerConfigWatchPage(String videoId) async {
|
|
||||||
var videoWatchPage = await getVideoWatchPage(videoId);
|
|
||||||
if (videoWatchPage == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
var playerConfigScript = videoWatchPage
|
|
||||||
.querySelectorAll('script')
|
|
||||||
.map((e) => e.text)
|
|
||||||
.firstWhere((e) => e.contains('ytplayer.config ='));
|
|
||||||
if (playerConfigScript == null) {
|
|
||||||
var errorReason =
|
|
||||||
videoWatchPage.querySelector('#unavailable-message').text.trim();
|
|
||||||
throw VideoUnplayableException(videoId, errorReason);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workaround: Couldn't get RegExp to work. TODO: Find working regexp
|
|
||||||
var startIndex = playerConfigScript.indexOf('ytplayer.config =');
|
|
||||||
var endIndex = playerConfigScript.indexOf(';ytplayer.load =');
|
|
||||||
|
|
||||||
var playerConfigRaw =
|
|
||||||
playerConfigScript.substring(startIndex + 17, endIndex);
|
|
||||||
var playerConfigJson = json.decode(playerConfigRaw);
|
|
||||||
|
|
||||||
var playerResponseJson =
|
|
||||||
json.decode(playerConfigJson['args']['player_response']);
|
|
||||||
var playerSourceUrl =
|
|
||||||
'https://youtube.com${playerConfigJson['assets']['js']}';
|
|
||||||
|
|
||||||
var videoInfo = playerResponseJson['videoDetails'];
|
|
||||||
var video = Video(
|
|
||||||
videoId,
|
|
||||||
videoInfo['author'],
|
|
||||||
null,
|
|
||||||
videoInfo['title'],
|
|
||||||
videoInfo['shortDescription'],
|
|
||||||
ThumbnailSet(videoId),
|
|
||||||
Duration(seconds: int.parse(videoInfo['lengthSeconds'])),
|
|
||||||
videoInfo['keywords']?.cast<String>() ?? const <String>[],
|
|
||||||
Statistics(int.parse(videoInfo['viewCount']), 0, 0));
|
|
||||||
|
|
||||||
var isLiveStream = playerResponseJson['videoDetails']['isLive'] == true;
|
|
||||||
|
|
||||||
var streamingData = playerResponseJson['streamingData'];
|
|
||||||
var validUntil = DateTime.now()
|
|
||||||
.add(Duration(seconds: int.parse(streamingData['expiresInSeconds'])));
|
|
||||||
var hlsManifestUrl = isLiveStream ? streamingData['hlsManifestUrl'] : null;
|
|
||||||
var dashManifestUrl =
|
|
||||||
isLiveStream ? null : streamingData['dashManifestUrl'];
|
|
||||||
var muxedStreamInfosUrlEncoded = isLiveStream
|
|
||||||
? null
|
|
||||||
: playerConfigJson['args']['url_encoded_fmt_stream_map'];
|
|
||||||
var adaptiveStreamInfosUrlEncoded =
|
|
||||||
isLiveStream ? null : playerConfigJson['args']['adaptive_fmts'];
|
|
||||||
var muxedStreamInfosJson = isLiveStream ? null : streamingData['formats'];
|
|
||||||
var adaptiveStreamInfosJson =
|
|
||||||
isLiveStream ? null : streamingData['adaptiveFormats'];
|
|
||||||
|
|
||||||
return PlayerConfiguration(
|
|
||||||
playerSourceUrl,
|
|
||||||
dashManifestUrl,
|
|
||||||
hlsManifestUrl,
|
|
||||||
muxedStreamInfosUrlEncoded,
|
|
||||||
adaptiveStreamInfosUrlEncoded,
|
|
||||||
muxedStreamInfosJson,
|
|
||||||
adaptiveStreamInfosJson,
|
|
||||||
video,
|
|
||||||
validUntil);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the video info dictionary for a given video.
|
|
||||||
Future<Map<String, String>> getVideoInfoDictionary(String videoId) async {
|
|
||||||
var eurl = Uri.encodeComponent('https://youtube.googleapis.com/v/$videoId');
|
|
||||||
var url = 'https://youtube.com/get_video_info?video_id=$videoId'
|
|
||||||
'&el=embedded&eurl=$eurl&hl=en';
|
|
||||||
var raw = (await client.get(url)).body;
|
|
||||||
return Uri.splitQueryString(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a [Video] instance.
|
|
||||||
/// Use this to extract general info about a video.
|
|
||||||
Future<Video> getVideo(String id) async {
|
|
||||||
var videoId = parseVideoId(id);
|
|
||||||
if (videoId == null) {
|
|
||||||
throw ArgumentError.value(id, 'videoId', 'Invalid video id');
|
|
||||||
}
|
|
||||||
|
|
||||||
var videoInfoDic = await getVideoInfoDictionary(videoId);
|
|
||||||
var playerResponseJson = json.decode(videoInfoDic['player_response']);
|
|
||||||
var status = playerResponseJson['playabilityStatus']['status']
|
|
||||||
?.toLowerCase() as String;
|
|
||||||
|
|
||||||
if (status.isNullOrWhiteSpace || status == 'error') {
|
|
||||||
throw VideoUnavailableException(videoId);
|
|
||||||
}
|
|
||||||
|
|
||||||
var details = playerResponseJson['videoDetails'];
|
|
||||||
|
|
||||||
var title = details['title'];
|
|
||||||
var author = details['author'];
|
|
||||||
var description = details['shortDescription'];
|
|
||||||
var duration = Duration(seconds: int.parse(details['lengthSeconds']));
|
|
||||||
var keyWords = details['keywords']?.cast<String>() ?? const <String>[];
|
|
||||||
var viewCount = int.tryParse(details['viewCount'] ?? '0') ?? 0;
|
|
||||||
|
|
||||||
var videoPageHtml = await getVideoWatchPage(videoId);
|
|
||||||
var uploadDate = DateTime.parse(videoPageHtml
|
|
||||||
.querySelector('meta[itemprop="datePublished"]')
|
|
||||||
.attributes['content']);
|
|
||||||
var rawLikeCount = videoPageHtml
|
|
||||||
.querySelector('.like-button-renderer-like-button')
|
|
||||||
?.text ??
|
|
||||||
'0';
|
|
||||||
var likeCount = rawLikeCount.isNullOrWhiteSpace
|
|
||||||
? 0
|
|
||||||
: int.parse(rawLikeCount.stripNonDigits);
|
|
||||||
|
|
||||||
var rawDislikeCount = videoPageHtml
|
|
||||||
.querySelector('.like-button-renderer-dislike-button')
|
|
||||||
?.text ??
|
|
||||||
'0';
|
|
||||||
var dislikeCount = rawDislikeCount.isNullOrWhiteSpace
|
|
||||||
? 0
|
|
||||||
: int.parse(rawLikeCount.stripNonDigits);
|
|
||||||
|
|
||||||
var statistics = Statistics(viewCount, likeCount, dislikeCount);
|
|
||||||
var thumbnails = ThumbnailSet(videoId);
|
|
||||||
|
|
||||||
return Video(videoId, author, uploadDate, title, description, thumbnails,
|
|
||||||
duration, keyWords, statistics);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> _parseContentLength(
|
|
||||||
String contentLengthString, String url) async {
|
|
||||||
var contentLength = int.tryParse(contentLengthString ?? '') ?? -1;
|
|
||||||
|
|
||||||
if (contentLength <= 0 && !url.isNullOrWhiteSpace) {
|
|
||||||
contentLength = _contentLenExp?.firstMatch(url)?.group(1) ?? -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentLength <= 0 && !url.isNullOrWhiteSpace) {
|
|
||||||
contentLength = await _requestContentLength(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return contentLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> _requestContentLength(String url) async {
|
|
||||||
var resp;
|
|
||||||
try {
|
|
||||||
resp = await client.head(url);
|
|
||||||
} on Exception {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (!resp.headers.containsKey('content-length')) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
String contentLengthString = resp.headers['content-length'];
|
|
||||||
return int.tryParse(contentLengthString ?? '') ?? -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the video watch page document.
|
|
||||||
Future<Document> getVideoWatchPage(String videoId) async {
|
|
||||||
var url = 'https://youtube.com/watch?v=$videoId&bpctr=9999999999&hl=en';
|
|
||||||
var req = await client.get(url);
|
|
||||||
if (req.statusCode != 200) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return html.parse(req.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the given [videoId] is valid.
|
|
||||||
static bool validateVideoId(String videoId) {
|
|
||||||
if (videoId.isNullOrWhiteSpace) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoId.length != 11) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return !RegExp(r'[^0-9a-zA-Z_\-]').hasMatch(videoId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses a video id from url or if given a valid id as url returns itself.
|
|
||||||
/// Returns null if the id couldn't be extracted.
|
|
||||||
static String parseVideoId(String url) {
|
|
||||||
if (url.isNullOrWhiteSpace) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validateVideoId(url)) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://www.youtube.com/watch?v=yIVRs6YSbOM
|
|
||||||
var regMatch = _regMatchExp.firstMatch(url)?.group(1);
|
|
||||||
if (!regMatch.isNullOrWhiteSpace && validateVideoId(regMatch)) {
|
|
||||||
return regMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://youtu.be/yIVRs6YSbOM
|
|
||||||
var shortMatch = _shortMatchExp.firstMatch(url)?.group(1);
|
|
||||||
if (!shortMatch.isNullOrWhiteSpace && validateVideoId(shortMatch)) {
|
|
||||||
return shortMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://www.youtube.com/embed/yIVRs6YSbOM
|
|
||||||
var embedMatch = _embedMatchExp.firstMatch(url)?.group(1);
|
|
||||||
if (!embedMatch.isNullOrWhiteSpace && validateVideoId(embedMatch)) {
|
|
||||||
return embedMatch;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Closes the youtube explode's http client.
|
|
||||||
void close() {
|
|
||||||
client.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Export the extension static members. */
|
|
||||||
|
|
||||||
/// Parses a playlist [url] returning its id.
|
|
||||||
/// If the [url] is a valid it is returned itself.
|
|
||||||
static String parsePlaylistId(String url) =>
|
|
||||||
PlaylistExtension.parsePlaylistId(url);
|
|
||||||
|
|
||||||
/// Returns true if [username] is a valid Youtube username.
|
|
||||||
static bool validateUsername(String username) =>
|
|
||||||
ChannelExtension.validateUsername(username);
|
|
||||||
|
|
||||||
/// Parses a username from an url.
|
|
||||||
/// Returns null if the username is not found.
|
|
||||||
static String parseUsername(String url) =>
|
|
||||||
ChannelExtension.parseUsername(url);
|
|
||||||
|
|
||||||
/// Returns true if [channelId] is a valid Youtube channel id.
|
|
||||||
static bool validateChannelId(String channelId) =>
|
|
||||||
ChannelExtension.validateChannelId(channelId);
|
|
||||||
|
|
||||||
/// Parses a channel id from an url.
|
|
||||||
/// Returns null if the username is not found.
|
|
||||||
static String parseChannelId(String url) =>
|
|
||||||
ChannelExtension.parseChannelId(url);
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
name: youtube_explode_dart
|
name: youtube_explode_dart
|
||||||
description: A port in dart of the youtube explode library. Supports several API functions.
|
description: A port in dart of the youtube explode library. Supports several API functions.
|
||||||
version: 0.0.16
|
version: 0.0.17
|
||||||
homepage: https://github.com/Hexer10/youtube_explode_dart
|
homepage: https://github.com/Hexer10/youtube_explode_dart
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
@ -14,6 +14,10 @@ dependencies:
|
||||||
equatable: ^1.1.0
|
equatable: ^1.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
quick_log: ^0.4.1
|
||||||
|
|
||||||
|
dart_benchmark:
|
||||||
|
path: ../repos/dart_benchmark
|
||||||
effective_dart: ^1.2.1
|
effective_dart: ^1.2.1
|
||||||
dart_console: ^0.5.0
|
dart_console: ^0.5.0
|
||||||
test: ^1.12.0
|
test: ^1.12.0
|
||||||
|
|
|
@ -1,33 +1,7 @@
|
||||||
//void main() {
|
import 'dart:convert';
|
||||||
// e().catchError(
|
|
||||||
// (onError) {
|
|
||||||
// print("called when there is an error catches error");
|
|
||||||
// return Future.error('error');
|
|
||||||
// },
|
|
||||||
// ).then((value) {
|
|
||||||
// print("called with value = null");
|
|
||||||
// }).whenComplete(() {
|
|
||||||
// print("called when future completes");
|
|
||||||
// });
|
|
||||||
//}
|
|
||||||
|
|
||||||
void main() async {
|
import 'package:youtube_explode_dart/src/videos/video_id.dart';
|
||||||
try {
|
|
||||||
await someFuture();
|
|
||||||
} catch (e) {
|
|
||||||
print("called when there is an error catches error: $e");
|
|
||||||
try {
|
|
||||||
print("called with value = null");
|
|
||||||
} finally {
|
|
||||||
print("called when future completes");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future f() {
|
void main() {
|
||||||
return Future.value(5);
|
var x = VideoId('asd');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future someFuture() {
|
|
||||||
return Future.error('Error occured');
|
|
||||||
}
|
|
Loading…
Reference in New Issue