Working v5
This commit is contained in:
parent
0cfdb5e575
commit
c7bbbf0d24
|
@ -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}');
|
||||
|
||||
|
|
|
@ -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 {
|
|
@ -0,0 +1,6 @@
|
|||
library youtube_explode.channels;
|
||||
|
||||
export 'channel.dart';
|
||||
export 'channel_client.dart';
|
||||
export 'channel_id.dart';
|
||||
export 'username.dart';
|
|
@ -0,0 +1,4 @@
|
|||
library youtube_explode.common;
|
||||
|
||||
export 'engagement.dart';
|
||||
export 'thumbnail_set.dart';
|
|
@ -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 {
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/// Parent class for domain exceptions thrown by [YoutubeExplode]
|
||||
abstract class YoutubeExplodeException implements Exception {
|
||||
final String message;
|
||||
|
||||
YoutubeExplodeException(this.message);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -1,3 +1,5 @@
|
|||
library _youtube_explode.extensions;
|
||||
|
||||
import '../reverse_engineering/cipher/cipher_operations.dart';
|
||||
|
||||
/// Utility for Strings.
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
/// AudioEncoding
|
||||
enum AudioEncoding {
|
||||
/// MPEG-4 Part 3, Advanced Audio Coding (AAC).
|
||||
aac,
|
||||
|
||||
/// Vorbis.
|
||||
vorbis,
|
||||
|
||||
/// Opus.
|
||||
opus
|
||||
}
|
|
@ -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]);
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -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
|
||||
]);
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -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
|
||||
]);
|
||||
}
|
|
@ -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';
|
|
@ -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
|
||||
];
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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
|
||||
];
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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),
|
||||
};
|
|
@ -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)';
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
library youtube_explode.playlists;
|
||||
|
||||
export 'playlist.dart';
|
||||
export 'playlist_client.dart';
|
||||
export 'playlist_id.dart';
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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]
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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'];
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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() ??
|
||||
'');
|
||||
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
export 'cipher/cipher_operations.dart';
|
||||
export 'heuristics.dart';
|
||||
export 'responses/responses.dart';
|
||||
export 'youtube_http_client.dart';
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -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';
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)';
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -1,2 +1,4 @@
|
|||
export 'streams/streams.dart';
|
||||
export 'video.dart';
|
||||
export 'video_client.dart';
|
||||
export 'video_id.dart';
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue