Working v5

This commit is contained in:
Hexah 2020-06-03 23:02:21 +02:00
parent 0cfdb5e575
commit c7bbbf0d24
78 changed files with 602 additions and 1639 deletions

View File

@ -1,12 +1,8 @@
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
Future<void> main() async {
// Parse the video id from the url.
var id = YoutubeExplode.parseVideoId(
'https://www.youtube.com/watch?v=bo_efYhYU2A');
var yt = YoutubeExplode();
var video = await yt.getVideo(id);
var video = await yt.videos.get(VideoId('https://www.youtube.com/watch?v=bo_efYhYU2A'));
print('Title: ${video.title}');

View File

@ -1,9 +1,10 @@
import '../reverse_engineering/reverse_engineering.dart';
import '../extensions/helpers_extension.dart';
import '../reverse_engineering/responses/responses.dart';
import '../reverse_engineering/youtube_http_client.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 {

View File

@ -0,0 +1,6 @@
library youtube_explode.channels;
export 'channel.dart';
export 'channel_client.dart';
export 'channel_id.dart';
export 'username.dart';

View File

@ -0,0 +1,4 @@
library youtube_explode.common;
export 'engagement.dart';
export 'thumbnail_set.dart';

View File

@ -1,7 +1,7 @@
import 'package:equatable/equatable.dart';
/// User activity statistics.
class Statistics extends Equatable {
class Engagement extends Equatable {
/// View count.
final int viewCount;
@ -12,7 +12,7 @@ class Statistics extends Equatable {
final int dislikeCount;
/// Initializes an instance of [Statistics]
const Statistics(this.viewCount, this.likeCount, this.dislikeCount);
const Engagement(this.viewCount, this.likeCount, this.dislikeCount);
/// Average user rating in stars (1 star to 5 stars).
num get avgRating {

View File

@ -12,7 +12,7 @@ class FatalFailureException implements YoutubeExplodeException {
FatalFailureException(this.message);
/// Initializes an instance of [FatalFailureException] with a [Response]
FatalFailureException.httpRequest(Response response)
FatalFailureException.httpRequest(BaseResponse response)
: message = '''
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.

View File

@ -3,16 +3,17 @@ import 'package:http/http.dart';
import 'youtube_explode_exception.dart';
/// Exception thrown when a fatal failure occurs.
class RequestLimitExceeded implements YoutubeExplodeException {
class RequestLimitExceededException implements YoutubeExplodeException {
/// Description message
@override
final String message;
/// Initializes an instance of [RequestLimitExceeded]
RequestLimitExceeded(this.message);
/// Initializes an instance of [RequestLimitExceededException]
RequestLimitExceededException(this.message);
/// Initializes an instance of [RequestLimitExceeded] with a [Response]
RequestLimitExceeded.httpRequest(Response response)
RequestLimitExceededException.httpRequest(BaseResponse response)
: message = '''
Failed to perform an HTTP request to YouTube because of rate limiting.
This error indicates that YouTube thinks there were too many requests made from this IP and considers it suspicious.

View File

@ -10,7 +10,7 @@ class TransientFailureException implements YoutubeExplodeException {
TransientFailureException(this.message);
/// Initializes an instance of [TransientFailureException] with a [Response]
TransientFailureException.httpRequest(Response response)
TransientFailureException.httpRequest(BaseResponse response)
: message = '''
Failed to perform an HTTP request to YouTube due to a transient failure.
In most cases, this error indicates that the problem is on YouTube's side and this is not a bug in the library.

View File

@ -1,5 +1,5 @@
import '../videos/video_id.dart';
import 'exceptions.dart';
import '../models/models.dart';
/// Thrown when a video is not available and cannot be processed.
/// This can happen because the video does not exist, is deleted,

View File

@ -1,5 +1,6 @@
/// Parent class for domain exceptions thrown by [YoutubeExplode]
abstract class YoutubeExplodeException implements Exception {
final String message;
YoutubeExplodeException(this.message);
}

View File

@ -1,102 +0,0 @@
import 'dart:convert';
import 'package:xml/xml.dart' as xml;
import '../exceptions/exceptions.dart';
import '../models/models.dart';
import '../youtube_explode_base.dart';
import 'helpers_extension.dart';
/// Caption extension for [YoutubeExplode]
extension CaptionExtension on YoutubeExplode {
/// Gets all available closed caption track infos for given video.
/// Returns an empty list of no caption is available.
Future<List<ClosedCaptionTrackInfo>> getVideoClosedCaptionTrackInfos(
String videoId) async {
if (!YoutubeExplode.validateVideoId(videoId)) {
throw ArgumentError.value(videoId, 'videoId', 'Invalid video id');
}
var videoInfoDic = await getVideoInfoDictionary(videoId);
var playerResponseJson = json.decode(videoInfoDic['player_response']);
var playAbility = playerResponseJson['playabilityStatus'];
if (playAbility['status'].toLowerCase() == 'error') {
throw VideoUnavailableException(videoId);
}
var captionTracks = playerResponseJson['captions'];
if (captionTracks == null) {
return const [];
}
var trackInfos = <ClosedCaptionTrackInfo>[];
for (var trackJson in captionTracks['playerCaptionsTracklistRenderer']
['captionTracks']) {
var url = Uri.parse(trackJson['baseUrl']);
var query = Map<String, String>.from(url.queryParameters);
query['format'] = '3';
url = url.replace(queryParameters: query);
var languageCode = trackJson['languageCode'];
var languageName = trackJson['name']['simpleText'];
var language = Language(languageCode, languageName);
var isAutoGenerated = trackJson['vssId'].toLowerCase().startsWith('a.');
trackInfos.add(ClosedCaptionTrackInfo(url, language,
isAutoGenerated: isAutoGenerated));
}
return trackInfos;
}
Future<xml.XmlDocument> _getClosedCaptionTrackXml(Uri url) async {
var raw = (await client.get(url)).body;
return xml.parse(raw);
}
/// Gets the closed caption track associated with given metadata.
Future<ClosedCaptionTrack> getClosedCaptionTrack(
ClosedCaptionTrackInfo info) async {
var trackXml = await _getClosedCaptionTrackXml(info.url);
var captions = <ClosedCaption>[];
for (var captionXml in trackXml.findElements('p')) {
var text = captionXml.text;
if (text.isNullOrWhiteSpace) {
continue;
}
var offset =
Duration(milliseconds: int.parse(captionXml.getAttribute('t')));
var duration = Duration(
milliseconds: int.parse(captionXml.getAttribute('d') ?? '-1'));
captions.add(ClosedCaption(text, offset, duration));
}
return ClosedCaptionTrack(info, captions);
}
}
/// Extension for List of [ClosedCaptions]
extension CaptionListExtension on List<ClosedCaption> {
/// Get the [ClosedCaption] displayed at [time].
/// [time] can be an [int] (time in seconds) or a [Duration].
ClosedCaption getByTime(dynamic time) {
Duration duration;
if (time is int) {
duration = Duration(seconds: time);
} else {
duration = time;
}
return firstWhere((e) => e.start <= duration && duration <= e.end);
}
}

View File

@ -1,159 +0,0 @@
import 'package:html/dom.dart';
import 'package:html/parser.dart' as html;
import '../models/models.dart';
import '../youtube_explode_base.dart';
import 'helpers_extension.dart';
import 'playlist_extension.dart';
/// Channel extension for [YoutubeExplode]
extension ChannelExtension on YoutubeExplode {
static final _usernameRegMatchExp =
RegExp(r'youtube\..+?/user/(.*?)(?:\?|&|/|$)');
static final _idRegMatchExp =
RegExp(r'youtube\..+?/channel/(.*?)(?:\?|&|/|$)');
/// Returns the [Channel] associated with the given channelId.
/// Throws an [ArgumentError] if the channel id is not valid.
Future<Channel> getChannel(String channelId) async {
if (!validateChannelId(channelId)) {
throw ArgumentError.value(
channelId, 'channelId', 'Invalid YouTube channel id');
}
var channelPage = await getChannelPage(channelId);
var channelTitle = channelPage
.querySelector('meta[property="og:title"]')
.attributes['content'];
var channelImage = channelPage
.querySelector('meta[property="og:image"]')
.attributes['content'];
return Channel(channelId, channelTitle, Uri.parse(channelImage));
}
/// Get a channel id from a username.
/// Returns null if the username is not found.
Future<String> getChannelId(String username) async {
if (!validateUsername(username)) {
throw ArgumentError.value(
username, 'username', 'Invalid YouTube username');
}
var userPage = await _getUserPage(username);
if (userPage == null) {
return null;
}
var channelUrl =
userPage.querySelector('meta[property="og:url"]').attributes['content'];
return channelUrl.replaceFirst('/channel/', '');
}
/// Returns all the videos uploaded by a channel up to [maxPages] count.
Future<List<Video>> getChannelUploads(String channelId,
[int maxPages = 5]) async {
if (!validateChannelId(channelId)) {
throw ArgumentError.value(
channelId, 'channelId', 'Invalid YouTube channel id');
}
var playlistId = 'UU${channelId.replaceFirst('UC', '')}';
var playlist = await getPlaylist(playlistId, maxPages);
return playlist.videos;
}
/// Returns the channel id for a given video.
Future<String> getChannelIdFromVideo(String videoId) async {
if (!YoutubeExplode.validateVideoId(videoId)) {
throw ArgumentError.value(videoId, 'videoId', 'Invalid YouTube video id');
}
var watchPage = await getVideoWatchPage(videoId);
var href = watchPage
.querySelector('.yt-user-info')
.querySelector('a')
.attributes['href'];
return href.replaceFirst('/channel/', '');
}
/// Returns the channel page document.
Future<Document> getChannelPage(String channelId) async {
var url = 'https://www.youtube.com/channel/$channelId?hl=en';
var raw = (await client.get(url)).body;
return html.parse(raw);
}
Future<Document> _getUserPage(String username) async {
var url = 'https://www.youtube.com/user/$username?hl=en';
var req = await client.get(url);
if (req.statusCode != 200) {
return null;
}
return html.parse(req);
}
/// Returns true if [username] is a valid Youtube username.
static bool validateUsername(String username) {
if (username.isNullOrWhiteSpace) {
return false;
}
if (username.length > 20) {
return false;
}
return !RegExp(r'[^0-9a-zA-Z]').hasMatch(username);
}
/// Parses a username from an url.
/// Returns null if the username is not found.
static String parseUsername(String url) {
if (url.isNullOrWhiteSpace) {
return null;
}
var regMatch = _usernameRegMatchExp.firstMatch(url)?.group(1);
if (!regMatch.isNullOrWhiteSpace && validateUsername(regMatch)) {
return regMatch;
}
return null;
}
/// Returns true if [channelId] is a valid Youtube channel id.
static bool validateChannelId(String channelId) {
if (channelId.isNullOrWhiteSpace) {
return false;
}
channelId = channelId.toLowerCase();
if (!channelId.startsWith('uc')) {
return false;
}
if (channelId.length != 24) {
return false;
}
return !RegExp(r'[^0-9a-zA-Z_\-]').hasMatch(channelId);
}
/// 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;
}
var regMatch = _idRegMatchExp.firstMatch(url)?.group(1);
if (!regMatch.isNullOrWhiteSpace && validateChannelId(regMatch)) {
return regMatch;
}
return null;
}
}

View File

@ -1,32 +0,0 @@
import 'dart:async';
import 'package:http/http.dart' as http;
import '../exceptions/exceptions.dart';
import '../models/models.dart';
/// Download extension for [MediaStreamInfo]
extension DownloadExtension on MediaStreamInfo {
static final _rateBypassExp = RegExp('ratebypass[=/]yes');
/// Returns the stream of this media stream object.
/// The download is split in multiple requests using the `range` parameter.
///
Stream<List<int>> downloadStream() async* {
var req = await http.head(url);
if (req.statusCode != 200) {
throw VideoStreamUnavailableException(req.statusCode, url);
}
var maxSize = _rateBypassExp.hasMatch(url.toString()) ? 9898989 : size + 1;
var total = 0;
for (var i = 1; total < size; i++) {
var req = http.Request('get', url);
req.headers['range'] = 'bytes=$total-${total + maxSize}';
var resp = await req.send();
yield* resp.stream;
total += maxSize + 1;
}
}
}

View File

@ -1,8 +0,0 @@
library youtube_explode.extensions;
export 'caption_extension.dart';
export 'channel_extension.dart';
export 'download_extension.dart';
export 'helpers_extension.dart';
export 'playlist_extension.dart';
export 'search_extension.dart';

View File

@ -1,3 +1,5 @@
library _youtube_explode.extensions;
import '../reverse_engineering/cipher/cipher_operations.dart';
/// Utility for Strings.

View File

@ -1,156 +0,0 @@
import 'dart:convert';
import '../models/models.dart';
import '../parser.dart' as parser;
import '../youtube_explode_base.dart';
import 'helpers_extension.dart';
/// Playlist extension for [YoutubeExplode]
extension PlaylistExtension on YoutubeExplode {
static final _regMatchExp =
RegExp(r'youtube\..+?/playlist.*?list=(.*?)(?:&|/|$)');
static final _compositeMatchExp = RegExp(
r'https://www.youtube.com/watch?v=b8m9zhNAgKs&list=PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr');
static final _shortCompositeMatchExp =
RegExp(r'youtu\.be/.*?/.*?list=(.*?)(?:&|/|$)');
static final _embedCompositeMatchExp =
RegExp(r'youtube\..+?/embed/.*?/.*?list=(.*?)(?:&|/|$)');
Future<Map<String, dynamic>> _getPlayListJson(
String playlistId, int index) async {
var url =
'https://youtube.com/list_ajax?style=json&action_get_list=1&list=$playlistId&index=$index&hl=en';
var raw = (await client.get(url)).body;
return json.decode(raw);
}
/// Returns a [Future] that completes with a [Playlist].
/// If the id is not valid an [ArgumentError] is thrown.
Future<Playlist> getPlaylist(String playlistId, [int maxPages = 500]) async {
if (!validatePlaylistId(playlistId)) {
throw ArgumentError.value(playlistId, 'videoId', 'Invalid video id');
}
Map<String, dynamic> playlistJson;
var page = 1;
var index = 0;
var videoIds = <String>[];
var videos = <Video>[];
do {
playlistJson = await _getPlayListJson(playlistId, index);
var countDelta = 0;
for (var videoJson in playlistJson['video'] as List<dynamic>) {
var videoId = videoJson['encrypted_id'];
var author = videoJson['author'];
var uploadDate = DateTime.fromMillisecondsSinceEpoch(
videoJson['time_created'] * 1000);
var title = videoJson['title'];
var description = videoJson['description'];
var duration = Duration(seconds: videoJson['length_seconds']);
var viewCount =
int.parse((videoJson['views'] as String).stripNonDigits);
var likeCount = videoJson['likes'];
var dislikeCount = videoJson['dislikes'];
var keyWords = RegExp(r'"[^\"]+"|\S+')
.allMatches(videoJson['keywords'])
.map((e) => e.group(0))
.toList();
var statistics = Statistics(viewCount, likeCount, dislikeCount);
var thumbnails = ThumbnailSet(videoId);
if (!videoIds.contains(videoId)) {
videoIds.add(videoId);
videos.add(Video(videoId, author, uploadDate, title, description,
thumbnails, duration, keyWords, statistics));
countDelta++;
}
}
if (countDelta <= 0) {
break;
}
index += countDelta;
page++;
} while (page <= maxPages);
var author = playlistJson['author'];
var title = playlistJson['title'];
var description = playlistJson['description'];
var viewCount = playlistJson['views'] ?? 0;
var likeCount = playlistJson['likes'] ?? 0;
var dislikeCount = playlistJson['dislikes'] ?? 0;
var type = parser.playlistTypeFromId(playlistId);
var statistics = Statistics(viewCount, likeCount, dislikeCount);
return Playlist(
playlistId, author, title, type, description, statistics, videos);
}
/// Returns true if the given [playlistId] is valid.
static bool validatePlaylistId(String playlistId) {
playlistId = playlistId.toLowerCase();
if (playlistId.isNullOrWhiteSpace) {
return false;
}
// Watch later
if (playlistId == 'wl') {
return true;
}
// My mix playlist
if (playlistId == 'rdmm') {
return true;
}
if (!playlistId.startsWith('pl') &&
!playlistId.startsWith('rd') &&
!playlistId.startsWith('ul') &&
!playlistId.startsWith('uu') &&
!playlistId.startsWith('pu') &&
!playlistId.startsWith('ol') &&
!playlistId.startsWith('ll') &&
!playlistId.startsWith('fl')) {
return false;
}
if (playlistId.length < 13) {
return false;
}
return !RegExp(r'[^0-9a-zA-Z_\-]').hasMatch(playlistId);
}
/// Parses a playlist [url] returning its id.
/// If the [url] is a valid it is returned itself.
static String parsePlaylistId(String url) {
if (url.isNullOrWhiteSpace) {
return null;
}
var regMatch = _regMatchExp.firstMatch(url)?.group(1);
if (!regMatch.isNullOrWhiteSpace && validatePlaylistId(regMatch)) {
return regMatch;
}
var compositeMatch = _compositeMatchExp.firstMatch(url)?.group(1);
if (!compositeMatch.isNullOrWhiteSpace &&
validatePlaylistId(compositeMatch)) {
return compositeMatch;
}
var shortCompositeMatch = _shortCompositeMatchExp.firstMatch(url)?.group(1);
if (!shortCompositeMatch.isNullOrWhiteSpace &&
validatePlaylistId(shortCompositeMatch)) {
return shortCompositeMatch;
}
var embedCompositeMatch = _embedCompositeMatchExp.firstMatch(url)?.group(1);
if (!embedCompositeMatch.isNullOrWhiteSpace &&
validatePlaylistId(embedCompositeMatch)) {
return embedCompositeMatch;
}
return null;
}
}

View File

@ -1,58 +0,0 @@
import 'dart:convert';
import '../models/models.dart';
import '../youtube_explode_base.dart';
import 'helpers_extension.dart';
/// Search extension for [YoutubeExplode]
extension SearchExtension on YoutubeExplode {
Future<Map<String, dynamic>> _getSearchResults(String query, int page) async {
var url =
'https://youtube.com/search_ajax?style=json&search_query=${Uri.encodeQueryComponent(query)}&page=$page&hl=en';
var raw = (await client.get(url)).body;
return json.decode(raw);
}
/// Searches videos using given query up to [maxPages] count.
Future<List<Video>> searchVideos(String query, [int maxPages = 5]) async {
var videos = <Video>[];
for (var page = 1; page <= maxPages; page++) {
var resultsJson = await _getSearchResults(query, page);
var countDelta = 0;
var videosJson = resultsJson['video'] as List<dynamic>;
if (videosJson == null) {
break;
}
for (var videoJson in videosJson) {
var id = videoJson['encrypted_id'];
var author = videoJson['author'];
var uploadDate = DateTime.fromMillisecondsSinceEpoch(1581602398 * 1000);
var title = videoJson['title'];
var description = videoJson['description'];
var duration = Duration(seconds: videoJson['length_seconds']);
var viewCount =
int.parse((videoJson['views'] as String).stripNonDigits);
var likeCount = videoJson['likes'];
var dislikeCount = videoJson['dislikes'];
var keyWords = RegExp(r'"[^\"]+"|\S+')
.allMatches(videoJson['keywords'])
.map((e) => e.group(0))
.toList();
var statistics = Statistics(viewCount, likeCount, dislikeCount);
var thumbnails = ThumbnailSet(id);
videos.add(Video(id, author, uploadDate, title, description, thumbnails,
duration, keyWords, statistics));
countDelta++;
}
if (countDelta <= 0) {
break;
}
}
return videos;
}
}

View File

@ -1,19 +0,0 @@
import 'package:equatable/equatable.dart';
/// Information about a YouTube channel.
class Channel extends Equatable {
/// ID of this channel.
final String id;
/// Title of this channel.
final String title;
/// Logo image URL of this channel.
final Uri logoUrl;
/// Initializes an instance of [Channel]
const Channel(this.id, this.title, this.logoUrl);
@override
List<Object> get props => [id, title, logoUrl];
}

View File

@ -1,30 +0,0 @@
import 'package:equatable/equatable.dart';
/// Text that gets displayed at specific time during video playback,
/// as part of a [ClosedCaptionTrack]
class ClosedCaption extends Equatable {
/// Text displayed by this caption.
final String text;
/// Time at which this caption starts being displayed.
final Duration offset;
/// Duration this caption is displayed.
/// Negative if not found.
final Duration duration;
/// Initializes an instance of [ClosedCaption]
const ClosedCaption(this.text, this.offset, this.duration);
/// Time at which this caption starts being displayed.
Duration get start => offset;
/// Time at which this caption ends being displayed.
Duration get end => duration + offset;
@override
String toString() => 'Caption: $text ($offset - $end)';
@override
List<Object> get props => [text, offset, duration];
}

View File

@ -1,18 +0,0 @@
import 'package:equatable/equatable.dart';
import '../models.dart';
/// Set of captions that get displayed during video playback.
class ClosedCaptionTrack extends Equatable {
/// Metadata associated with this track.
final ClosedCaptionTrackInfo info;
/// Collection of closed captions that belong to this track.
final List<ClosedCaption> captions;
/// Initializes an instance of [ClosedCaptionTrack]
const ClosedCaptionTrack(this.info, this.captions);
@override
List<Object> get props => [info, captions];
}

View File

@ -1,11 +0,0 @@
/// AudioEncoding
enum AudioEncoding {
/// MPEG-4 Part 3, Advanced Audio Coding (AAC).
aac,
/// Vorbis.
vorbis,
/// Opus.
opus
}

View File

@ -1,18 +0,0 @@
import '../models.dart';
/// Metadata associated with a certain [MediaStream] that contains only audio.
class AudioStreamInfo extends MediaStreamInfo {
/// Bitrate (bits/s) of the associated stream.
final int bitrate;
/// Audio encoding of the associated stream.
final AudioEncoding audioEncoding;
/// Initializes an instance of [AudioStreamInfo]
AudioStreamInfo(int tag, Uri url, Container container, int size, this.bitrate,
this.audioEncoding)
: super(tag, url, container, size);
@override
List<Object> get props => super.props..addAll([bitrate, audioEncoding]);
}

View File

@ -1,11 +0,0 @@
/// Media stream container type.
enum Container {
/// MPEG-4 Part 14 (.mp4).
mp4,
/// Web Media (.webm).
webM,
/// 3rd Generation Partnership Project (.3gpp).
tgpp
}

View File

@ -1,27 +0,0 @@
import 'package:equatable/equatable.dart';
import '../models.dart';
/// Metadata associated with a certain [MediaStream]
class MediaStreamInfo extends Equatable {
/// Unique tag that identifies the properties of the associated stream.
final int itag;
/// URL of the endpoint that serves the associated stream.
final Uri url;
/// Container of the associated stream.
final Container container;
/// Content length (bytes) of the associated stream.
final int size;
/// Initializes an instance of [MediaStreamInfo]
const MediaStreamInfo(this.itag, this.url, this.container, this.size);
@override
String toString() => '$itag ($container)';
@override
List<Object> get props => [itag, url, container, size];
}

View File

@ -1,34 +0,0 @@
import 'package:equatable/equatable.dart';
import '../models.dart';
/// Set of all available media stream infos.
class MediaStreamInfoSet extends Equatable {
/// Muxed streams.
final List<MuxedStreamInfo> muxed;
/// Audio-only streams.
final List<AudioStreamInfo> audio;
/// Video-only streams.
final List<VideoStreamInfo> video;
/// Raw HTTP Live Streaming (HLS) URL to the m3u8 playlist.
/// Null if not a live stream.
final String hlsLiveStreamUrl;
/// Video details associated with the stream info set.
/// Some values might be null.
final Video videoDetails;
/// Date until this media set is valid.
final DateTime validUntil;
/// Initializes an instance of [MediaStreamInfoSet].
const MediaStreamInfoSet(this.muxed, this.audio, this.video,
this.hlsLiveStreamUrl, this.videoDetails, this.validUntil);
@override
List<Object> get props =>
[muxed, audio, video, hlsLiveStreamUrl, videoDetails, validUntil];
}

View File

@ -1,43 +0,0 @@
import '../models.dart';
/// Metadata associated with a certain [MediaStream]
/// that contains both audio and video.
class MuxedStreamInfo extends MediaStreamInfo {
/// Audio encoding of the associated stream.
final AudioEncoding audioEncoding;
/// Video encoding of the associated stream.
final VideoEncoding videoEncoding;
/// Video quality label of the associated stream.
final String videoQualityLabel;
/// Video quality of the associated stream.
final VideoQuality videoQuality;
/// Video resolution of the associated stream.
final VideoResolution videoResolution;
/// Initializes an instance of [MuxedStreamInfo]
const MuxedStreamInfo(
int itag,
Uri url,
Container container,
int size,
this.audioEncoding,
this.videoEncoding,
this.videoQualityLabel,
this.videoQuality,
this.videoResolution)
: super(itag, url, container, size);
@override
List<Object> get props => super.props
..addAll([
audioEncoding,
videoEncoding,
videoQualityLabel,
videoQuality,
videoResolution
]);
}

View File

@ -1,21 +0,0 @@
/// Video encoding.
enum VideoEncoding {
/// MPEG-4 Part 2.
mp4v,
@Deprecated('Not available anymore')
/// H263.
h263,
/// MPEG-4 Part 10, H264, Advanced Video Coding (AVC).
h264,
/// VP8.
vp8,
/// VP9.
vp9,
/// AV1.
av1
}

View File

@ -1,35 +0,0 @@
/// 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
}

View File

@ -1,19 +0,0 @@
import 'package:equatable/equatable.dart';
/// Width and height of a video.
class VideoResolution extends Equatable {
/// 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';
@override
List<Object> get props => [width, height];
}

View File

@ -1,47 +0,0 @@
import '../models.dart';
/// Metadata associated with a certain [MediaStream]that contains only video.
class VideoStreamInfo extends MediaStreamInfo {
/// Bitrate (bits/s) of the associated stream.
final int bitrate;
/// Video encoding of the associated stream.
final VideoEncoding videoEncoding;
/// Video quality label of the associated stream.
final String videoQualityLabel;
/// Video quality of the associated stream.
final VideoQuality videoQuality;
/// Video resolution of the associated stream.
final VideoResolution videoResolution;
/// Video framerate (FPS) of the associated stream.
final int framerate;
/// Initializes an instance of [VideoStreamInfo]
const VideoStreamInfo(
int itag,
Uri url,
Container container,
int size,
this.bitrate,
this.videoEncoding,
this.videoQualityLabel,
this.videoQuality,
this.videoResolution,
this.framerate)
: super(itag, url, container, size);
@override
List<Object> get props => super.props
..addAll([
bitrate,
videoEncoding,
videoQualityLabel,
videoQuality,
videoResolution,
framerate
]);
}

View File

@ -1,24 +0,0 @@
library youtube_explode.models;
export 'channel.dart';
export 'closed_captions/closed_caption.dart';
export 'closed_captions/closed_caption_track.dart';
export 'closed_captions/closed_caption_track_info.dart';
export 'closed_captions/language.dart';
export 'media_streams/audio_encoding.dart';
export 'media_streams/audio_stream_info.dart';
export 'media_streams/container.dart';
export 'media_streams/media_stream_info.dart';
export 'media_streams/media_stream_info_set.dart';
export 'media_streams/muxed_stream_info.dart';
export 'media_streams/video_encoding.dart';
export 'media_streams/video_quality.dart';
export 'media_streams/video_resolution.dart';
export 'media_streams/video_stream_info.dart';
export 'player_configuration.dart';
export 'playlist.dart';
export 'playlist_type.dart';
export 'statistics.dart';
export 'thumbnail_set.dart';
export 'video.dart';
export 'video_id.dart';

View File

@ -1,58 +0,0 @@
import 'package:equatable/equatable.dart';
import 'models.dart';
/// Player configuration.
class PlayerConfiguration extends Equatable {
/// Player source url.
final String playerSourceUrl;
/// Dash manifest url.
final String dashManifestUrl;
/// Live stream raw url.
final String hlsManifestUrl;
/// Muxed stream url encoded.
final String muxedStreamInfosUrlEncoded;
/// Adaptive stream url encoded.
final String adaptiveStreamInfosUrlEncoded;
/// Muxed stream info.
final List<dynamic> muxedStreamInfoJson;
/// Adaptive stream info.
final List<dynamic> adaptiveStreamInfosJson;
/// Video associated with this player configuration.
final Video video;
/// Date until this player configuration is valid.
final DateTime validUntil;
/// Initializes an instance of [PlayerConfiguration]
const PlayerConfiguration(
this.playerSourceUrl,
this.dashManifestUrl,
this.hlsManifestUrl,
this.muxedStreamInfosUrlEncoded,
this.adaptiveStreamInfosUrlEncoded,
this.muxedStreamInfoJson,
this.adaptiveStreamInfosJson,
this.video,
this.validUntil);
@override
List<Object> get props => [
playerSourceUrl,
dashManifestUrl,
hlsManifestUrl,
muxedStreamInfosUrlEncoded,
adaptiveStreamInfosUrlEncoded,
muxedStreamInfoJson,
adaptiveStreamInfosJson,
video,
validUntil
];
}

View File

@ -1,35 +0,0 @@
import 'package:equatable/equatable.dart';
import 'models.dart';
/// Information about a YouTube playlist.
class Playlist extends Equatable {
/// ID of this playlist.
final String id;
/// Author of this playlist.
final String author;
/// Title of this playlist.
final String title;
/// The type of this playlist.
final PlaylistType type;
/// Description of this playlist.
final String description;
/// Statistics of this playlist.
final Statistics statistics;
/// Collection of videos contained in this playlist.
final List<Video> videos;
/// Initializes an instance of [Playlist]
Playlist(this.id, this.author, this.title, this.type, this.description,
this.statistics, this.videos);
@override
List<Object> get props =>
[id, author, title, type, description, statistics, videos];
}

View File

@ -1,30 +0,0 @@
/// Playlist type.
enum PlaylistType {
/// Regular playlist created by a user.
normal,
/// Mix playlist generated to group similar videos.
videoMix,
/// Mix playlist generated to group similar videos uploaded
/// by the same channel.
channelVideoMix,
/// Playlist generated from channel uploads.
channelVideos,
/// Playlist generated from popular channel uploads.
popularChannelVideos,
/// Playlist generated from automated music videos.
musicAlbum,
/// System playlist for videos liked by a user.
likedVideos,
/// System playlist for videos favorited by a user.
favorites,
/// System playlist for videos user added to watch later.
watchLater,
}

View File

@ -1,62 +0,0 @@
import 'package:equatable/equatable.dart';
import 'models.dart';
/// Information about a YouTube video.
class Video extends Equatable {
/// ID of this video.
final String id;
/// Author of this video.
final String author;
/// Upload date of this video.
/// null for [MediaStreamInfoSet.videoDetails]
final DateTime uploadDate;
/// Title of this video.
final String title;
/// Description of this video.
final String description;
/// Thumbnails of this video.
final ThumbnailSet thumbnailSet;
/// Duration of this video.
final Duration duration;
/// Search keywords of this video.
final List<String> keyWords;
/// Statistics of this video.
final Statistics statistics;
/// Initializes an instance of [Video]
const Video(
this.id,
this.author,
this.uploadDate,
this.title,
this.description,
this.thumbnailSet,
this.duration,
this.keyWords,
this.statistics);
@override
String toString() => 'Video($id): $title';
@override
List<Object> get props => [
id,
author,
uploadDate,
title,
description,
thumbnailSet,
duration,
keyWords,
statistics
];
}

View File

@ -1,70 +0,0 @@
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 url)
: value = parseVideoId(url) ??
ArgumentError('Invalid YouTube video ID or URL: $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;
}
}

View File

@ -1,290 +0,0 @@
import 'models/models.dart';
/// Parse the string as [Container].
/// Throws an [ArgumentError] if the string matches no container.
Container stringToContainer(String str) {
str = str.toLowerCase().trim(); // Case-insensitive.
if (str == 'mp4') {
return Container.mp4;
}
if (str == 'webm') {
return Container.webM;
}
if (str == '3gpp') {
return Container.tgpp;
}
throw ArgumentError.value(str, 'str', 'Unrecognized container');
}
/// Parse the string as [AudioEncoding].
/// /// Throws an [ArgumentError] if the string matches no audio encoding.
AudioEncoding audioEncodingFromString(String str) {
str = str.toLowerCase().trim();
if (str.startsWith('mp4a')) {
return AudioEncoding.aac;
}
if (str.startsWith('vorbis')) {
return AudioEncoding.vorbis;
}
if (str.startsWith('opus')) {
return AudioEncoding.opus;
}
throw ArgumentError.value(str, 'str', 'Unrecognized audio encoding');
}
/// Parses the string as [VideoEncoding].
/// Throws an [ArgumentError] if the string matches no video encoding.
VideoEncoding videoEncodingFromString(String str) {
str = str.toLowerCase().trim();
if (str.startsWith('mp4v')) {
return VideoEncoding.mp4v;
}
if (str.startsWith('avc1')) {
return VideoEncoding.h264;
}
if (str.startsWith('vp8')) {
return VideoEncoding.vp8;
}
if (str.startsWith('vp9')) {
return VideoEncoding.vp9;
}
if (str.startsWith('av01')) {
return VideoEncoding.av1;
}
throw ArgumentError.value(str, 'str', 'Unrecognized video encoding');
}
/// Parses the itag as [VideoQuality]
/// Throws an [ArgumentError] if the itag matches no video quality.
VideoQuality videoQualityFromITag(int itag) {
var q = _qualityMap[itag];
if (q == null) {
throw ArgumentError.value(itag, 'itag', 'Unrecognized itag');
}
return q;
}
/// Convert a video quality to a [String].
String videoQualityToLabel(VideoQuality quality) =>
// ignore: prefer_interpolation_to_compose_strings
quality.toString().replaceAll(RegExp(r'\D'), '') + 'p';
/// Returns a [VideoResolution] from its [VideoQuality]
VideoResolution videoQualityToResolution(VideoQuality quality) {
var r = _resolutionMap[quality];
if (r == null) {
throw ArgumentError.value(quality, 'quality', 'Unrecognized video quality');
}
return r;
}
/// Parses the label as [VideoQuality]
/// Throws an [ArgumentError] if the string matches no video quality.
VideoQuality videoQualityFromLabel(String label) {
label = label.toLowerCase();
if (label.startsWith('144p')) {
return VideoQuality.low144;
}
if (label.startsWith('240p')) {
return VideoQuality.low144;
}
if (label.startsWith('360p')) {
return VideoQuality.medium360;
}
if (label.startsWith('480p')) {
return VideoQuality.medium480;
}
if (label.startsWith('720p')) {
return VideoQuality.high720;
}
if (label.startsWith('1080p')) {
return VideoQuality.high1080;
}
if (label.startsWith('1440p')) {
return VideoQuality.high1440;
}
if (label.startsWith('2160p')) {
return VideoQuality.high2160;
}
if (label.startsWith('2880p')) {
return VideoQuality.high2880;
}
if (label.startsWith('3072p')) {
return VideoQuality.high3072;
}
if (label.startsWith('4320p')) {
return VideoQuality.high4320;
}
throw ArgumentError.value(label, 'label', 'Unrecognized video quality label');
}
/// Parses the playlist id as [PlaylistType]
/// Throws an [ArgumentError] if the string matches no playlist type.
PlaylistType playlistTypeFromId(String id) {
id = id.toLowerCase();
if (id.startsWith('pl')) {
return PlaylistType.normal;
}
if (id.startsWith('rd')) {
return PlaylistType.videoMix;
}
if (id.startsWith('ul')) {
return PlaylistType.channelVideoMix;
}
if (id.startsWith('uu')) {
return PlaylistType.channelVideos;
}
if (id.startsWith('pu')) {
return PlaylistType.popularChannelVideos;
}
if (id.startsWith('ol')) {
return PlaylistType.musicAlbum;
}
if (id.startsWith('ll')) {
return PlaylistType.likedVideos;
}
if (id.startsWith('fl')) {
return PlaylistType.favorites;
}
if (id.startsWith('ml')) {
return PlaylistType.watchLater;
}
throw ArgumentError.value(id, 'id', 'Unrecognized playlist type');
}
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),
};

View File

@ -0,0 +1,29 @@
import '../common/common.dart';
import 'playlist_id.dart';
class Playlist {
/// Playlist ID.
final PlaylistId id;
/// Playlist URL.
String get url => 'https://www.youtube.com/playlist?list=$id';
/// Playlist title.
final String title;
/// Playlist author.
/// Can be null if it's a system playlist (e.g. Video Mix, Topics, etc.).
final String author;
/// Playlist description.
final String description;
/// Engagement statistics.
final Engagement engagement;
/// Initializes an instance of [Playlist].
Playlist(this.id, this.title, this.author, this.description, this.engagement);
@override
String toString() => 'Playlist ($title)';
}

View File

@ -0,0 +1,64 @@
import '../common/common.dart';
import '../reverse_engineering/responses/responses.dart';
import '../reverse_engineering/youtube_http_client.dart';
import '../videos/video.dart';
import '../videos/video_id.dart';
import 'playlist.dart';
import 'playlist_id.dart';
/// Queries related to YouTube playlists.
class PlaylistClient {
final YoutubeHttpClient _httpClient;
/// Initializes an instance of [PlaylistClient]
PlaylistClient(this._httpClient);
/// Gets the metadata associated with the specified playlist.
Future<Playlist> get(PlaylistId id) async {
var response = await PlaylistResponse.get(_httpClient, id.value);
return Playlist(
id,
response.title,
response.author,
response.description ?? '',
Engagement(response.viewCount ?? 0, response.likeCount ?? 0,
response.dislikeCount ?? 0));
}
/// Enumerates videos included in the specified playlist.
Stream<Video> getVideos(PlaylistId id) async* {
var encounteredVideoIds = <String>{};
var index = 0;
while (true) {
var response =
await PlaylistResponse.get(_httpClient, id.value, index: index);
var countDelta = 0;
for (var video in response.videos) {
var videoId = video.id;
// Already added
if (encounteredVideoIds.add(videoId)) {
continue;
}
yield Video(
VideoId(videoId),
video.title,
video.author,
video.uploadDate,
video.description,
video.duration,
ThumbnailSet(videoId),
video.keywords,
Engagement(video.viewCount, video.likes, video.dislikes));
countDelta++;
}
// Videos loop around, so break when we stop seeing new videos
if (countDelta <= 0) {
break;
}
index += countDelta;
}
}
}

View File

@ -0,0 +1,87 @@
import '../extensions/helpers_extension.dart';
class PlaylistId {
static final _regMatchExp =
RegExp(r'youtube\..+?/playlist.*?list=(.*?)(?:&|/|$)');
static final _compositeMatchExp = RegExp(
r'https://www.youtube.com/watch?v=b8m9zhNAgKs&list=PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr');
static final _shortCompositeMatchExp =
RegExp(r'youtu\.be/.*?/.*?list=(.*?)(?:&|/|$)');
static final _embedCompositeMatchExp =
RegExp(r'youtube\..+?/embed/.*?/.*?list=(.*?)(?:&|/|$)');
final String value;
/// Initializes an instance of [PlaylistId]
PlaylistId(String idOrUrl)
: value = parsePlaylistId(idOrUrl) ??
ArgumentError.value(idOrUrl, 'idOrUrl', 'Invalid url.');
/// Returns true if the given [playlistId] is valid.
static bool validatePlaylistId(String playlistId) {
playlistId = playlistId.toLowerCase();
if (playlistId.isNullOrWhiteSpace) {
return false;
}
// Watch later
if (playlistId == 'WL') {
return true;
}
// My mix playlist
if (playlistId == 'RDMM') {
return true;
}
if (!playlistId.startsWith('PL') &&
!playlistId.startsWith('RD') &&
!playlistId.startsWith('UL') &&
!playlistId.startsWith('UU') &&
!playlistId.startsWith('PU') &&
!playlistId.startsWith('OL') &&
!playlistId.startsWith('LL') &&
!playlistId.startsWith('FL')) {
return false;
}
if (playlistId.length < 13) {
return false;
}
return !RegExp(r'[^0-9a-zA-Z_\-]').hasMatch(playlistId);
}
/// Parses a playlist [url] returning its id.
/// If the [url] is a valid it is returned itself.
static String parsePlaylistId(String url) {
if (url.isNullOrWhiteSpace) {
return null;
}
var regMatch = _regMatchExp.firstMatch(url)?.group(1);
if (!regMatch.isNullOrWhiteSpace && validatePlaylistId(regMatch)) {
return regMatch;
}
var compositeMatch = _compositeMatchExp.firstMatch(url)?.group(1);
if (!compositeMatch.isNullOrWhiteSpace &&
validatePlaylistId(compositeMatch)) {
return compositeMatch;
}
var shortCompositeMatch = _shortCompositeMatchExp.firstMatch(url)?.group(1);
if (!shortCompositeMatch.isNullOrWhiteSpace &&
validatePlaylistId(shortCompositeMatch)) {
return shortCompositeMatch;
}
var embedCompositeMatch = _embedCompositeMatchExp.firstMatch(url)?.group(1);
if (!embedCompositeMatch.isNullOrWhiteSpace &&
validatePlaylistId(embedCompositeMatch)) {
return embedCompositeMatch;
}
return null;
}
}

View File

@ -0,0 +1,5 @@
library youtube_explode.playlists;
export 'playlist.dart';
export 'playlist_client.dart';
export 'playlist_id.dart';

View File

@ -26,7 +26,7 @@ int getExceptionCost(Exception e) {
if (e is TransientFailureException) {
return 1;
}
if (e is RequestLimitExceeded) {
if (e is RequestLimitExceededException) {
return 2;
}
if (e is FatalFailureException) {

View File

@ -1,113 +0,0 @@
library youtube_explode.cipher;
import 'package:http/http.dart' as http;
import '../../exceptions/exceptions.dart';
import '../../extensions/helpers_extension.dart';
import 'cipher_operations.dart';
final _deciphererFuncNameExp = RegExp(
r'(\w+)=function\(\w+\){(\w+)=\2\.split\(\x22{2}\);.*?return\s+\2\.join\(\x22{2}\)}');
final _deciphererDefNameExp = RegExp(r'(\w+).\w+\(\w+,\d+\);');
final _calledFuncNameExp = RegExp(r'\w+(?:.|\[)(\"?\w+(?:\")?)\]?\(');
final _indexExp = RegExp(r'\(\w+,(\d+)\)');
final _cipherCache = <String, List<CipherOperation>>{};
/// Returns a [Future] that completes with a [List] of [CipherOperation]
Future<List<CipherOperation>> getCipherOperations(
String playerSourceUrl, http.Client client) async {
if (_cipherCache.containsKey(playerSourceUrl)) {
return _cipherCache[playerSourceUrl];
}
var raw = (await client.get(playerSourceUrl)).body;
var deciphererFuncName = _deciphererFuncNameExp.firstMatch(raw)?.group(1);
if (deciphererFuncName.isNullOrWhiteSpace) {
throw UnrecognizedStructureException(
'Could not find decipherer name. Please report this issue on GitHub.',
raw);
}
var exp = RegExp(r'(?!h\.)'
'${RegExp.escape(deciphererFuncName)}'
r'=function\(\w+\)\{(.*?)\}');
var decipherFuncBody = exp.firstMatch(raw)?.group(1);
if (decipherFuncBody.isNullOrWhiteSpace) {
throw UnrecognizedStructureException(
'Could not find decipherer body. Please report this issue on GitHub.',
raw);
}
var deciphererFuncBodyStatements = decipherFuncBody.split(';');
var deciphererDefName =
_deciphererDefNameExp.firstMatch(decipherFuncBody)?.group(1);
exp = RegExp(
r'var\s+'
'${RegExp.escape(deciphererDefName)}'
r'=\{(\w+:function\(\w+(,\w+)?\)\{(.*?)\}),?\};',
dotAll: true);
var deciphererDefBody = exp.firstMatch(raw)?.group(0);
var operations = <CipherOperation>[];
for (var statement in deciphererFuncBodyStatements) {
var calledFuncName = _calledFuncNameExp.firstMatch(statement)?.group(1);
if (calledFuncName.isNullOrWhiteSpace) {
continue;
}
final funcNameEsc = RegExp.escape(calledFuncName);
var exp =
RegExp('$funcNameEsc' r':\bfunction\b\([a],b\).(\breturn\b)?.?\w+\.');
// Slice
if (exp.hasMatch(deciphererDefBody)) {
var index = int.parse(_indexExp.firstMatch(statement).group(1));
operations.add(SliceCipherOperation(index));
continue;
}
// Swap
exp = RegExp('$funcNameEsc' r':\bfunction\b\(\w+\,\w\).\bvar\b.\bc=a\b');
if (exp.hasMatch(deciphererDefBody)) {
var index = int.parse(_indexExp.firstMatch(statement).group(1));
operations.add(SwapCipherOperation(index));
continue;
}
// Reverse
exp = RegExp('$funcNameEsc' r':\bfunction\b\(\w+\)');
if (exp.hasMatch(deciphererDefBody)) {
operations.add(const ReverseCipherOperation());
}
}
return _cipherCache[playerSourceUrl] = operations;
}
/// Returns a Uri with a signature.
/// The result is cached for the [playerSourceUrl]
Future<Uri> decipherUrl(
String playerSourceUrl, String cipher, http.Client client) async {
var cipherDic = Uri.splitQueryString(cipher);
var url = Uri.parse(cipherDic['url']);
var signature = cipherDic['s'];
var cipherOperations = await getCipherOperations(playerSourceUrl, client);
var query = Map<String, dynamic>.from(url.queryParameters);
signature = cipherOperations.decipher(signature);
query[cipherDic['sp']] = signature;
return url.replace(queryParameters: query);
}

View File

@ -182,7 +182,7 @@ extension VideoQualityUtil on VideoQuality {
static String getLabelFromTagWithFramerate(int itag, double framerate) {
var videoQuality = fromTag(itag);
return getLabelWithFramerate(framerate);
return videoQuality.getLabelWithFramerate(framerate);
}
/// Returns a [VideoResolution] from its [VideoQuality]

View File

@ -1,8 +1,9 @@
import 'package:html/dom.dart';
import 'package:html/parser.dart' as parser;
import 'package:youtube_explode_dart/src/exceptions/exceptions.dart';
import 'package:youtube_explode_dart/src/retry.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
import '../../exceptions/exceptions.dart';
import '../../retry.dart';
import '../youtube_http_client.dart';
class ChannelPage {
final Document _root;
@ -38,7 +39,8 @@ class ChannelPage {
});
}
static Future<ChannelPage> getByUsername(YoutubeHttpClient httpClient, String username) {
static Future<ChannelPage> getByUsername(
YoutubeHttpClient httpClient, String username) {
var url = 'https://www.youtube.com/user/$username?hl=en';
return retry(() async {

View File

@ -1,6 +1,7 @@
import 'package:xml/xml.dart' as xml;
import 'package:youtube_explode_dart/src/retry.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
import '../../retry.dart';
import '../youtube_http_client.dart';
class ClosedCaptionTrackResponse {
final xml.XmlDocument _root;
@ -46,9 +47,8 @@ class ClosedCaption {
Duration get end => offset + duration;
void getParts() {
_root.findElements('s').map((e) => ClosedCaptionPart._(e));
}
Iterable<ClosedCaptionPart> getParts() =>
_root.findElements('s').map((e) => ClosedCaptionPart._(e));
}
class ClosedCaptionPart {

View File

@ -1,7 +1,7 @@
import 'package:xml/xml.dart' as xml;
import '../../retry.dart';
import '../reverse_engineering.dart';
import '../youtube_http_client.dart';
import 'stream_info_provider.dart';
class DashManifest {

View File

@ -2,10 +2,10 @@ import 'dart:convert';
import 'package:html/dom.dart';
import 'package:html/parser.dart' as parser;
import 'package:youtube_explode_dart/src/retry.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
import '../../retry.dart';
import '../../extensions/extensions.dart';
import '../../extensions/helpers_extension.dart';
import '../youtube_http_client.dart';
class EmbedPage {
static final _playerConfigExp =

View File

@ -20,17 +20,16 @@ class PlayerResponse {
String get videoAuthor => _root['videoDetails']['author'];
//TODO: Check how this is formatted.
String /* DateTime */ get videoUploadDate =>
_root['microformat']['playerMicroformatRenderer']['uploadDate'];
DateTime get videoUploadDate => DateTime.parse(
_root['microformat']['playerMicroformatRenderer']['uploadDate']);
String get videoChannelId => _root['videoDetails']['channelId'];
Duration get videoDuration =>
Duration(seconds: _root['videoDetails']['lengthSeconds']);
Duration(seconds: int.parse(_root['videoDetails']['lengthSeconds']));
Iterable<String> get videoKeywords =>
_root['videoDetails']['keywords'] ?? const [];
_root['videoDetails']['keywords'].cast<String>() ?? const [];
String get videoDescription => _root['videoDetails']['shortDescription'];
@ -97,9 +96,11 @@ class ClosedCaptionTrack {
String get url => _root['baseUrl'];
String get language => _root['name']['simpleText'];
String get languageCode => _root['languageCode'];
bool get autoGenerated => _root['vssId'].startsWith("a.");
String get languageName => _root['name']['simpleText'];
bool get autoGenerated => _root['vssId'].toLowerCase().startsWith("a.");
}
class _StreamInfo extends StreamInfoProvider {

View File

@ -1,7 +1,7 @@
import 'package:youtube_explode_dart/src/exceptions/exceptions.dart';
import 'package:youtube_explode_dart/src/retry.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/cipher/cipher_operations.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
import '../../exceptions/exceptions.dart';
import '../../retry.dart';
import '../cipher/cipher_operations.dart';
import '../youtube_http_client.dart';
class PlayerSource {
final RegExp _statIndexExp = RegExp(r'\(\w+,(\d+)\)');
@ -95,9 +95,9 @@ class PlayerSource {
static Future<PlayerSource> get(YoutubeHttpClient httpClient, String url) {
return retry(() async {
var raw = await httpClient.getString(url);
return PlayerSource.parse(raw);
});
var raw = await httpClient.getString(url);
return PlayerSource.parse(raw);
});
}
}

View File

@ -1,8 +1,8 @@
import 'dart:convert';
import 'package:youtube_explode_dart/src/exceptions/exceptions.dart';
import 'package:youtube_explode_dart/src/retry.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
import '../../exceptions/exceptions.dart';
import '../../retry.dart';
import '../youtube_http_client.dart';
class PlaylistResponse {
// Json parsed map
@ -22,8 +22,8 @@ class PlaylistResponse {
int get dislikeCount => int.tryParse(_root['dislikes']);
Iterable<Video> get videos =>
_root['video']?.map((e) => Video(e)) ?? const [];
Iterable<_Video> get videos =>
_root['video']?.map((e) => _Video(e)) ?? const [];
PlaylistResponse.parse(String raw) : _root = json.tryDecode(raw) {
if (_root == null) {
@ -53,11 +53,11 @@ class PlaylistResponse {
}
}
class Video {
class _Video {
// Json parsed map
final Map<String, dynamic> _root;
Video(this._root);
_Video(this._root);
String get id => _root['encrypted_id'];

View File

@ -1,3 +1,5 @@
library _youtube_explode.responses;
export 'channel_page.dart';
export 'closed_caption_track_response.dart';
export 'dash_manifest.dart';
@ -7,4 +9,4 @@ export 'player_source.dart';
export 'playerlist_response.dart';
export 'stream_info_provider.dart';
export 'video_info_response.dart';
export 'watch_page.dart';
export 'watch_page.dart';

View File

@ -1,9 +1,10 @@
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/stream_info_provider.dart';
import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart';
import '../../exceptions/exceptions.dart';
import '../../retry.dart';
import '../youtube_http_client.dart';
import 'player_response.dart';
import 'stream_info_provider.dart';
class VideoInfoResponse {
final Map<String, String> _root;
@ -12,7 +13,7 @@ class VideoInfoResponse {
String get status => _root['status'];
bool get isVideoAvailable => status.toLowerCase() == 'fail';
bool get isVideoAvailable => status.toLowerCase() != 'fail';
PlayerResponse get playerResponse =>
PlayerResponse.parse(_root['player_response']);
@ -40,7 +41,7 @@ class VideoInfoResponse {
[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';
'https://youtube.com/get_video_info?video_id=$videoId&el=embedded&eurl=$eurl&hl=en${sts != null ? '&sts=$sts' : ''}';
return retry(() async {
var raw = await httpClient.getString(url);
var result = VideoInfoResponse.parse(raw);

View File

@ -7,8 +7,10 @@ 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 '../../videos/video_id.dart';
import '../youtube_http_client.dart';
import 'player_response.dart';
import 'stream_info_provider.dart';
class WatchPage {
final RegExp _videoLikeExp = RegExp(r'label""\s*:\s*""([\d,\.]+) likes');
@ -24,17 +26,19 @@ 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.text)
.group(1)
.nullIfWhitespace
.firstMatch(_root.outerHtml)
?.group(1)
?.nullIfWhitespace
?.stripNonDigits() ??
'');
//TODO: This does not work.
int get videoDislikeCount => int.tryParse(_videoDislikeExp
.firstMatch(_root.text)
.group(1)
.nullIfWhitespace
.firstMatch(_root.outerHtml)
?.group(1)
?.nullIfWhitespace
?.stripNonDigits() ??
'');

View File

@ -1,4 +0,0 @@
export 'cipher/cipher_operations.dart';
export 'heuristics.dart';
export 'responses/responses.dart';
export 'youtube_http_client.dart';

View File

@ -1,42 +1,51 @@
import 'package:http/http.dart';
import '../exceptions/exceptions.dart';
class YoutubeHttpClient {
final Client _httpClient = Client();
final Map<String, String> _userAgent = const {
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36'
};
/// Throws if something is wrong with the response.
void _validateResponse(Request request, int statusCode) {
void _validateResponse(BaseResponse response, int statusCode) {
var request = response.request;
if (request.url.host.endsWith('.google.com') &&
request.url.path.startsWith('/sorry/')) {
//TODO: throw RequestLimitExceededException.FailedHttpRequest(response);
throw RequestLimitExceededException.httpRequest(response);
}
if (statusCode >= 500) {
//TODO: TransientFailureException.FailedHttpRequest(response);
throw TransientFailureException.httpRequest(response);
}
if (statusCode == 429) {
//TODO: throw RequestLimitExceededException.FailedHttpRequest(response);
throw RequestLimitExceededException.httpRequest(response);
}
if (statusCode >= 400) {
//TODO: throw FatalFailureException.FailedHttpRequest(response);
throw FatalFailureException.httpRequest(response);
}
}
Future<Response> get(dynamic url, {Map<String, String> headers}) {
return _httpClient.get(url, headers: headers);
return _httpClient.get(url, headers: {...?headers, ..._userAgent});
}
Future<Response> head(dynamic url, {Map<String, String> headers}) {
return _httpClient.head(url, headers: headers);
return _httpClient.head(url, headers: {...?headers, ..._userAgent});
}
Future<String> getString(dynamic url,
{Map<String, String> headers, bool validate = true}) async {
var response = await _httpClient.get(url, headers: headers);
var response =
await _httpClient.get(url, headers: {...?headers, ..._userAgent});
if (validate) {
_validateResponse(response.request, response.statusCode);
_validateResponse(response, response.statusCode);
}
return response.body;
@ -44,14 +53,15 @@ class YoutubeHttpClient {
Stream<List<int>> getStream(dynamic url,
{Map<String, String> headers,
int from,
int to,
bool validate = true}) async* {
int from,
int to,
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.request, response.statusCode);
_validateResponse(response, response.statusCode);
}
yield* response.stream;
}
@ -61,9 +71,13 @@ class YoutubeHttpClient {
var response = await head(url, headers: headers);
if (validate) {
_validateResponse(response.request, response.statusCode);
_validateResponse(response, response.statusCode);
}
return int.parse(response.headers['content-length']);
}
/// Closes the [Client] assigned to this [YoutubeHttpClient].
/// Should be called after this is not used anymore.
void close() => _httpClient.close();
}

View File

@ -0,0 +1,32 @@
import 'dart:collection';
import 'closed_caption_part.dart';
/// Text that gets displayed at specific time during video playback,
/// as part of a [ClosedCaptionTrack]
class ClosedCaption {
/// Text displayed by this caption.
final String text;
/// Time at which this caption starts being displayed.
final Duration offset;
/// Duration this caption is displayed.
final Duration duration;
/// Caption parts (usually individual words).
/// May be empty because not all captions contain parts.
final UnmodifiableListView<ClosedCaptionPart> parts;
/// Initializes an instance of [ClosedCaption]
ClosedCaption(
this.text, this.offset, this.duration, Iterable<ClosedCaptionPart> parts)
: parts = UnmodifiableListView(parts);
/// Gets the caption part displayed at the specified point in time,
/// relative to this caption's offset.
/// Returns null if not found.
/// Note that some captions may not have any parts at all.
ClosedCaptionPart getPartByTime(Duration offset) =>
parts.firstWhere((e) => e.offset >= offset, orElse: () => null);
}

View File

@ -0,0 +1,45 @@
import '../../extensions/helpers_extension.dart';
import '../../reverse_engineering/responses/closed_caption_track_response.dart'
hide ClosedCaption, ClosedCaptionPart;
import '../../reverse_engineering/responses/video_info_response.dart';
import '../../reverse_engineering/youtube_http_client.dart';
import '../videos.dart';
import 'closed_caption.dart';
import 'closed_caption_manifest.dart';
import 'closed_caption_part.dart';
import 'closed_caption_track.dart';
import 'closed_caption_track_info.dart';
import 'language.dart';
/// Queries related to closed captions of YouTube videos.
class ClosedCaptionClient {
final YoutubeHttpClient _httpClient;
/// Initializes an instance of [ClosedCaptionClient]
ClosedCaptionClient(this._httpClient);
/// Gets the manifest that contains information
/// about available closed caption tracks in the specified video.
Future<ClosedCaptionManifest> getManifest(VideoId videoId) async {
var videoInfoResponse =
await VideoInfoResponse.get(_httpClient, videoId.value);
var playerResponse = videoInfoResponse.playerResponse;
var tracks = playerResponse.closedCaptionTrack.map((track) =>
ClosedCaptionTrackInfo(Uri.parse(track.url),
Language(track.languageCode, track.languageName)));
return ClosedCaptionManifest(tracks);
}
Future<ClosedCaptionTrack> get(ClosedCaptionTrackInfo trackInfo) async {
var response = await ClosedCaptionTrackResponse.get(
_httpClient, trackInfo.url.toString());
var captions = response.closedCaptions
.where((e) => !e.text.isNullOrWhiteSpace)
.map((e) => ClosedCaption(e.text, e.offset, e.duration,
e.getParts().map((f) => ClosedCaptionPart(f.text, f.offset))));
return ClosedCaptionTrack(captions);
}
//TODO: Implement WriteToAsync and DownloadAsync
}

View File

@ -0,0 +1,25 @@
import 'dart:collection';
import 'package:youtube_explode_dart/src/videos/closed_captions/closed_caption_track_info.dart';
/// Manifest that contains information about available closed caption tracks
/// in a specific video.
class ClosedCaptionManifest {
/// Available closed caption tracks.
final UnmodifiableListView<ClosedCaptionTrackInfo> tracks;
/// Initializes an instance of [ClosedCaptionManifest]
ClosedCaptionManifest(Iterable<ClosedCaptionTrackInfo> tracks)
: tracks = UnmodifiableListView(tracks);
/// Gets the closed caption track in the specified language.
/// Returns null if not found.
ClosedCaptionTrackInfo getByLanguage(String language) {
language = language.toLowerCase();
return tracks.firstWhere(
(e) =>
e.language.code.toLowerCase() == language ||
e.language.name.toLowerCase() == language,
orElse: () => null);
}
}

View File

@ -0,0 +1,15 @@
/// Part of a closed caption (usually a single word).
class ClosedCaptionPart {
/// Text displayed by this caption part.
final String text;
/// Time at which this caption part starts being displayed
/// (relative to the caption's own offset).
final Duration offset;
/// Initializes an instance of [ClosedCaptionPart]
ClosedCaptionPart(this.text, this.offset);
@override
String toString() => text;
}

View File

@ -0,0 +1,14 @@
import 'dart:collection';
import 'closed_caption.dart';
/// Track that contains closed captions in a specific language.
class ClosedCaptionTrack {
/// Closed captions.
final UnmodifiableListView<ClosedCaption> captions;
/// Initializes an instance of [ClosedCaptionTrack].
ClosedCaptionTrack(Iterable<ClosedCaption> captions)
: captions = UnmodifiableListView(captions);
}

View File

@ -1,6 +1,6 @@
import 'package:equatable/equatable.dart';
import '../models.dart';
import 'language.dart';
/// Metadata associated with a certain [ClosedCaptionTrack]
class ClosedCaptionTrackInfo extends Equatable {
@ -16,6 +16,9 @@ class ClosedCaptionTrackInfo extends Equatable {
/// Initializes an instance of [ClosedCaptionTrackInfo]
const ClosedCaptionTrackInfo(this.url, this.language, {this.isAutoGenerated});
@override
String toString() => 'CC Track ($language)';
@override
List<Object> get props => [url, language, isAutoGenerated];
}

View File

@ -0,0 +1,6 @@
export 'closed_caption.dart';
export 'closed_caption_client.dart';
export 'closed_caption_manifest.dart';
export 'closed_caption_part.dart';
export 'closed_caption_track.dart';
export 'closed_caption_track_info.dart';

View File

@ -1,4 +1,5 @@
import '../../reverse_engineering/reverse_engineering.dart';
import '../../reverse_engineering/cipher/cipher_operations.dart';
import '../../reverse_engineering/responses/responses.dart';
///
class StreamContext {

View File

@ -5,11 +5,11 @@ 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 'streams_client.dart';
export 'video_only_stream_info.dart';
export 'video_quality.dart';
export 'video_resolution.dart';
export 'video_stream_info.dart';
export 'video_stream_info.dart';

View File

@ -1,11 +1,9 @@
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 '../../reverse_engineering/cipher/cipher_operations.dart';
import '../../reverse_engineering/heuristics.dart';
import '../../reverse_engineering/responses/responses.dart';
import '../../reverse_engineering/youtube_http_client.dart';
import '../video_id.dart';
import 'bitrate.dart';
import 'container.dart';
@ -14,13 +12,14 @@ import 'framerate.dart';
import 'stream_context.dart';
import 'stream_info.dart';
import 'stream_manifest.dart';
import 'streams.dart';
/// Queries related to media streams of YouTube videos.
class StreamClient {
class StreamsClient {
final YoutubeHttpClient _httpClient;
/// Initializes an instance of [StreamsClient]
StreamClient._(this._httpClient);
StreamsClient(this._httpClient);
Future<DashManifest> _getDashManifest(
Uri dashManifestUrl, Iterable<CipherOperation> cipherOperations) {
@ -241,14 +240,13 @@ class StreamClient {
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 CopyToAsync
//TODO: Implement DownloadAsync
//TODO: Implement DownloadAsync
}

54
lib/src/videos/video.dart Normal file
View File

@ -0,0 +1,54 @@
import 'dart:collection';
import 'package:youtube_explode_dart/src/common/common.dart';
import 'video_id.dart';
/// YouTube video metadata.
class Video {
/// Video ID.
final VideoId id;
/// Video URL.
String get url => 'https://www.youtube.com/watch?v=$id';
/// Video title.
final String title;
/// Video author.
final String author;
/// Video upload date.
final DateTime uploadDate;
/// Video description.
final String description;
/// Duration of the video.
final Duration duration;
/// Available thumbnails for this video.
final ThumbnailSet thumbnails;
/// Search keywords used for this video.
final UnmodifiableListView<String> keywords;
/// Engagement statistics for this video.
final Engagement engagement;
/// Initializes an instance of [Video]
Video(
this.id,
this.title,
this.author,
this.uploadDate,
this.description,
this.duration,
this.thumbnails,
Iterable<String> keywords,
this.engagement)
: keywords = UnmodifiableListView(keywords);
@override
String toString() => 'Video ($title)';
}

View File

@ -0,0 +1,40 @@
import '../common/common.dart';
import '../reverse_engineering/responses/responses.dart';
import '../reverse_engineering/youtube_http_client.dart';
import 'closed_captions/closed_caption_client.dart';
import 'videos.dart';
/// Queries related to YouTube videos.
class VideoClient {
final YoutubeHttpClient _httpClient;
/// Queries related to media streams of YouTube videos.
final StreamsClient streamsClient;
/// Queries related to closed captions of YouTube videos.
final ClosedCaptionClient closedCaptions;
/// Initializes an instance of [VideoClient].
VideoClient(this._httpClient)
: streamsClient = StreamsClient(_httpClient),
closedCaptions = ClosedCaptionClient(_httpClient);
/// Gets the metadata associated with the specified video.
Future<Video> get(VideoId id) async {
var videoInfoResponse = await VideoInfoResponse.get(_httpClient, id.value);
var playerResponse = videoInfoResponse.playerResponse;
var watchPage = await WatchPage.get(_httpClient, id.value);
return Video(
id,
playerResponse.videoTitle,
playerResponse.videoAuthor,
playerResponse.videoUploadDate,
playerResponse.videoDescription,
playerResponse.videoDuration,
ThumbnailSet(id.value),
playerResponse.videoKeywords,
Engagement(playerResponse.videoViewCount ?? 0, watchPage.videoLikeCount,
watchPage.videoDislikeCount));
}
}

View File

@ -1,6 +1,6 @@
import 'package:equatable/equatable.dart';
import '../extensions/extensions.dart';
import '../extensions/helpers_extension.dart';
/// Encapsulates a valid YouTube video ID.
class VideoId extends Equatable {

View File

@ -1,2 +1,4 @@
export 'streams/streams.dart';
export 'video.dart';
export 'video_client.dart';
export 'video_id.dart';

View File

@ -0,0 +1,35 @@
import 'channels/channels.dart';
import 'playlists/playlist_client.dart';
import 'reverse_engineering/youtube_http_client.dart';
import 'videos/video_client.dart';
/// Library entry point.
class YoutubeExplode {
final YoutubeHttpClient _httpClient;
/// Queries related to YouTube videos.
VideoClient get videos => _videos;
/// Queries related to YouTube playlists.
PlaylistClient get playlists => _playlists;
/// Queries related to YouTube channels.
ChannelClient get channels => _channels;
/// YouTube search queries.
//TODO: Add SearchClient
YoutubeExplode() : _httpClient = YoutubeHttpClient() {
_videos = VideoClient(_httpClient);
_playlists = PlaylistClient(_httpClient);
_channels = ChannelClient(_httpClient);
}
VideoClient _videos;
PlaylistClient _playlists;
ChannelClient _channels;
/// Closes the HttpClient assigned to this [YoutubeHttpClient].
/// Should be called after this is not used anymore.
void close() => _httpClient.close();
}

View File

@ -1,7 +1,8 @@
library youtube_explode;
export 'src/channels/channels.dart';
export 'src/common/common.dart';
export 'src/exceptions/exceptions.dart';
export 'src/extensions/extensions.dart'
hide StringUtility, ListDecipher, ListFirst; // Hide helper extensions.
export 'src/models/models.dart';
export 'src/playlists/playlists.dart';
export 'src/videos/videos.dart';
export 'src/youtube_explode_base.dart';

View File

@ -1,7 +1,13 @@
import 'dart:convert';
import 'package:youtube_explode_dart/src/videos/video_id.dart';
import 'dart:collection';
void main() {
var x = VideoId('asd');
}
var c = MyClass(UnmodifiableListView([1, 2, 3]));
c.list[1] =1;
}
class MyClass {
final UnmodifiableListView<int> list;
MyClass(this.list);
}