Merge pull request #33 from Hexer10/v5

Update to v5 of YoutubeExplode for C#
This commit is contained in:
Mattia 2020-06-08 01:05:53 +02:00 committed by GitHub
commit d56eabc554
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
110 changed files with 3725 additions and 2149 deletions

View File

@ -1,3 +1,19 @@
## 1.0.0-beta
- Updated to v5 of YouTube Explode for C#
## 1.0.1-beta
- Implement `SearchClient`.
- Implement `VideoStreamInfoExtension` for Iterables.
- Update `xml` dependency.
- Fixed closed caption api.
## 1.0.2-beta
- Fix video likes and dislikes count. #30
<hr>
## 0.0.1
- Initial version, created by Stagehand
@ -69,4 +85,9 @@
## 0.0.16
- When a video is not available(403) a `VideoStreamUnavailableException`
- When a video is not available(403) a `VideoStreamUnavailableException`
## 0.0.17
- Fixed bug in #23

View File

@ -16,23 +16,16 @@ This doesn't require an API key and has no usage quotas.
- Provides static methods to validate IDs and to parse IDs from URLs
- No need for an API key and no usage quotas
- All model extend `Equatable` to easily perform equality checks
- Download Stream
## Features not implemented
- Adaptive streams
## Differences from YoutubeExplode
- The entry point is `YoutubeExplode`, not `YoutubeClient`.
- The `MediaStreamInfoSet` class has a `videoDetails` class which contains info about the video metadata (to avoid making several api calls).
- The `ClosedCaption` has a `end` getter to get when a closed captions ends being displayed.
## Install
Add the dependency to the pubspec.yaml (Check for the latest version)
```yaml
youtube_explode_dart: ^0.0.9
youtube_explode_dart: ^1.0.0
```
Import the library
@ -44,8 +37,6 @@ import 'package:youtube_explode_dart/youtube_explode_dart.dart';
To start using the API you need to initialize the `YoutubeExplode` class (which will create a new http client), and get (for example) the video id of the video you'd want to retrieve information, which usually is the `v` parameter.
```dart
var id = YoutubeExplode.parseVideoId('https://www.youtube.com/watch?v=OpQFFLBMEPI'); // Returns `OpQFFLBMEPI`
var yt = YoutubeExplode();
```
@ -53,19 +44,18 @@ var yt = YoutubeExplode();
The [Video][Video] class contains info about the video such as the video title, the duration or the search keywords.
```dart
var video = yt.getVideo(id); // Returns a Video instance.
var video = yt.video.get(id); // Returns a Video instance.
```
## Get video mediaStream
The [MediaStreamsInfoSet][MediaStreamsInfoSet] contains the audio, video and muxed streams of the video. Each of the streams provides an url which can be used to download a video with a get request (See [example][VidExample]).
## Get video streams
The Manifest contains the audio, video and muxed streams of the video. Each of the streams provides an url which can be used to download a video with a get request (See [example][VidExample]).
```dart
var mediaStreams = yt.getVideoMediaStream();
var manifest = yt.videos.streamsClient.getManifest(videoId);
var muxed = mediaStreams.muxed; // List of `MuxedStreamInfo` sorted by video quality.
var audio = mediaStreams.audio; // List of `AudioStreamInfo` sorted by bitrate.
var video = mediaStreams.video; // List of `VideoSteamInfo` sorted by video quality.
var videoDetails = mediaStreams.videoDetails; //Returns a `Video` instance. Used to avoid calling `yt.getVideo`.
var muxed = manifest.muxed; // List of `MuxedStreamInfo` sorted by video quality.
var audio = manifest.audio; // List of `AudioStreamInfo` sorted by bitrate.
var video = manifest.video; // List of `VideoSteamInfo` sorted by video quality.
// There are available manifest.audioOnly and manifest.videoOnly as well.
```
Be aware, the muxed streams don't hold the best quality, to achieve so, you'd need to merge the audio and video streams.
@ -74,25 +64,9 @@ Be aware, the muxed streams don't hold the best quality, to achieve so, you'd ne
To get the video closed caption it is need to query before the caption track infos, which can be used to retrieve the closed caption.
```dart
var trackInfos = await yt.getVideoClosedCaptionTrackInfos(id); // Get the caption track infos
if (trackInfos.isEmpty) {
// No caption is available.
return;
}
var enTrack = trackInfos.firstWhere(
(e) => e.language.code == 'en'); // Find the english caption track.
if (enTrack == null) {
// The english track doesn't exist.
return;
}
var captionTrack = await yt.getClosedCaptionTrack(enTrack); // Get the english closed caption track
var captions = captionTrack.captions; // List of ClosedCaption
captions.first; // Get the first displayed caption.
captions.getByTime(7); // Get the caption displayed at the 7th second.
var trackInfos = await yt.videos.closedCaptions.getManifest(videoId); // Get the caption track infos
var trackInfo = manifest.getByLanguage(en); // Get english caption.
var track = await track.getByTime(duration); // Get the caption displayed at `duration`.
```
## Cleanup
@ -111,12 +85,10 @@ Available on [GitHub][Examples]
---
Check the [api doc][API] for additional information.
More features are provided through extensions.
[YoutubeExplode]: https://github.com/Tyrrrz/YoutubeExplode/
[Video]: https://pub.dev/documentation/youtube_explode_dart/latest/youtube_explode/Video-class.html
[MediaStreamsInfoSet]: https://pub.dev/documentation/youtube_explode_dart/latest/youtube_explode/MediaStreamInfoSet-class.html
[VidExample]: https://github.com/Hexer10/youtube_explode_dart/blob/master/example/video_download.dart
[API]: https://pub.dev/documentation/youtube_explode_dart/latest/youtube_explode/youtube_explode-library.html
[Examples]: [https://github.com/Hexer10/youtube_explode_dart/tree/master/example]

View File

@ -13,7 +13,10 @@ linter:
- prefer_const_literals_to_create_immutables
- prefer_constructors_over_static_methods
- prefer_contains
- annotate_overrides
- await_only_futures
- unawaited_futures
analyzer:
# exclude:
# - path/to/excluded/files/**
exclude:
- example\**

View File

@ -1,12 +1,9 @@
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('https://www.youtube.com/watch?v=bo_efYhYU2A');
print('Title: ${video.title}');

View File

@ -1,3 +1,5 @@
//TODO: Fixing the console printing.
import 'dart:async';
import 'dart:io';
@ -14,19 +16,12 @@ Future<void> main() async {
var url = stdin.readLineSync().trim();
// Get the video url.
var id = YoutubeExplode.parseVideoId(url);
if (id == null) {
console.writeLine('Invalid video id or url.');
exit(1);
}
// Save the video to the download directory.
Directory('downloads').createSync();
console.hideCursor();
// Download the video.
await download(id);
await download(url);
yt.close();
console.showCursor();
@ -34,24 +29,28 @@ Future<void> main() async {
}
Future<void> download(String id) async {
// Get the video media stream.
var mediaStream = await yt.getVideoMediaStream(id);
// Get video metadata.
var video = await yt.videos.get(id);
// Get the video manifest.
var manifest = await yt.videos.streamsClient.getManifest(id);
var streams = manifest.audioOnly;
// Get the last audio track (the one with the highest bitrate).
var audio = mediaStream.audio.last;
var audio = streams.last;
var audioStream = yt.videos.streamsClient.get(audio);
// Compose the file name removing the unallowed characters in windows.
var fileName =
'${mediaStream.videoDetails.title}.${audio.container.toString()}'
.replaceAll('Container.', '')
.replaceAll(r'\', '')
.replaceAll('/', '')
.replaceAll('*', '')
.replaceAll('?', '')
.replaceAll('"', '')
.replaceAll('<', '')
.replaceAll('>', '')
.replaceAll('|', '');
var fileName = '${video.title}.${audio.container.name.toString()}'
.replaceAll('Container.', '')
.replaceAll(r'\', '')
.replaceAll('/', '')
.replaceAll('*', '')
.replaceAll('?', '')
.replaceAll('"', '')
.replaceAll('<', '')
.replaceAll('>', '')
.replaceAll('|', '');
var file = File('downloads/$fileName');
// Create the StreamedRequest to track the download status.
@ -60,24 +59,25 @@ Future<void> download(String id) async {
var output = file.openWrite(mode: FileMode.writeOnlyAppend);
// Track the file download status.
var len = audio.size;
var len = audio.size.totalBytes;
var count = 0;
var oldProgress = -1;
// Create the message and set the cursor position.
var msg = 'Downloading `${mediaStream.videoDetails.title}`: \n';
var row = console.cursorPosition.row;
var col = msg.length - 2;
console.cursorPosition = Coordinate(row, 0);
console.write(msg);
var msg = 'Downloading `${video.title}`(.${audio.container.name}): \n';
print(msg);
// var row = console.cursorPosition.row;
// var col = msg.length - 2;
// console.cursorPosition = Coordinate(row, 0);
// console.write(msg);
// Listen for data received.
await for (var data in audio.downloadStream()) {
await for (var data in audioStream) {
count += data.length;
var progress = ((count / len) * 100).round();
if (progress != oldProgress) {
console.cursorPosition = Coordinate(row, col);
console.write('$progress%');
// console.cursorPosition = Coordinate(row, col);
print('$progress%');
oldProgress = progress;
}
output.add(data);

View File

@ -0,0 +1,22 @@
import 'channel_id.dart';
/// YouTube channel metadata.
class Channel {
/// Channel ID.
final ChannelId id;
/// Channel URL.
String get url => 'https://www.youtube.com/channel/$id';
/// Channel title.
final String title;
/// URL of the channel's logo image.
final String logoUrl;
/// Initializes an instance of [Channel]
Channel(this.id, this.title, this.logoUrl);
@override
String toString() => 'Channel ($title)';
}

View File

@ -0,0 +1,56 @@
import '../extensions/helpers_extension.dart';
import '../playlists/playlists.dart';
import '../reverse_engineering/responses/responses.dart';
import '../reverse_engineering/youtube_http_client.dart';
import '../videos/video.dart';
import '../videos/video_id.dart';
import 'channel.dart';
import 'channel_id.dart';
import 'username.dart';
/// Queries related to YouTube channels.
class ChannelClient {
final YoutubeHttpClient _httpClient;
/// Initializes an instance of [ChannelClient]
ChannelClient(this._httpClient);
/// Gets the metadata associated with the specified channel.
/// [id] must be either a [ChannelId] or a string
/// which is parsed to a [ChannelId]
Future<Channel> get(dynamic id) async {
var channelPage = await ChannelPage.get(_httpClient, id.value);
return Channel(id, channelPage.channelTitle, channelPage.channelLogoUrl);
}
/// Gets the metadata associated with the channel of the specified user.
/// [username] must be either a [Username] or a string
/// which is parsed to a [Username]
Future<Channel> getByUsername(dynamic username) async {
username = Username.fromString(username);
var channelPage =
await ChannelPage.getByUsername(_httpClient, username.value);
return Channel(ChannelId(channelPage.channelId), channelPage.channelTitle,
channelPage.channelLogoUrl);
}
/// Gets the metadata associated with the channel
/// that uploaded the specified video.
Future<Channel> getByVideo(dynamic videoId) async {
videoId = VideoId.fromString(videoId);
var videoInfoResponse =
await VideoInfoResponse.get(_httpClient, videoId.value);
var playerResponse = videoInfoResponse.playerResponse;
var channelId = playerResponse.videoChannelId;
return await get(ChannelId(channelId));
}
/// Enumerates videos uploaded by the specified channel.
Stream<Video> getUploads(ChannelId id) {
var playlistId = 'UU${id.value.substringAfter('UC')}';
return PlaylistClient(_httpClient).getVideos(PlaylistId(playlistId));
}
}

View File

@ -0,0 +1,68 @@
import 'package:equatable/equatable.dart';
import '../extensions/helpers_extension.dart';
/// Encapsulates a valid YouTube channel ID.
class ChannelId extends Equatable {
/// ID as a string.
final String value;
/// Initializes an instance of [ChannelId]
ChannelId(String value) : value = parseChannelId(value) {
if (this.value == null) {
throw ArgumentError.value(value, 'value', 'Invalid channel id');
}
}
/// Returns true if the given id is a valid channel id.
static bool validateChannelId(String id) {
if (id.isNullOrWhiteSpace) {
return false;
}
if (!id.startsWith('UC')) {
return false;
}
if (id.length != 24) {
return false;
}
return !RegExp('[^0-9a-zA-Z_\-]').hasMatch(id);
}
/// Parses a channel id from an url.
/// Returns null if the username is not found.
static String parseChannelId(String url) {
if (url.isNullOrWhiteSpace) {
return null;
}
if (validateChannelId(url)) {
return url;
}
var regMatch = RegExp(r'youtube\..+?/channel/(.*?)(?:\?|&|/|$)')
.firstMatch(url)
?.group(1);
if (!regMatch.isNullOrWhiteSpace && validateChannelId(regMatch)) {
return regMatch;
}
return null;
}
/// Converts [obj] to a [ChannelId] by calling .toString on that object.
/// If it is already a [ChannelId], [obj] is returned
factory ChannelId.fromString(dynamic obj) {
if (obj is ChannelId) {
return obj;
}
return ChannelId(obj.toString());
}
@override
String toString() => '$value';
@override
List<Object> get props => [value];
}

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,56 @@
import '../extensions/helpers_extension.dart';
/// Encapsulates a valid YouTube user name.
class Username {
/// User name as string.
final String value;
/// Initializes an instance of [Username].
Username(String urlOrUsername) : value = parseUsername(urlOrUsername) {
if (value == null) {
throw ArgumentError.value(
urlOrUsername, 'urlOrUsername', 'Invalid username');
}
}
/// Returns true if the given username is a valid username.
static bool validateUsername(String name) {
if (name.isNullOrWhiteSpace) {
return false;
}
if (name.length > 20) {
return false;
}
return !RegExp('[^0-9a-zA-Z]').hasMatch(name);
}
/// Parses a username from a url.
static String parseUsername(String nameOrUrl) {
if (nameOrUrl.isNullOrWhiteSpace) {
return null;
}
if (validateUsername(nameOrUrl)) {
return nameOrUrl;
}
var regMatch = RegExp(r'youtube\..+?/user/(.*?)(?:\?|&|/|$)')
.firstMatch(nameOrUrl)
?.group(1);
if (!regMatch.isNullOrWhiteSpace && validateUsername(regMatch)) {
return regMatch;
}
return null;
}
/// Converts [obj] to a [Username] by calling .toString on that object.
/// If it is already a [Username], [obj] is returned
factory Username.fromString(dynamic obj) {
if (obj is Username) {
return obj;
}
return Username(obj.toString());
}
}

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

@ -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 {
@ -22,6 +22,10 @@ class Statistics extends Equatable {
return 1 + 4.0 * likeCount / (likeCount + dislikeCount);
}
@override
String toString() =>
'$viewCount views, $likeCount likes, $dislikeCount dislikes';
@override
List<Object> get props => [viewCount, likeCount, dislikeCount];
}

View File

@ -1,7 +1,9 @@
library youtube_explode.exceptions;
export 'unrecognized_structure_exception.dart';
export 'fatal_failure_exception.dart';
export 'request_limit_exceeded_exception.dart';
export 'transient_failure_exception.dart';
export 'video_requires_purchase_exception.dart';
export 'video_stream_unavailable_exception.dart';
export 'video_unavailable_exception.dart';
export 'video_unplayable_exception.dart';
export 'youtube_explode_exception.dart';

View File

@ -0,0 +1,26 @@
import 'package:http/http.dart';
import 'youtube_explode_exception.dart';
/// Exception thrown when a fatal failure occurs.
class FatalFailureException implements YoutubeExplodeException {
/// Description message
@override
final String message;
/// Initializes an instance of [FatalFailureException]
FatalFailureException(this.message);
/// Initializes an instance of [FatalFailureException] with a [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.
If this issue persists, please report it on the project's GitHub page.
Request: ${response.request}
Response: (${response.statusCode})
''';
@override
String toString() => 'FatalFailureException: $message';
}

View File

@ -0,0 +1,27 @@
import 'package:http/http.dart';
import 'youtube_explode_exception.dart';
/// Exception thrown when a fatal failure occurs.
class RequestLimitExceededException implements YoutubeExplodeException {
/// Description message
@override
final String message;
/// Initializes an instance of [RequestLimitExceededException]
RequestLimitExceededException(this.message);
/// Initializes an instance of [RequestLimitExceeded] with a [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.
To resolve this error, please wait some time and try again -or- try injecting an HttpClient that has cookies for an authenticated user.
Unfortunately, there's nothing the library can do to work around this error.
Request: ${response.request}
Response: $response
''';
@override
String toString() => 'RequestLimitExceeded: $message';
}

View File

@ -0,0 +1,26 @@
import 'package:http/http.dart';
import 'youtube_explode_exception.dart';
/// Exception thrown when a fatal failure occurs.
class TransientFailureException implements YoutubeExplodeException {
@override
final String message;
/// Initializes an instance of [TransientFailureException]
TransientFailureException(this.message);
/// Initializes an instance of [TransientFailureException] with a [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.
To resolve this error, please wait some time and try again.
If this issue persists, please report it on the project's GitHub page.
Request: ${response.request}
Response: $response
''';
@override
String toString() => 'TransientFailureException: $message';
}

View File

@ -1,18 +0,0 @@
/// Thrown when YoutubeExplode fails to extract required information.
/// This usually happens when YouTube makes changes that break YoutubeExplode.
class UnrecognizedStructureException implements FormatException {
///A message describing the format error.
@override
final String message;
/// The actual source input which caused the error.
@override
final String source;
/// Initializes an instance of [UnrecognizedStructureException]
const UnrecognizedStructureException([this.message, this.source]);
/// Unimplemented
@override
int get offset => throw UnsupportedError('Offset not supported');
}

View File

@ -1,16 +1,18 @@
import 'exceptions.dart';
import '../videos/video_id.dart';
import 'video_unplayable_exception.dart';
/// Thrown when a video is not playable because it requires purchase.
/// Exception thrown when the requested video requires purchase.
class VideoRequiresPurchaseException implements VideoUnplayableException {
/// ID of the video.
final String videoId;
/// ID of the preview video.
final String previewVideoId;
/// Initializes an instance of [VideoRequiresPurchaseException]
const VideoRequiresPurchaseException(this.videoId, this.previewVideoId);
/// Description message
@override
String get reason => 'Requires purchase';
final String message;
/// VideoId instance
final VideoId previewVideoId;
/// Initializes an instance of [VideoRequiresPurchaseException].
VideoRequiresPurchaseException.preview(VideoId videoId, this.previewVideoId)
: message = 'Video `$videoId` is unplayable because it requires purchase.'
'Streams are not available for this video.'
'There is a preview video available: `$previewVideoId`.';
}

View File

@ -1,20 +0,0 @@
/// Thrown when a video stream is not available
/// and returns a status code not equal to 200 OK.
class VideoStreamUnavailableException implements Exception {
/// The returned status code.
final int statusCode;
/// Url
final Uri url;
/// Initializes an instance of [VideoStreamUnavailableException]
VideoStreamUnavailableException(this.statusCode, this.url);
@override
String toString() => 'VideoStreamUnavailableException: '
'The video stream in not availble (status code: $statusCode).\n'
'Url: $url';
}

View File

@ -1,14 +1,23 @@
import '../videos/video_id.dart';
import 'exceptions.dart';
/// Thrown when a video is not available and cannot be processed.
/// This can happen because the video does not exist, is deleted,
/// is private, or due to other reasons.
class VideoUnavailableException implements Exception {
/// ID of the video.
final String videoId;
class VideoUnavailableException implements VideoUnplayableException {
/// Description message
@override
final String message;
/// Initializes an instance of [VideoUnavailableException]
const VideoUnavailableException(this.videoId);
VideoUnavailableException(this.message);
@override
String toString() =>
'VideoUnavailableException: Video $videoId is unavailable.';
/// Initializes an instance of [VideoUnplayableException] with a [VideoId]
VideoUnavailableException.unavailable(VideoId videoId)
: message = 'Video \'$videoId\' is unavailable\n'
'In most cases, this error indicates that the video doesn\'t exist, ' // ignore: lines_longer_than_80_chars
'is private, or has been taken down.\n'
'If you can however open this video in your browser in incognito mode, ' // ignore: lines_longer_than_80_chars
'it most likely means that YouTube changed something, which broke this library.\n' // ignore: lines_longer_than_80_chars
'Please report this issue on GitHub in that case.';
}

View File

@ -1,17 +1,34 @@
/// Thrown when a video is not playable and its streams cannot be resolved.
/// This can happen because the video requires purchase,
/// is blocked in your country, is controversial, or due to other reasons.
class VideoUnplayableException {
/// ID of the video.
final String videoId;
import '../videos/video_id.dart';
import 'youtube_explode_exception.dart';
/// Reason why the video can't be played.
final String reason;
/// Exception thrown when the requested video is unplayable.
class VideoUnplayableException implements YoutubeExplodeException {
/// Description message
@override
final String message;
/// Initializes an instance of [VideoUnplayableException]
const VideoUnplayableException(this.videoId, [this.reason]);
VideoUnplayableException(this.message);
String toString() =>
'VideoUnplayableException: Video $videoId couldn\'t be played.'
'${reason == null ? '' : 'Reason: $reason'}';
/// Initializes an instance of [VideoUnplayableException] with a [VideoId]
VideoUnplayableException.unplayable(VideoId videoId, {String reason = ''})
: message = 'Video \'$videoId\' is unplayable.\n'
'Streams are not available for this video.\n'
'In most cases, this error indicates that there are \n'
'some restrictions in place that prevent watching this video.\n'
'Reason: $reason';
/// Initializes an instance of [VideoUnplayableException] with a [VideoId]
VideoUnplayableException.liveStream(VideoId videoId)
: message = 'Video \'$videoId\' is an ongoing live stream.\n'
'Streams are not available for this video.\n'
'Please wait until the live stream finishes and try again.';
/// Initializes an instance of [VideoUnplayableException] with a [VideoId]
VideoUnplayableException.notLiveStream(VideoId videoId)
: message = 'Video \'$videoId\' is not an ongoing live stream.\n'
'Live stream manifest is not available for this video';
@override
String toString() => 'VideoUnplayableException: $message';
}

View File

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

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.findAllElements('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,7 +1,12 @@
import '../cipher/cipher_operations.dart';
library _youtube_explode.extensions;
import '../reverse_engineering/cipher/cipher_operations.dart';
/// Utility for Strings.
extension StringUtility on String {
/// Returns null if this string is whitespace.
String get nullIfWhitespace => trim().isEmpty ? null : this;
/// Returns true if the string is null or empty.
bool get isNullOrWhiteSpace {
if (this == null) {
@ -13,14 +18,21 @@ extension StringUtility on String {
return false;
}
/// Returns null if this string is a whitespace.
String substringUntil(String separator) => substring(0, indexOf(separator));
///
String substringAfter(String separator) =>
substring(indexOf(separator) + separator.length);
static final _exp = RegExp(r'\D');
/// Strips out all non digit characters.
String get stripNonDigits => replaceAll(_exp, '');
String stripNonDigits() => replaceAll(_exp, '');
}
/// List decipher utility.
extension ListDecipher on List<CipherOperation> {
extension ListDecipher on Iterable<CipherOperation> {
/// Apply every CipherOperation on the [signature]
String decipher(String signature) {
for (var operation in this) {
@ -41,3 +53,39 @@ extension ListFirst<E> on List<E> {
return first;
}
}
/// Uri utility
extension UriUtility on Uri {
/// Returns a new Uri with the new query parameters set.
Uri setQueryParam(String key, String value) {
var query = Map<String, String>.from(queryParameters);
query[key] = value;
return replace(queryParameters: query);
}
}
///
extension GetOrNull<K, V> on Map<K, V> {
/// Get a value from a map
V getValue(K key) {
var v = this[key];
if (v == null) {
return null;
}
return v;
}
}
///
extension GetOrNullMap on Map {
/// Get a map inside a map
Map<String, dynamic> get(String key) {
var v = this[key];
if (v == null) {
return null;
}
return v;
}
}

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,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,23 +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';

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,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,30 @@
import '../common/common.dart';
import 'playlist_id.dart';
/// YouTube playlist metadata.
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,67 @@
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(dynamic id) async {
id = PlaylistId.fromString(id);
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(dynamic id) async* {
id = PlaylistId.fromString(id);
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,107 @@
import '../extensions/helpers_extension.dart';
/// Encapsulates a valid YouTube playlist ID.
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=(.*?)(?:&|/|$)');
/// The playlist id as string.
final String value;
/// Initializes an instance of [PlaylistId]
PlaylistId(String idOrUrl) : value = parsePlaylistId(idOrUrl) {
if (value == null) {
throw ArgumentError.value(idOrUrl, 'idOrUrl', 'Invalid url');
}
}
/// Returns true if the given [playlistId] is valid.
static bool validatePlaylistId(String playlistId) {
playlistId = playlistId.toUpperCase();
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;
}
if (validatePlaylistId(url)) {
return url;
}
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;
}
@override
String toString() => value;
/// Converts [obj] to a [PlaylistId] by calling .toString on that object.
/// If it is already a [PlaylistId], [obj] is returned
factory PlaylistId.fromString(dynamic obj) {
if (obj is PlaylistId) {
return obj;
}
return PlaylistId(obj.toString());
}
}

View File

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

38
lib/src/retry.dart Normal file
View File

@ -0,0 +1,38 @@
library _youtube_explode.retry;
import 'dart:async';
import 'exceptions/exceptions.dart';
/// Run the [function] each time an exception is thrown until the retryCount
/// is 0.
Future<T> retry<T>(FutureOr<T> function()) async {
var retryCount = 5;
while (true) {
try {
return await function();
// ignore: avoid_catches_without_on_clauses
} on Exception catch (e) {
retryCount -= getExceptionCost(e);
if (retryCount <= 0) {
rethrow;
}
await Future.delayed(const Duration(milliseconds: 500));
}
}
}
/// Get "retry" cost of each YoutubeExplode exception.
int getExceptionCost(Exception e) {
if (e is TransientFailureException) {
return 1;
}
if (e is RequestLimitExceededException) {
return 2;
}
if (e is FatalFailureException) {
return 3;
}
return 100;
}

View File

@ -0,0 +1,196 @@
import '../extensions/helpers_extension.dart';
import '../videos/streams/video_quality.dart';
import '../videos/streams/video_resolution.dart';
const _qualityMap = <int, VideoQuality>{
5: VideoQuality.low144,
6: VideoQuality.low240,
13: VideoQuality.low144,
17: VideoQuality.low144,
18: VideoQuality.medium360,
22: VideoQuality.high720,
34: VideoQuality.medium360,
35: VideoQuality.medium480,
36: VideoQuality.low240,
37: VideoQuality.high1080,
38: VideoQuality.high3072,
43: VideoQuality.medium360,
44: VideoQuality.medium480,
45: VideoQuality.high720,
46: VideoQuality.high1080,
59: VideoQuality.medium480,
78: VideoQuality.medium480,
82: VideoQuality.medium360,
83: VideoQuality.medium480,
84: VideoQuality.high720,
85: VideoQuality.high1080,
91: VideoQuality.low144,
92: VideoQuality.low240,
93: VideoQuality.medium360,
94: VideoQuality.medium480,
95: VideoQuality.high720,
96: VideoQuality.high1080,
100: VideoQuality.medium360,
101: VideoQuality.medium480,
102: VideoQuality.high720,
132: VideoQuality.low240,
151: VideoQuality.low144,
133: VideoQuality.low240,
134: VideoQuality.medium360,
135: VideoQuality.medium480,
136: VideoQuality.high720,
137: VideoQuality.high1080,
138: VideoQuality.high4320,
160: VideoQuality.low144,
212: VideoQuality.medium480,
213: VideoQuality.medium480,
214: VideoQuality.high720,
215: VideoQuality.high720,
216: VideoQuality.high1080,
217: VideoQuality.high1080,
264: VideoQuality.high1440,
266: VideoQuality.high2160,
298: VideoQuality.high720,
299: VideoQuality.high1080,
399: VideoQuality.high1080,
398: VideoQuality.high720,
397: VideoQuality.medium480,
396: VideoQuality.medium360,
395: VideoQuality.low240,
394: VideoQuality.low144,
167: VideoQuality.medium360,
168: VideoQuality.medium480,
169: VideoQuality.high720,
170: VideoQuality.high1080,
218: VideoQuality.medium480,
219: VideoQuality.medium480,
242: VideoQuality.low240,
243: VideoQuality.medium360,
244: VideoQuality.medium480,
245: VideoQuality.medium480,
246: VideoQuality.medium480,
247: VideoQuality.high720,
248: VideoQuality.high1080,
271: VideoQuality.high1440,
272: VideoQuality.high2160,
278: VideoQuality.low144,
302: VideoQuality.high720,
303: VideoQuality.high1080,
308: VideoQuality.high1440,
313: VideoQuality.high2160,
315: VideoQuality.high2160,
330: VideoQuality.low144,
331: VideoQuality.low240,
332: VideoQuality.medium360,
333: VideoQuality.medium480,
334: VideoQuality.high720,
335: VideoQuality.high1080,
336: VideoQuality.high1440,
337: VideoQuality.high2160,
};
const _resolutionMap = <VideoQuality, VideoResolution>{
VideoQuality.low144: VideoResolution(256, 144),
VideoQuality.low240: VideoResolution(426, 240),
VideoQuality.medium360: VideoResolution(640, 360),
VideoQuality.medium480: VideoResolution(854, 480),
VideoQuality.high720: VideoResolution(1280, 720),
VideoQuality.high1080: VideoResolution(1920, 1080),
VideoQuality.high1440: VideoResolution(2560, 1440),
VideoQuality.high2160: VideoResolution(3840, 2160),
VideoQuality.high2880: VideoResolution(5120, 2880),
VideoQuality.high3072: VideoResolution(4096, 3072),
VideoQuality.high4320: VideoResolution(7680, 4320),
};
/// Utilities for [VideoQuality]
extension VideoQualityUtil on VideoQuality {
/// Parses the itag as [VideoQuality]
/// Throws an [ArgumentError] if the itag matches no video quality.
static VideoQuality fromTag(int itag) {
var q = _qualityMap[itag];
if (q == null) {
throw ArgumentError.value(itag, 'itag', 'Unrecognized itag');
}
return q;
}
/// Parses the label as [VideoQuality]
/// Throws an [ArgumentError] if the string matches no video quality.
static VideoQuality fromLabel(String label) {
label = label.toLowerCase();
if (label.startsWith('144')) {
return VideoQuality.low144;
}
if (label.startsWith('240')) {
return VideoQuality.low144;
}
if (label.startsWith('360')) {
return VideoQuality.medium360;
}
if (label.startsWith('480')) {
return VideoQuality.medium480;
}
if (label.startsWith('720')) {
return VideoQuality.high720;
}
if (label.startsWith('1080')) {
return VideoQuality.high1080;
}
if (label.startsWith('1440')) {
return VideoQuality.high1440;
}
if (label.startsWith('2160')) {
return VideoQuality.high2160;
}
if (label.startsWith('2880')) {
return VideoQuality.high2880;
}
if (label.startsWith('3072')) {
return VideoQuality.high3072;
}
if (label.startsWith('4320')) {
return VideoQuality.high4320;
}
throw ArgumentError.value(
label, 'label', 'Unrecognized video quality label');
}
String getLabel() => '${toString().stripNonDigits()}p';
String getLabelWithFramerate(double framerate) {
// Framerate appears only if it's above 30
if (framerate <= 30) {
return getLabel();
}
var framerateRounded = (framerate / 10).ceil() * 10;
return '${getLabel()}$framerateRounded';
}
static String getLabelFromTagWithFramerate(int itag, double framerate) {
var videoQuality = fromTag(itag);
return videoQuality.getLabelWithFramerate(framerate);
}
/// Returns a [VideoResolution] from its [VideoQuality]
VideoResolution toVideoResolution() {
var r = _resolutionMap[this];
if (r == null) {
throw ArgumentError.value(this, 'quality', 'Unrecognized video quality');
}
return r;
}
}

View File

@ -0,0 +1,57 @@
import 'package:html/dom.dart';
import 'package:html/parser.dart' as parser;
import '../../exceptions/exceptions.dart';
import '../../extensions/helpers_extension.dart';
import '../../retry.dart';
import '../youtube_http_client.dart';
class ChannelPage {
final Document _root;
bool get isOk => _root.querySelector('meta[property="og:url"]') != null;
String get channelUrl =>
_root.querySelector('meta[property="og:url"]')?.attributes['content'];
String get channelId => channelUrl.substringAfter('channel/');
String get channelTitle =>
_root.querySelector('meta[property="og:title"]')?.attributes['content'];
String get channelLogoUrl =>
_root.querySelector('meta[property="og:image"]')?.attributes['content'];
ChannelPage(this._root);
ChannelPage.parse(String raw) : _root = parser.parse(raw);
static Future<ChannelPage> get(YoutubeHttpClient httpClient, String id) {
var url = 'https://www.youtube.com/channel/$id?hl=en';
return retry(() async {
var raw = await httpClient.getString(url);
var result = ChannelPage.parse(raw);
if (!result.isOk) {
throw TransientFailureException('Channel page is broken');
}
return result;
});
}
static Future<ChannelPage> getByUsername(
YoutubeHttpClient httpClient, String username) {
var url = 'https://www.youtube.com/user/$username?hl=en';
return retry(() async {
var raw = await httpClient.getString(url);
var result = ChannelPage.parse(raw);
if (!result.isOk) {
throw TransientFailureException('Channel page is broken');
}
return result;
});
}
}

View File

@ -0,0 +1,63 @@
import 'package:xml/xml.dart' as xml;
import '../../retry.dart';
import '../youtube_http_client.dart';
class ClosedCaptionTrackResponse {
final xml.XmlDocument _root;
ClosedCaptionTrackResponse(this._root);
Iterable<ClosedCaption> get closedCaptions =>
_root.findAllElements('p').map((e) => ClosedCaption._(e));
ClosedCaptionTrackResponse.parse(String raw) : _root = xml.parse(raw);
static Future<ClosedCaptionTrackResponse> get(
YoutubeHttpClient httpClient, String url) {
var formatUrl = _setQueryParameters(url, {'format': '3'});
return retry(() async {
var raw = await httpClient.getString(formatUrl);
return ClosedCaptionTrackResponse.parse(raw);
});
}
static Uri _setQueryParameters(String url, Map<String, String> parameters) {
var uri = Uri.parse(url);
var query = Map<String, String>.from(uri.queryParameters);
query.addAll(parameters);
return uri.replace(queryParameters: query);
}
}
class ClosedCaption {
final xml.XmlElement _root;
ClosedCaption._(this._root);
String get text => _root.text;
Duration get offset =>
Duration(milliseconds: int.parse(_root.getAttribute('t') ?? 0));
Duration get duration =>
Duration(milliseconds: int.parse(_root.getAttribute('d') ?? 0));
Duration get end => offset + duration;
Iterable<ClosedCaptionPart> getParts() =>
_root.findAllElements('s').map((e) => ClosedCaptionPart._(e));
}
class ClosedCaptionPart {
final xml.XmlElement _root;
ClosedCaptionPart._(this._root);
String get text => _root.text;
Duration get offset =>
Duration(milliseconds: int.parse(_root.getAttribute('t') ?? '0'));
}

View File

@ -0,0 +1,78 @@
import 'package:xml/xml.dart' as xml;
import '../../retry.dart';
import '../youtube_http_client.dart';
import 'stream_info_provider.dart';
class DashManifest {
static final _urlSignatureExp = RegExp(r'/s/(.*?)(?:/|$)');
final xml.XmlDocument _root;
DashManifest(this._root);
Iterable<_StreamInfo> get streams => _root
.findElements('Representation')
.where((e) => e
.findElements('Initialization')
.first
.getAttribute('sourceURL')
.contains('sq/'))
.map((e) => _StreamInfo(e));
DashManifest.parse(String raw) : _root = xml.parse(raw);
static Future<DashManifest> get(YoutubeHttpClient httpClient, dynamic url) {
return retry(() async {
var raw = await httpClient.getString(url);
return DashManifest.parse(raw);
});
}
static String getSignatureFromUrl(String url) =>
_urlSignatureExp.firstMatch(url)?.group(1);
}
class _StreamInfo extends StreamInfoProvider {
static final _contentLenExp = RegExp(r'[/\?]clen[/=](\d+)');
static final _containerExp = RegExp(r'mime[/=]\w*%2F([\w\d]*)');
final xml.XmlElement _root;
_StreamInfo(this._root);
@override
int get tag => int.parse(_root.getAttribute('id'));
@override
String get url => _root.getAttribute('BaseURL');
@override
int get contentLength => int.parse(_root.getAttribute('contentLength') ??
_contentLenExp.firstMatch(url).group(1));
@override
int get bitrate => int.parse(_root.getAttribute('bandwidth'));
@override
String get container =>
Uri.decodeFull(_containerExp.firstMatch(url).group(1));
bool get isAudioOnly =>
_root.findElements('AudioChannelConfiguration').isNotEmpty;
@override
String get audioCodec => isAudioOnly ? null : _root.getAttribute('codecs');
@override
String get videoCodec => isAudioOnly ? _root.getAttribute('codecs') : null;
@override
int get videoWidth => int.parse(_root.getAttribute('width'));
@override
int get videoHeight => int.parse(_root.getAttribute('height'));
@override
int get framerate => int.parse(_root.getAttribute('framerate'));
}

View File

@ -0,0 +1,50 @@
import 'dart:convert';
import 'package:html/dom.dart';
import 'package:html/parser.dart' as parser;
import '../../extensions/helpers_extension.dart';
import '../../retry.dart';
import '../youtube_http_client.dart';
class EmbedPage {
static final _playerConfigExp =
RegExp(r"yt\.setConfig\({'PLAYER_CONFIG':(.*)}\);");
final Document _root;
EmbedPage(this._root);
_PlayerConfig get playerconfig {
var playerConfigJson = _playerConfigJson;
if (playerConfigJson == null) {
return null;
}
return _PlayerConfig(json.decode(playerConfigJson));
}
String get _playerConfigJson => _root
.getElementsByTagName('script')
.map((e) => e.text)
.map((e) => _playerConfigExp.firstMatch(e)?.group(1))
.firstWhere((e) => !e.isNullOrWhiteSpace, orElse: () => null);
EmbedPage.parse(String raw) : _root = parser.parse(raw);
static Future<EmbedPage> get(YoutubeHttpClient httpClient, String videoId) {
var url = 'https://youtube.com/embed/$videoId?hl=en';
return retry(() async {
var raw = await httpClient.getString(url);
return EmbedPage.parse(raw);
});
}
}
class _PlayerConfig {
// Json parsed map.
final Map<String, dynamic> _root;
_PlayerConfig(this._root);
String get sourceUrl => 'https://youtube.com${_root['assets']['js']}';
}

View File

@ -0,0 +1,186 @@
import 'dart:convert';
import 'package:http_parser/http_parser.dart';
import '../../extensions/helpers_extension.dart';
import 'stream_info_provider.dart';
class PlayerResponse {
// Json parsed map
final Map<String, dynamic> _root;
PlayerResponse(this._root);
String get playabilityStatus => _root['playabilityStatus']['status'];
bool get isVideoAvailable => playabilityStatus.toLowerCase() != 'error';
bool get isVideoPlayable => playabilityStatus.toLowerCase() == 'ok';
String get videoTitle => _root['videoDetails']['title'];
String get videoAuthor => _root['videoDetails']['author'];
DateTime get videoUploadDate => DateTime.parse(
_root['microformat']['playerMicroformatRenderer']['uploadDate']);
String get videoChannelId => _root['videoDetails']['channelId'];
Duration get videoDuration =>
Duration(seconds: int.parse(_root['videoDetails']['lengthSeconds']));
Iterable<String> get videoKeywords =>
_root['videoDetails']['keywords']?.cast<String>() ?? const [];
String get videoDescription => _root['videoDetails']['shortDescription'];
int get videoViewCount => int.parse(_root['videoDetails']['viewCount']);
// Can be null
String get previewVideoId =>
_root
.get('playabilityStatus')
?.get('errorScreen')
?.get('playerLegacyDesktopYpcTrailerRenderer')
?.getValue('trailerVideoId') ??
Uri.splitQueryString(_root
.get('playabilityStatus')
?.get('errorScreen')
?.get('')
?.get('ypcTrailerRenderer')
?.getValue('playerVars') ??
'')['video_id'];
bool get isLive => _root.get('videoDetails')?.getValue('isLive') ?? false;
// Can be null
String get hlsManifestUrl =>
_root.get('streamingData')?.getValue('hlsManifestUrl');
// Can be null
String get dashManifestUrl =>
_root.get('streamingData')?.getValue('dashManifestUrl');
Iterable<StreamInfoProvider> get muxedStreams =>
_root
?.get('streamingData')
?.getValue('formats')
?.map((e) => _StreamInfo(e))
?.cast<StreamInfoProvider>() ??
const <StreamInfoProvider>[];
Iterable<StreamInfoProvider> get adaptiveStreams =>
_root
?.get('streamingData')
?.getValue('adaptiveFormats')
?.map((e) => _StreamInfo(e))
?.cast<StreamInfoProvider>() ??
const <StreamInfoProvider>[];
Iterable<StreamInfoProvider> get streams =>
[...muxedStreams, ...adaptiveStreams];
Iterable<ClosedCaptionTrack> get closedCaptionTrack =>
_root
.get('captions')
?.get('playerCaptionsTracklistRenderer')
?.getValue('captionTracks')
?.map((e) => ClosedCaptionTrack(e))
?.cast<ClosedCaptionTrack>() ??
const [];
String getVideoPlayabilityError() =>
_root.get('playabilityStatus')?.getValue('reason');
PlayerResponse.parse(String raw) : _root = json.decode(raw);
}
class ClosedCaptionTrack {
// Json parsed map
final Map<String, dynamic> _root;
ClosedCaptionTrack(this._root);
String get url => _root['baseUrl'];
String get languageCode => _root['languageCode'];
String get languageName => _root['name']['simpleText'];
bool get autoGenerated => _root['vssId'].toLowerCase().startsWith("a.");
}
class _StreamInfo extends StreamInfoProvider {
// Json parsed map
final Map<String, dynamic> _root;
_StreamInfo(this._root);
@override
int get bitrate => _root['bitrate'];
@override
String get container => mimeType.subtype;
static final _contentLenExp = RegExp(r'[\?&]clen=(\d+)');
@override
int get contentLength =>
int.tryParse(_root['contentLength'] ?? '') ??
_contentLenExp.firstMatch(url)?.group(1);
@override
int get framerate => _root['fps'];
@override
String get signature =>
Uri.splitQueryString(_root['signatureCipher'] ?? '')['s'];
@override
String get signatureParameter =>
Uri.splitQueryString(_root['cipher'] ?? '')['sp'] ??
Uri.splitQueryString(_root['signatureCipher'] ?? '')['sp'];
@override
int get tag => _root['itag'];
@override
String get url => _getUrl();
String _getUrl() {
var url = _root['url'];
url ??= Uri.splitQueryString(_root['cipher'] ?? '')['url'];
url ??= Uri.splitQueryString(_root['signatureCipher'] ?? '')['url'];
return url;
}
@override
String get videoCodec =>
isAudioOnly ? null : codecs.split(',').first.trim().nullIfWhitespace;
@override
int get videoHeight => _root['height'];
@override
String get videoQualityLabel => _root['qualityLabel'];
@override
int get videoWidth => _root['width'];
bool get isAudioOnly => mimeType.type == 'audio';
MediaType get mimeType => MediaType.parse(_root['mimeType']);
String get codecs => mimeType?.parameters['codecs']?.toLowerCase();
@override
String get audioCodec =>
isAudioOnly ? codecs : _getAudioCodec(codecs.split(','))?.trim();
String _getAudioCodec(List<String> codecs) {
if (codecs.length == 1) {
return null;
}
return codecs.last;
}
}

View File

@ -0,0 +1,118 @@
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+)\)');
final RegExp _funcBodyExp = RegExp(
r'(\w+)=function\(\w+\){(\w+)=\2\.split\(\x22{2}\);.*?return\s+\2\.join\(\x22{2}\)}');
final RegExp _funcNameExp = RegExp(r'(\w+).\w+\(\w+,\d+\);');
final RegExp _calledFuncNameExp = RegExp(r'\w+(?:.|\[)(\"?\w+(?:\")?)\]?\(');
final String _root;
PlayerSource(this._root);
String get sts {
var val = RegExp(r'(?<=invalid namespace.*?;\w+\s*=)\d+')
.stringMatch(_root)
?.nullIfWhitespace;
if (val == null) {
throw FatalFailureException('Could not find sts in player source.');
}
return val;
}
Iterable<CipherOperation> getCiperOperations() sync* {
var funcBody = _getDeciphererFuncBody();
if (funcBody == null) {
throw FatalFailureException(
'Could not find signature decipherer function body.');
}
var definitionBody = _getDeciphererDefinitionBody(funcBody);
if (definitionBody == null) {
throw FatalFailureException(
'Could not find signature decipherer definition body.');
}
for (var statement in funcBody.split(';')) {
var calledFuncName = _calledFuncNameExp.firstMatch(statement)?.group(1);
if (calledFuncName.isNullOrWhiteSpace) {
continue;
}
var escapedFuncName = RegExp.escape(calledFuncName);
// Slice
var exp = RegExp('$escapedFuncName'
r':\bfunction\b\([a],b\).(\breturn\b)?.?\w+\.');
if (exp.hasMatch(definitionBody)) {
var index = int.parse(_statIndexExp.firstMatch(statement).group(1));
yield SliceCipherOperation(index);
}
// Swap
exp = RegExp(
'$escapedFuncName' r':\bfunction\b\(\w+\,\w\).\bvar\b.\bc=a\b');
if (exp.hasMatch(definitionBody)) {
var index = int.parse(_statIndexExp.firstMatch(statement).group(1));
yield SwapCipherOperation(index);
}
// Reverse
exp = RegExp('$escapedFuncName' r':\bfunction\b\(\w+\)');
if (exp.hasMatch(definitionBody)) {
yield const ReverseCipherOperation();
}
}
}
String _getDeciphererFuncBody() {
var funcName = _funcBodyExp.firstMatch(_root).group(1);
var exp = RegExp(
r'(?!h\.)' '${RegExp.escape(funcName)}' r'=function\(\w+\)\{(.*?)\}');
return exp.firstMatch(_root).group(1).nullIfWhitespace;
}
String _getDeciphererDefinitionBody(String deciphererFuncBody) {
var funcName = _funcNameExp.firstMatch(deciphererFuncBody).group(1);
var exp = RegExp(
r'var\s+'
'${RegExp.escape(funcName)}'
r'=\{(\w+:function\(\w+(,\w+)?\)\{(.*?)\}),?\};',
dotAll: true);
return exp.firstMatch(_root).group(0).nullIfWhitespace;
}
// Same as default constructor
PlayerSource.parse(this._root);
static Future<PlayerSource> get(YoutubeHttpClient httpClient, String url) {
return retry(() async {
var raw = await httpClient.getString(url);
return PlayerSource.parse(raw);
});
}
}
extension on String {
String get nullIfWhitespace => trim().isEmpty ? null : this;
bool get isNullOrWhiteSpace {
if (this == null) {
return true;
}
if (trim().isEmpty) {
return true;
}
return false;
}
}

View File

@ -0,0 +1,96 @@
import 'dart:convert';
import '../../exceptions/exceptions.dart';
import '../../extensions/helpers_extension.dart';
import '../../retry.dart';
import '../youtube_http_client.dart';
class PlaylistResponse {
// Json parsed map
final Map<String, dynamic> _root;
PlaylistResponse(this._root);
String get title => _root['title'];
String get author => _root['author'];
String get description => _root['description'];
int get viewCount => _root['views'];
int get likeCount => _root['likes'];
int get dislikeCount => _root['dislikes'];
Iterable<_Video> get videos =>
_root['video']?.map((e) => _Video(e))?.cast<_Video>() ?? const <_Video>[];
PlaylistResponse.parse(String raw) : _root = json.tryDecode(raw) {
if (_root == null) {
throw TransientFailureException('Playerlist response is broken.');
}
}
static Future<PlaylistResponse> get(YoutubeHttpClient httpClient, String id,
{int index = 0}) {
var url =
'https://youtube.com/list_ajax?style=json&action_get_list=1&list=$id&index=$index&hl=en';
return retry(() async {
var raw = await httpClient.getString(url);
return PlaylistResponse.parse(raw);
});
}
static Future<PlaylistResponse> searchResults(
YoutubeHttpClient httpClient, String query,
{int page = 0}) {
var url = 'https://youtube.com/search_ajax?style=json&search_query='
'${Uri.encodeQueryComponent(query)}&page=$page&hl=en';
return retry(() async {
var raw = await httpClient.getString(url, validate: false);
return PlaylistResponse.parse(raw);
});
}
}
class _Video {
// Json parsed map
final Map<String, dynamic> _root;
_Video(this._root);
String get id => _root['encrypted_id'];
String get author => _root['author'];
DateTime get uploadDate =>
DateTime.fromMillisecondsSinceEpoch(_root['time_created'] * 1000);
String get title => _root['title'];
String get description => _root['description'];
Duration get duration => Duration(seconds: _root['length_seconds']);
int get viewCount => int.parse((_root['views'] as String).stripNonDigits());
int get likes => _root['likes'];
int get dislikes => _root['dislikes'];
Iterable<String> get keywords => RegExp(r'"[^\"]+"|\S+')
.allMatches(_root['keywords'])
.map((e) => e.group(0))
.toList(growable: false);
}
extension on JsonCodec {
dynamic tryDecode(String source) {
try {
return json.decode(source);
} on FormatException {
return null;
}
}
}

View File

@ -0,0 +1,12 @@
library _youtube_explode.responses;
export 'channel_page.dart';
export 'closed_caption_track_response.dart';
export 'dash_manifest.dart';
export 'embed_page.dart';
export 'player_response.dart';
export 'player_source.dart';
export 'playerlist_response.dart';
export 'stream_info_provider.dart';
export 'video_info_response.dart';
export 'watch_page.dart';

View File

@ -0,0 +1,38 @@
abstract class StreamInfoProvider {
static final RegExp contentLenExp = RegExp(r'clen=(\d+)');
int get tag;
String get url;
// Can be null
String get signature => null;
// Can be null
String get signatureParameter => null;
// Can be null
int get contentLength => null;
int get bitrate;
String get container;
// Can be null
String get audioCodec => null;
// Can be null
String get videoCodec => null;
// Can be null
String get videoQualityLabel => null;
// Can be null
int get videoWidth => null;
// Can be null
int get videoHeight => null;
// Can be null
int get framerate => null;
}

View File

@ -0,0 +1,130 @@
import 'package:http_parser/http_parser.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;
VideoInfoResponse(this._root);
String get status => _root['status'];
bool get isVideoAvailable => status.toLowerCase() != 'fail';
PlayerResponse get playerResponse =>
PlayerResponse.parse(_root['player_response']);
Iterable<_StreamInfo> get muxedStreams =>
_root['url_encoded_fmt_stream_map']
?.split(',')
?.map(Uri.splitQueryString)
?.map((e) => _StreamInfo(e)) ??
const [];
Iterable<_StreamInfo> get adaptiveStreams =>
_root['adaptive_fmts']
?.split(',')
?.map(Uri.splitQueryString)
?.map((e) => _StreamInfo(e)) ??
const [];
Iterable<_StreamInfo> get streams => [...muxedStreams, ...adaptiveStreams];
VideoInfoResponse.parse(String raw) : _root = Uri.splitQueryString(raw);
static Future<VideoInfoResponse> get(
YoutubeHttpClient httpClient, String videoId,
[String sts]) {
var eurl = Uri.encodeFull('https://youtube.googleapis.com/v/$videoId');
var url =
'https://youtube.com/get_video_info?video_id=$videoId&el=embedded&eurl=$eurl&hl=en${sts != null ? '&sts=$sts' : ''}';
return retry(() async {
var raw = await httpClient.getString(url);
var result = VideoInfoResponse.parse(raw);
if (!result.isVideoAvailable || !result.playerResponse.isVideoAvailable) {
throw VideoUnplayableException(videoId);
}
return result;
});
}
}
class _StreamInfo extends StreamInfoProvider {
final Map<String, String> _root;
_StreamInfo(this._root);
@override
int get tag => int.parse(_root['itag']);
@override
String get url => _root['url'];
@override
String get signature => _root['s'];
@override
String get signatureParameter => _root['sp'];
@override
int get contentLength => int.tryParse(_root['clen'] ??
StreamInfoProvider.contentLenExp.firstMatch(url).group(1));
@override
int get bitrate => int.parse(_root['bitrate']);
MediaType get mimeType => MediaType.parse(_root["type"]);
@override
String get container => mimeType.subtype;
List<String> get codecs =>
mimeType.parameters['codecs'].split(',').map((e) => e.trim());
@override
String get audioCodec => codecs.last;
@override
String get videoCodec => isAudioOnly ? null : codecs.first;
bool get isAudioOnly => mimeType.type == 'audio';
@override
String get videoQualityLabel => _root['quality_label'];
List<int> get _size =>
_root['size'].split(',').map((e) => int.tryParse(e ?? ''));
@override
int get videoWidth => _size.first;
@override
int get videoHeight => _size.last;
@override
int get framerate => int.tryParse(_root['fps'] ?? '');
}
extension on String {
String get nullIfWhitespace => trim().isEmpty ? null : this;
bool get isNullOrWhiteSpace {
if (this == null) {
return true;
}
if (trim().isEmpty) {
return true;
}
return false;
}
String substringUntil(String separator) => substring(0, indexOf(separator));
String substringAfter(String separator) =>
substring(indexOf(separator) + length);
}

View File

@ -0,0 +1,192 @@
import 'dart:convert';
import 'package:html/dom.dart';
import 'package:html/parser.dart' as parser;
import 'package:http_parser/http_parser.dart';
import '../../../youtube_explode_dart.dart';
import '../../extensions/helpers_extension.dart';
import '../../retry.dart';
import '../../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"');
final RegExp _videoDislikeExp =
RegExp(r'"label"\s*:\s*"([\d,\.]+) dislikes"');
final Document _root;
WatchPage(this._root);
bool get isOk => _root.body.querySelector('#player') != null;
bool get isVideoAvailable =>
_root.querySelector('meta[property="og:url"]') != null;
//TODO: Update this to the new "parsing method" w/ regex "label"\s*:\s*"([\d,\.]+) likes"
int get videoLikeCount => int.parse(_videoLikeExp
.firstMatch(_root.outerHtml)
?.group(1)
?.stripNonDigits()
?.nullIfWhitespace ??
_root
.querySelector('.like-button-renderer-like-button')
?.text
?.stripNonDigits()
?.nullIfWhitespace ??
'0');
//TODO: Update this to the new "parsing method" w/ regex "label"\s*:\s*"([\d,\.]+) dislikes"
int get videoDislikeCount => int.parse(_videoDislikeExp
.firstMatch(_root.outerHtml)
?.group(1)
?.stripNonDigits()
?.nullIfWhitespace ??
_root
.querySelector('.like-button-renderer-dislike-button')
?.text
?.stripNonDigits()
?.nullIfWhitespace ??
'0');
_PlayerConfig get playerConfig => _PlayerConfig(json.decode(
_matchJson(_extractJson(_root.getElementsByTagName('html').first.text))));
final String configSep = 'ytplayer.config = ';
String _extractJson(String html) {
return _matchJson(
html.substring(html.indexOf(configSep) + configSep.length));
}
String _matchJson(String str) {
var bracketCount = 0;
int lastI;
for (var i = 0; i < str.length; i++) {
lastI = i;
if (str[i] == '{') {
bracketCount++;
} else if (str[i] == '}') {
bracketCount--;
} else if (str[i] == ';') {
if (bracketCount == 0) {
return str.substring(0, i);
}
}
}
return str.substring(0, lastI + 1);
}
WatchPage.parse(String raw) : _root = parser.parse(raw);
static Future<WatchPage> get(YoutubeHttpClient httpClient, String videoId) {
final url = 'https://youtube.com/watch?v=$videoId&bpctr=9999999999&hl=en';
return retry(() async {
var raw = await httpClient.getString(url);
var result = WatchPage.parse(raw);
if (!result.isOk) {
throw TransientFailureException("Video watch page is broken.");
}
if (!result.isVideoAvailable) {
throw VideoUnavailableException.unavailable(VideoId(videoId));
}
return result;
});
}
}
class _StreamInfo extends StreamInfoProvider {
final Map<String, String> _root;
_StreamInfo(this._root);
@override
int get bitrate => int.parse(_root['bitrate']);
@override
int get tag => int.parse(_root['itag']);
@override
String get url => _root['url'];
@override
String get signature => _root['s'];
@override
String get signatureParameter => _root['sp'];
@override
int get contentLength => int.tryParse(_root['clen'] ??
StreamInfoProvider.contentLenExp
.firstMatch(url)
.group(1)
.nullIfWhitespace ??
'');
MediaType get mimeType => MediaType.parse(_root['mimeType']);
@override
String get container => mimeType.subtype;
bool get isAudioOnly => mimeType.type == 'audio';
@override
String get audioCodec => codecs.last;
@override
String get videoCodec => isAudioOnly ? null : codecs.first;
List<String> get codecs =>
mimeType.parameters['codecs'].split(',').map((e) => e.trim());
@override
String get videoQualityLabel => _root['quality_label'];
List<int> get _size =>
_root['size'].split(',').map((e) => int.tryParse(e ?? ''));
@override
int get videoWidth => _size.first;
@override
int get videoHeight => _size.last;
@override
int get framerate => int.tryParse(_root['fps'] ?? '');
}
class _PlayerConfig {
// Json parsed map
final Map<String, dynamic> _root;
_PlayerConfig(this._root);
String get sourceUrl => 'https://youtube.com${_root['assets']['js']}';
PlayerResponse get playerResponse =>
PlayerResponse.parse(_root['args']['player_response']);
List<_StreamInfo> get muxedStreams =>
_root
.get('args')
?.getValue('url_encoded_fmt_stream_map')
?.split(',')
?.map((e) => _StreamInfo(Uri.splitQueryString(e))) ??
const [];
List<_StreamInfo> get adaptiveStreams =>
_root
.get('args')
?.getValue('adaptive_fmts')
?.split(',')
?.map((e) => _StreamInfo(Uri.splitQueryString(e))) ??
const [];
List<_StreamInfo> get streams => [...muxedStreams, ...adaptiveStreams];
}

View File

@ -0,0 +1,94 @@
import 'package:http/http.dart';
import '../exceptions/exceptions.dart';
import '../videos/streams/streams.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/81.0.4044.138 Safari/537.36'
};
/// Throws if something is wrong with the response.
void _validateResponse(BaseResponse response, int statusCode) {
var request = response.request;
if (request.url.host.endsWith('.google.com') &&
request.url.path.startsWith('/sorry/')) {
throw RequestLimitExceededException.httpRequest(response);
}
if (statusCode >= 500) {
throw TransientFailureException.httpRequest(response);
}
if (statusCode == 429) {
throw RequestLimitExceededException.httpRequest(response);
}
if (statusCode >= 400) {
throw FatalFailureException.httpRequest(response);
}
}
Future<Response> get(dynamic url, {Map<String, String> headers}) {
return _httpClient.get(url, headers: {...?headers, ..._userAgent});
}
Future<Response> head(dynamic url, {Map<String, String> 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, ..._userAgent});
if (validate) {
_validateResponse(response, response.statusCode);
}
return response.body;
}
Stream<List<int>> getStream(StreamInfo streamInfo,
{Map<String, String> headers, bool validate = true}) async* {
var url = streamInfo.url;
if (!streamInfo.isRateLimited()) {
var request = Request('get', url);
request.headers.addAll(_userAgent);
var response = await request.send();
if (validate) {
_validateResponse(response, response.statusCode);
}
yield* response.stream;
} else {
for (var i = 0; i < streamInfo.size.totalBytes; i += 9898989) {
var request = Request('get', url);
request.headers['range'] = 'bytes=$i-${i + 9898989}';
request.headers.addAll(_userAgent);
var response = await request.send();
if (validate) {
_validateResponse(response, response.statusCode);
}
yield* response.stream;
}
}
}
Future<int> getContentLength(dynamic url,
{Map<String, String> headers, bool validate = true}) async {
var response = await head(url, headers: headers);
if (validate) {
_validateResponse(response, response.statusCode);
}
return int.tryParse(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,49 @@
import '../common/common.dart';
import '../reverse_engineering/responses/playerlist_response.dart';
import '../reverse_engineering/youtube_http_client.dart';
import '../videos/video.dart';
import '../videos/video_id.dart';
/// YouTube search queries.
class SearchClient {
final YoutubeHttpClient _httpClient;
/// Initializes an instance of [SearchClient]
SearchClient(this._httpClient);
/// Enumerates videos returned by the specified search query.
Stream<Video> getVideosAsync(String searchQuery) async* {
var encounteredVideoIds = <String>{};
for (var page = 0; page < double.maxFinite; page++) {
var response =
await PlaylistResponse.searchResults(_httpClient, searchQuery);
var countDelta = 0;
for (var video in response.videos) {
var videoId = video.id;
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;
}
}
}
}

View File

@ -0,0 +1,35 @@
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;
/// Time at which this caption ends being displayed.
Duration get end => offset + 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,46 @@
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(dynamic videoId) async {
videoId = VideoId.fromString(videoId);
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),
isAutoGenerated: track.autoGenerated));
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);
}
}

View File

@ -0,0 +1,25 @@
import 'dart:collection';
import '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,18 @@
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);
/// Gets the caption displayed at the specified point in time.
/// Returns null if not found.
ClosedCaption getByTime(Duration time) => captions
.firstWhere((e) => time >= e.offset && time <= e.end, orElse: () => null);
}

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

@ -0,0 +1,29 @@
import 'streams.dart';
/// YouTube media stream that only contains audio.
class AudioOnlyStreamInfo implements AudioStreamInfo {
@override
final int tag;
@override
final Uri url;
@override
final Container container;
@override
final FileSize size;
@override
final Bitrate bitrate;
@override
final String audioCodec;
/// Initializes an instance of [AudioOnlyStreamInfo]
AudioOnlyStreamInfo(this.tag, this.url, this.container, this.size,
this.bitrate, this.audioCodec);
@override
String toString() => 'Audio-only ($tag | $container)';
}

View File

@ -0,0 +1,12 @@
import 'streams.dart';
/// YouTube media stream that contains audio.
abstract class AudioStreamInfo extends StreamInfo {
/// Audio codec.
final String audioCodec;
///
AudioStreamInfo(int tag, Uri url, Container container, FileSize size,
Bitrate bitrate, this.audioCodec)
: super(tag, url, container, size, bitrate);
}

View File

@ -0,0 +1,54 @@
import 'package:equatable/equatable.dart';
/// Encapsulates bitrate.
class Bitrate extends Comparable<Bitrate> with EquatableMixin {
/// Bits per second.
final int bitsPerSecond;
/// Kilobits per second.
double get kiloBitsPerSecond => bitsPerSecond / 1024;
/// Megabits per second.
double get megaBitsPerSecond => kiloBitsPerSecond / 1024;
/// Gigabits per second.
double get gigaBitsPerSecond => megaBitsPerSecond / 1024;
/// Initializes an instance of [Bitrate]
Bitrate(this.bitsPerSecond);
@override
int compareTo(Bitrate other) => bitsPerSecond.compareTo(other.bitsPerSecond);
@override
List<Object> get props => [bitsPerSecond];
String _getLargestSymbol() {
if (gigaBitsPerSecond.abs() >= 1) {
return 'Gbit/s';
}
if (megaBitsPerSecond.abs() >= 1) {
return 'Mbit/s';
}
if (kiloBitsPerSecond.abs() >= 1) {
return 'Kbit/s';
}
return 'Bit/s';
}
num _getLargestValue() {
if (gigaBitsPerSecond.abs() >= 1) {
return gigaBitsPerSecond;
}
if (megaBitsPerSecond.abs() >= 1) {
return megaBitsPerSecond;
}
if (kiloBitsPerSecond.abs() >= 1) {
return kiloBitsPerSecond;
}
return bitsPerSecond;
}
@override
String toString() => '${_getLargestValue()} ${_getLargestSymbol()}';
}

View File

@ -0,0 +1,38 @@
import 'package:equatable/equatable.dart';
/// Stream container.
class Container with EquatableMixin {
/// Container name.
/// Can be used as file extension
final String name;
/// Initializes an instance of [Container]
Container._(this.name);
/// MPEG-4 Part 14 (.mp4).
static final Container mp4 = Container._('mp4');
/// Web Media (.webm).
static final Container webM = Container._('webm');
/// 3rd Generation Partnership Project (.3gpp).
static final Container tgpp = Container._('3gpp');
/// Parse a container from name.
static Container parse(String name) {
if (name.toLowerCase() == 'mp4') {
return Container.mp4;
}
if (name.toLowerCase() == 'webm') {
return Container.webM;
}
if (name.toLowerCase() == '3gpp') {
return Container.tgpp;
}
throw ArgumentError.value(name, 'name', 'Valid values: mp4, webm, 3gpp');
}
@override
List<Object> get props => [name];
}

View File

@ -0,0 +1,54 @@
import 'package:equatable/equatable.dart';
/// Encapsulates file size.
class FileSize extends Comparable<FileSize> with EquatableMixin {
/// Total bytes.
final int totalBytes;
/// Total kilobytes.
double get totalKiloBytes => totalBytes / 1024;
/// Total megabytes.
double get totalMegaBytes => totalKiloBytes / 1024;
/// Total gigabytes.
double get totalGigaBytes => totalMegaBytes / 1024;
/// Initializes an instance of [FileSize]
FileSize(this.totalBytes);
@override
int compareTo(FileSize other) => totalBytes.compareTo(other.totalBytes);
String _getLargestSymbol() {
if (totalGigaBytes.abs() >= 1) {
return 'GB';
}
if (totalMegaBytes.abs() >= 1) {
return 'MB';
}
if (totalKiloBytes.abs() >= 1) {
return 'KB';
}
return 'B';
}
num _getLargestValue() {
if (totalGigaBytes.abs() >= 1) {
return totalGigaBytes;
}
if (totalMegaBytes.abs() >= 1) {
return totalMegaBytes;
}
if (totalKiloBytes.abs() >= 1) {
return totalKiloBytes;
}
return totalBytes;
}
@override
String toString() => '${_getLargestValue()} ${_getLargestSymbol()}';
@override
List<Object> get props => [totalBytes];
}

View File

@ -0,0 +1,26 @@
import 'package:equatable/equatable.dart';
/// Encapsulates framerate.
class Framerate extends Comparable<Framerate> with EquatableMixin {
/// Framerate as frames per second
final num framesPerSecond;
/// Initialize an instance of [Framerate]
Framerate(this.framesPerSecond);
///
bool operator >(Framerate other) => framesPerSecond > other.framesPerSecond;
///
bool operator <(Framerate other) => framesPerSecond < other.framesPerSecond;
@override
String toString() => '$framesPerSecond FPS';
@override
List<Object> get props => [framesPerSecond];
@override
int compareTo(Framerate other) =>
framesPerSecond.compareTo(other.framesPerSecond);
}

View File

@ -0,0 +1,65 @@
import 'audio_stream_info.dart';
import 'bitrate.dart';
import 'container.dart';
import 'filesize.dart';
import 'framerate.dart';
import 'video_quality.dart';
import 'video_resolution.dart';
import 'video_stream_info.dart';
/// YouTube media stream that contains both audio and video.
class MuxedStreamInfo implements AudioStreamInfo, VideoStreamInfo {
@override
final int tag;
@override
final Uri url;
@override
final Container container;
@override
final FileSize size;
@override
final Bitrate bitrate;
@override
final String audioCodec;
@override
final String videoCodec;
/// Video quality label, as seen on YouTube.
@override
final String videoQualityLabel;
/// Video quality.
@override
final VideoQuality videoQuality;
/// Video resolution.
@override
final VideoResolution videoResolution;
/// Video framerate.
@override
final Framerate framerate;
/// Initializes an instance of [MuxedStreamInfo]
MuxedStreamInfo(
this.tag,
this.url,
this.container,
this.size,
this.bitrate,
this.audioCodec,
this.videoCodec,
this.videoQualityLabel,
this.videoQuality,
this.videoResolution,
this.framerate);
@override
String toString() => 'Muxed ($tag | $videoQualityLabel | $container';
}

View File

@ -0,0 +1,14 @@
import '../../reverse_engineering/cipher/cipher_operations.dart';
import '../../reverse_engineering/responses/responses.dart';
///
class StreamContext {
///
final Iterable<StreamInfoProvider> streamInfoProviders;
///
final Iterable<CipherOperation> cipherOperations;
///
StreamContext(this.streamInfoProviders, this.cipherOperations);
}

View File

@ -0,0 +1,37 @@
import 'bitrate.dart';
import 'container.dart';
import 'filesize.dart';
/// Generic YouTube media stream.
abstract class StreamInfo {
/// Stream tag.
/// Uniquely identifies a stream inside a manifest.
final int tag;
/// Stream URL.
final Uri url;
/// Stream container.
final Container container;
/// Stream size.
final FileSize size;
/// Stream bitrate.
final Bitrate bitrate;
/// Initialize an instance of [StreamInfo].
StreamInfo(this.tag, this.url, this.container, this.size, this.bitrate);
}
/// Extensions for [StreamInfo]
extension StreamInfoExt on StreamInfo {
static final _exp = RegExp('ratebypass[=/]yes');
/// Returns true if this video is rate limited.
bool isRateLimited() => _exp.hasMatch(url.toString());
/// Gets the stream with highest bitrate.
static StreamInfo getHighestBitrate(List<StreamInfo> streams) =>
(streams..sort((a, b) => a.bitrate.compareTo(b.bitrate))).last;
}

View File

@ -0,0 +1,37 @@
import 'dart:collection';
import 'audio_stream_info.dart';
import 'stream_info.dart';
import 'streams.dart';
/// Manifest that contains information about available media streams
/// in a specific video.
class StreamManifest {
/// Available streams.
final UnmodifiableListView<StreamInfo> streams;
/// Initializes an instance of [StreamManifest]
StreamManifest(Iterable<StreamInfo> streams)
: streams = UnmodifiableListView(streams);
/// Gets streams that contain audio
/// (which includes muxed and audio-only streams).
Iterable<AudioStreamInfo> get audio => streams.whereType<AudioStreamInfo>();
/// Gets streams that contain video
/// (which includes muxed and video-only streams).
Iterable<VideoStreamInfo> get video => streams.whereType<VideoStreamInfo>();
/// Gets muxed streams (contain both audio and video).
/// Note that muxed streams are limited in quality and don't go beyond 720p30.
Iterable<MuxedStreamInfo> get muxed => streams.whereType<MuxedStreamInfo>();
/// Gets audio-only streams (no video).
Iterable<AudioOnlyStreamInfo> get audioOnly =>
streams.whereType<AudioOnlyStreamInfo>();
/// Gets video-only streams (no audio).
/// These streams have the widest range of qualities available.
Iterable<VideoOnlyStreamInfo> get videoOnly =>
streams.whereType<VideoOnlyStreamInfo>();
}

View File

@ -0,0 +1,15 @@
export 'audio_only_stream_info.dart';
export 'audio_stream_info.dart';
export 'bitrate.dart';
export 'container.dart';
export 'filesize.dart';
export 'framerate.dart';
export 'muxed_stream_info.dart';
export 'stream_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';

View File

@ -0,0 +1,251 @@
import '../../exceptions/exceptions.dart';
import '../../extensions/helpers_extension.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';
import 'filesize.dart';
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 StreamsClient {
final YoutubeHttpClient _httpClient;
/// Initializes an instance of [StreamsClient]
StreamsClient(this._httpClient);
Future<DashManifest> _getDashManifest(
Uri dashManifestUrl, Iterable<CipherOperation> cipherOperations) {
var signature =
DashManifest.getSignatureFromUrl(dashManifestUrl.toString());
if (!signature.isNullOrWhiteSpace) {
signature = cipherOperations.decipher(signature);
dashManifestUrl = dashManifestUrl.setQueryParam('signature', signature);
}
return DashManifest.get(_httpClient, dashManifestUrl);
}
Future<StreamContext> _getStreamContextFromVideoInfo(VideoId videoId) async {
var embedPage = await EmbedPage.get(_httpClient, videoId.toString());
var playerConfig = embedPage.playerconfig;
if (playerConfig == null) {
throw VideoUnplayableException.unplayable(videoId);
}
var playerSource =
await PlayerSource.get(_httpClient, playerConfig.sourceUrl);
var cipherOperations = playerSource.getCiperOperations();
var videoInfoReponse = await VideoInfoResponse.get(
_httpClient, videoId.toString(), playerSource.sts);
var playerResponse = videoInfoReponse.playerResponse;
var previewVideoId = playerResponse.previewVideoId;
if (!previewVideoId.isNullOrWhiteSpace) {
throw VideoRequiresPurchaseException.preview(
videoId, VideoId(previewVideoId));
}
if (!playerResponse.isVideoPlayable) {
throw VideoUnplayableException.unplayable(videoId,
reason: playerResponse.getVideoPlayabilityError());
}
if (playerResponse.isLive) {
throw VideoUnplayableException.liveStream(videoId);
}
var streamInfoProviders = <StreamInfoProvider>[
...videoInfoReponse.streams,
...playerResponse.streams
];
var dashManifestUrl = playerResponse.dashManifestUrl;
if (!dashManifestUrl.isNullOrWhiteSpace) {
var dashManifest =
await _getDashManifest(Uri.parse(dashManifestUrl), cipherOperations);
streamInfoProviders.addAll(dashManifest.streams);
}
return StreamContext(streamInfoProviders, cipherOperations);
}
Future<StreamContext> _getStreamContextFromWatchPage(VideoId videoId) async {
var watchPage = await WatchPage.get(_httpClient, videoId.toString());
var playerConfig = watchPage.playerConfig;
if (playerConfig == null) {
throw VideoUnplayableException.unplayable(videoId);
}
var playerResponse = playerConfig.playerResponse;
var previewVideoId = playerResponse.previewVideoId;
if (!previewVideoId.isNullOrWhiteSpace) {
throw VideoRequiresPurchaseException.preview(
videoId, VideoId(previewVideoId));
}
var playerSource =
await PlayerSource.get(_httpClient, playerConfig.sourceUrl);
var cipherOperations = playerSource.getCiperOperations();
if (!playerResponse.isVideoPlayable) {
throw VideoUnplayableException.unplayable(videoId,
reason: playerResponse.getVideoPlayabilityError());
}
if (playerResponse.isLive) {
throw VideoUnplayableException.liveStream(videoId);
}
var streamInfoProviders = <StreamInfoProvider>[
...playerConfig.streams,
...playerResponse.streams
];
var dashManifestUrl = playerResponse.dashManifestUrl;
if (!dashManifestUrl.isNullOrWhiteSpace) {
var dashManifest =
await _getDashManifest(Uri.parse(dashManifestUrl), cipherOperations);
streamInfoProviders.addAll(dashManifest.streams);
}
return StreamContext(streamInfoProviders, cipherOperations);
}
Future<StreamManifest> _getManifest(StreamContext streamContext) async {
// To make sure there are no duplicates streams, group them by tag
var streams = <int, StreamInfo>{};
for (var streamInfo in streamContext.streamInfoProviders) {
var tag = streamInfo.tag;
var url = Uri.parse(streamInfo.url);
// Signature
var signature = streamInfo.signature;
var signatureParameter = streamInfo.signatureParameter ?? "signature";
if (!signature.isNullOrWhiteSpace) {
signature = streamContext.cipherOperations.decipher(signature);
url = url.setQueryParam(signatureParameter, signature);
}
// Content length
var contentLength = streamInfo.contentLength ??
await _httpClient.getContentLength(url, validate: false) ??
0;
if (contentLength <= 0) {
continue;
}
// Common
var container = Container.parse(streamInfo.container);
var fileSize = FileSize(contentLength);
var bitrate = Bitrate(streamInfo.bitrate);
var audioCodec = streamInfo.audioCodec;
var videoCodec = streamInfo.videoCodec;
// Muxed or Video-only
if (!videoCodec.isNullOrWhiteSpace) {
var framerate = Framerate(streamInfo.framerate ?? 24);
var videoQualityLabel = streamInfo.videoQualityLabel ??
VideoQualityUtil.getLabelFromTagWithFramerate(
tag, framerate.framesPerSecond);
var videoQuality = VideoQualityUtil.fromLabel(videoQualityLabel);
var videoWidth = streamInfo.videoWidth;
var videoHeight = streamInfo.videoHeight;
var videoResolution = videoWidth != null && videoHeight != null
? VideoResolution(videoWidth, videoHeight)
: videoQuality.toVideoResolution();
// Muxed
if (!audioCodec.isNullOrWhiteSpace) {
streams[tag] = MuxedStreamInfo(
tag,
url,
container,
fileSize,
bitrate,
audioCodec,
videoCodec,
videoQualityLabel,
videoQuality,
videoResolution,
framerate);
continue;
}
// Video only
streams[tag] = VideoOnlyStreamInfo(
tag,
url,
container,
fileSize,
bitrate,
videoCodec,
videoQualityLabel,
videoQuality,
videoResolution,
framerate);
continue;
}
// Audio-only
if (!audioCodec.isNullOrWhiteSpace) {
streams[tag] = AudioOnlyStreamInfo(
tag, url, container, fileSize, bitrate, audioCodec);
}
// #if DEBUG
// throw FatalFailureException("Stream info doesn't contain audio/video codec information.");
}
return StreamManifest(streams.values);
}
/// Gets the manifest that contains information
/// about available streams in the specified video.
Future<StreamManifest> getManifest(dynamic videoId) async {
videoId = VideoId.fromString(videoId);
// We can try to extract the manifest from two sources:
// get_video_info and the video watch page.
// In some cases one works, in some cases another does.
try {
var context = await _getStreamContextFromVideoInfo(videoId);
return _getManifest(context);
} on YoutubeExplodeException {
var context = await _getStreamContextFromWatchPage(videoId);
return _getManifest(context);
}
}
/// Gets the HTTP Live Stream (HLS) manifest URL
/// for the specified video (if it's a live video stream).
Future<String> getHttpLiveStreamUrl(VideoId videoId) async {
var videoInfoResponse =
await VideoInfoResponse.get(_httpClient, videoId.toString());
var playerResponse = videoInfoResponse.playerResponse;
if (!playerResponse.isVideoPlayable) {
throw VideoUnplayableException.unplayable(videoId,
reason: playerResponse.getVideoPlayabilityError());
}
var hlsManifest = playerResponse.hlsManifestUrl;
if (hlsManifest == null) {
throw VideoUnplayableException.notLiveStream(videoId);
}
return hlsManifest;
}
/// Gets the actual stream which is identified by the specified metadata.
Stream<List<int>> get(StreamInfo streamInfo) =>
_httpClient.getStream(streamInfo);
}

View File

@ -0,0 +1,56 @@
import 'bitrate.dart';
import 'container.dart';
import 'filesize.dart';
import 'framerate.dart';
import 'video_quality.dart';
import 'video_resolution.dart';
import 'video_stream_info.dart';
/// YouTube media stream that only contains video.
class VideoOnlyStreamInfo implements VideoStreamInfo {
@override
final int tag;
@override
final Uri url;
@override
final Container container;
@override
final FileSize size;
@override
final Bitrate bitrate;
@override
final String videoCodec;
@override
final String videoQualityLabel;
@override
final VideoQuality videoQuality;
@override
final VideoResolution videoResolution;
@override
final Framerate framerate;
/// Initializes an instance of [VideoOnlyStreamInfo]
VideoOnlyStreamInfo(
this.tag,
this.url,
this.container,
this.size,
this.bitrate,
this.videoCodec,
this.videoQualityLabel,
this.videoQuality,
this.videoResolution,
this.framerate);
@override
String toString() => 'Video-only ($tag | $videoQualityLabel | $container';
}

View File

@ -1,7 +1,5 @@
import 'package:equatable/equatable.dart';
/// Width and height of a video.
class VideoResolution extends Equatable {
class VideoResolution {
/// Viewport width.
final int width;
@ -13,7 +11,4 @@ class VideoResolution extends Equatable {
@override
String toString() => '${width}x$height';
@override
List<Object> get props => [width, height];
}

View File

@ -0,0 +1,52 @@
import 'streams.dart';
/// YouTube media stream that contains video.
abstract class VideoStreamInfo extends StreamInfo {
/// Video codec.
final String videoCodec;
/// Video quality label, as seen on YouTube.
final String videoQualityLabel;
/// Video quality.
final VideoQuality videoQuality;
/// Video resolution.
final VideoResolution videoResolution;
/// Video framerate.
final Framerate framerate;
///
VideoStreamInfo(
int tag,
Uri url,
Container container,
FileSize size,
Bitrate bitrate,
this.videoCodec,
this.videoQualityLabel,
this.videoQuality,
this.videoResolution,
this.framerate)
: super(tag, url, container, size, bitrate);
}
/// Extensions for Iterables of [VideoStreamInfo]
extension VideoStreamInfoExtension on Iterable<VideoStreamInfo> {
/// Gets all video qualities available in a collection of video streams.
Set<VideoQuality> getAllVideoQualities() =>
map((e) => e.videoQuality).toSet();
/// Gets video quality labels of all streams available in
/// a collection of video streams.
/// This could be longer than [getAllVideoQualities] since this gives also all
/// the different fps.
Set<String> getAllVideoQualitiesLabel() =>
map((e) => e.videoQualityLabel).toSet();
/// Gets the video stream with highest video quality.
List<VideoStreamInfo> sortByVideoQuality() => toList()
..sort((a, b) => b.framerate.compareTo(a.framerate))
..sort((a, b) => b.videoQuality.index.compareTo(a.videoQuality.index));
}

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

@ -0,0 +1,53 @@
import 'dart:collection';
import '../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,42 @@
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(dynamic videoId) async {
videoId = VideoId.fromString(videoId);
var videoInfoResponse =
await VideoInfoResponse.get(_httpClient, videoId.value);
var playerResponse = videoInfoResponse.playerResponse;
var watchPage = await WatchPage.get(_httpClient, videoId.value);
return Video(
videoId,
playerResponse.videoTitle,
playerResponse.videoAuthor,
playerResponse.videoUploadDate,
playerResponse.videoDescription,
playerResponse.videoDuration,
ThumbnailSet(videoId.value),
playerResponse.videoKeywords,
Engagement(playerResponse.videoViewCount ?? 0, watchPage.videoLikeCount,
watchPage.videoDislikeCount));
}
}

View File

@ -0,0 +1,80 @@
import 'package:equatable/equatable.dart';
import '../extensions/helpers_extension.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 idOrUrl) : value = parseVideoId(idOrUrl) {
if (value == null) {
throw ArgumentError.value(
idOrUrl, 'urlOrUrl', 'Invalid YouTube video ID or URL');
}
}
@override
String toString() => value;
@override
List<Object> get props => [value];
/// Returns true if the given [videoId] is valid.
static bool validateVideoId(String videoId) {
if (videoId.isNullOrWhiteSpace) {
return false;
}
if (videoId.length != 11) {
return false;
}
return !RegExp(r'[^0-9a-zA-Z_\-]').hasMatch(videoId);
}
/// Parses a video id from url or if given a valid id as url returns itself.
/// Returns null if the id couldn't be extracted.
static String parseVideoId(String url) {
if (url.isNullOrWhiteSpace) {
return null;
}
if (validateVideoId(url)) {
return url;
}
// https://www.youtube.com/watch?v=yIVRs6YSbOM
var regMatch = _regMatchExp.firstMatch(url)?.group(1);
if (!regMatch.isNullOrWhiteSpace && validateVideoId(regMatch)) {
return regMatch;
}
// https://youtu.be/yIVRs6YSbOM
var shortMatch = _shortMatchExp.firstMatch(url)?.group(1);
if (!shortMatch.isNullOrWhiteSpace && validateVideoId(shortMatch)) {
return shortMatch;
}
// https://www.youtube.com/embed/yIVRs6YSbOM
var embedMatch = _embedMatchExp.firstMatch(url)?.group(1);
if (!embedMatch.isNullOrWhiteSpace && validateVideoId(embedMatch)) {
return embedMatch;
}
return null;
}
/// Converts [obj] to a [VideoId] by calling .toString on that object.
/// If it is already a [VideoId], [obj] is returned
factory VideoId.fromString(dynamic obj) {
if (obj is VideoId) {
return obj;
}
return VideoId(obj.toString());
}
}

View File

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

View File

@ -1,541 +1,41 @@
import 'dart:convert';
library youtube_explode.base;
import 'package:html/dom.dart';
import 'package:html/parser.dart' as html;
import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart' show MediaType;
import 'channels/channels.dart';
import 'playlists/playlist_client.dart';
import 'reverse_engineering/youtube_http_client.dart';
import 'search/search_client.dart';
import 'videos/video_client.dart';
import 'cipher/cipher.dart';
import 'exceptions/exceptions.dart';
import 'extensions/extensions.dart';
import 'models/models.dart';
import 'parser.dart' as parser;
/// YoutubeExplode entry class.
/// Library entry point.
class YoutubeExplode {
static final _regMatchExp = RegExp(r'youtube\..+?/watch.*?v=(.*?)(?:&|/|$)');
static final _shortMatchExp = RegExp(r'youtu\.be/(.*?)(?:\?|&|/|$)');
static final _embedMatchExp = RegExp(r'youtube\..+?/embed/(.*?)(?:\?|&|/|$)');
static final _playerConfigExp = RegExp(
r"yt\.setConfig\({'PLAYER_CONFIG':(.*)}\);",
multiLine: true,
caseSensitive: false);
static final _contentLenExp = RegExp(r'clen=(\d+)');
final YoutubeHttpClient _httpClient;
/// HTTP Client.
// Visible only for extensions.
http.Client client;
/// Queries related to YouTube videos.
VideoClient get videos => _videos;
/// Initialize [YoutubeExplode] class and http client.
YoutubeExplode() : client = http.Client();
/// Queries related to YouTube playlists.
PlaylistClient get playlists => _playlists;
/// Returns a [Future] that completes with a [MediaStreamInfoSet]
/// Use this to extract the muxed, audio and video streams from a video.
Future<MediaStreamInfoSet> getVideoMediaStream(String videoId) async {
if (!validateVideoId(videoId)) {
throw ArgumentError.value(videoId, 'videoId', 'Invalid video id');
}
/// Queries related to YouTube channels.
ChannelClient get channels => _channels;
var playerConfiguration = await getPlayerConfiguration(videoId);
/// YouTube search queries.
SearchClient get search => _search;
var muxedStreamInfoMap = <int, MuxedStreamInfo>{};
var audioStreamInfoMap = <int, AudioStreamInfo>{};
var videoStreamInfoMap = <int, VideoStreamInfo>{};
var muxedStreamInfoDics =
playerConfiguration.muxedStreamInfosUrlEncoded?.split(',');
if (muxedStreamInfoDics != null) {
// TODO: Implement muxedStreamInfoDics
throw UnsupportedError(
'muxedStreamInfoDics not null not implemented yet.');
}
if (playerConfiguration.muxedStreamInfoJson != null) {
for (var streamInfoJson in playerConfiguration.muxedStreamInfoJson) {
var itag = streamInfoJson['itag'] as int;
var urlString = streamInfoJson['url'] as String;
Uri url;
if (urlString.isNullOrWhiteSpace &&
!playerConfiguration.playerSourceUrl.isNullOrWhiteSpace) {
var cipher = streamInfoJson['signatureCipher'] as String;
url = await decipherUrl(
playerConfiguration.playerSourceUrl, cipher, client);
}
url ??= Uri.parse(urlString);
var contentLength = await _parseContentLength(
streamInfoJson['contentLength'],
url?.toString(),
);
// Extract container
var mimeType = MediaType.parse(streamInfoJson['mimeType'] as String);
var container = parser.stringToContainer(mimeType.subtype);
var codecs = mimeType.parameters['codecs'].split(',');
// Extract audio encoding
var audioEncoding = parser.audioEncodingFromString(codecs.last);
// Extract video encoding
var videoEncoding = parser.videoEncodingFromString(codecs.first);
// Extract video quality from itag.
var videoQuality = parser.videoQualityFromITag(itag);
// Get video quality label
var videoQualityLabel = parser.videoQualityToLabel(videoQuality);
// Get video resolution
var resolution = parser.videoQualityToResolution(videoQuality);
assert(url != null);
assert(contentLength != null && contentLength != -1);
muxedStreamInfoMap[itag] = MuxedStreamInfo(
itag,
url,
container,
contentLength,
audioEncoding,
videoEncoding,
videoQualityLabel,
videoQuality,
resolution);
}
}
var adaptiveStreamInfoDics =
playerConfiguration.adaptiveStreamInfosUrlEncoded?.split(',');
if (adaptiveStreamInfoDics != null) {
// TODO: Implement adaptiveStreamInfoDics
throw UnsupportedError(
'adaptiveStreamInfoDics not null not implemented yet.');
}
if (playerConfiguration.adaptiveStreamInfosJson != null) {
for (var streamInfoJson in playerConfiguration.adaptiveStreamInfosJson) {
var itag = streamInfoJson['itag'] as int;
var urlString = streamInfoJson['url'] as String;
var bitrate = streamInfoJson['bitrate'] as int;
Uri url;
if (urlString.isNullOrWhiteSpace &&
!playerConfiguration.playerSourceUrl.isNullOrWhiteSpace) {
var cipher = streamInfoJson['signatureCipher'] as String;
url = await decipherUrl(
playerConfiguration.playerSourceUrl, cipher, client);
}
url ??= Uri.parse(urlString);
var contentLength = await _parseContentLength(
streamInfoJson['contentLength'],
url?.toString(),
);
// Extract container
var mimeType = MediaType.parse(streamInfoJson['mimeType'] as String);
var container = parser.stringToContainer(mimeType.subtype);
var codecs = mimeType.parameters['codecs'].toLowerCase();
// Audio only
if (streamInfoJson['audioSampleRate'] != null) {
var audioEncoding = parser.audioEncodingFromString(codecs);
audioStreamInfoMap[itag] = AudioStreamInfo(
itag, url, container, contentLength, bitrate, audioEncoding);
} else {
// Video only
var videoEncoding = codecs == 'unknown'
? VideoEncoding.av1
: parser.videoEncodingFromString(codecs);
var videoQualityLabel = streamInfoJson['qualityLabel'] as String;
var videoQuality = parser.videoQualityFromLabel(videoQualityLabel);
var width = streamInfoJson['width'] as int;
var height = streamInfoJson['height'] as int;
var resolution = VideoResolution(width, height);
var framerate = streamInfoJson['fps'];
videoStreamInfoMap[itag] = VideoStreamInfo(
itag,
url,
container,
contentLength,
bitrate,
videoEncoding,
videoQualityLabel,
videoQuality,
resolution,
framerate);
}
}
}
var sortedMuxed = muxedStreamInfoMap.values.toList()
..sort((a, b) => a.videoQuality.index.compareTo(b.videoQuality.index));
var sortedAudio = audioStreamInfoMap.values.toList()
..sort((a, b) => a.bitrate.compareTo(b.bitrate));
var sortedVideo = videoStreamInfoMap.values.toList()
..sort((a, b) => a.videoQuality.index.compareTo(b.videoQuality.index));
return MediaStreamInfoSet(
sortedMuxed,
sortedAudio,
sortedVideo,
playerConfiguration.hlsManifestUrl,
playerConfiguration.video,
playerConfiguration.validUntil);
/// Initializes an instance of [YoutubeClient].
YoutubeExplode() : _httpClient = YoutubeHttpClient() {
_videos = VideoClient(_httpClient);
_playlists = PlaylistClient(_httpClient);
_channels = ChannelClient(_httpClient);
_search = SearchClient(_httpClient);
}
/// Returns the player configuration for a given video.
Future<PlayerConfiguration> getPlayerConfiguration(String videoId) async {
var playerConfiguration = await _getPlayerConfigEmbed(videoId);
VideoClient _videos;
PlaylistClient _playlists;
ChannelClient _channels;
SearchClient _search;
// If still null try from the watch page.
playerConfiguration ??= await _getPlayerConfigWatchPage(videoId);
if (playerConfiguration == null) {
throw VideoUnavailableException(videoId);
}
return playerConfiguration;
}
Future<PlayerConfiguration> _getPlayerConfigEmbed(String videoId) async {
var req = await client.get('https://www.youtube.com/embed/$videoId?&hl=en');
if (req.statusCode != 200) {
return null;
}
var body = req.body;
var document = html.parse(body);
var playerConfigRaw = document
.getElementsByTagName('script')
.map((e) => e.innerHtml)
.map((e) => _playerConfigExp?.firstMatch(e)?.group(1))
.firstWhere((s) => s?.trim()?.isNotEmpty ?? false);
var playerConfigJson = json.decode(playerConfigRaw);
// Extract player source URL.
var playerSourceUrl =
'https://youtube.com${playerConfigJson['assets']['js']}';
// Get video info dictionary.
var videoInfoDic = await getVideoInfoDictionary(videoId);
var playerResponseJson = json.decode(videoInfoDic['player_response']);
var playAbility = playerResponseJson['playabilityStatus'];
if (playAbility['status'].toString().toLowerCase() == 'error') {
throw VideoUnavailableException(videoId);
}
var errorReason = playAbility['reason'] as String;
// Valid configuration
if (errorReason.isNullOrWhiteSpace) {
var videoInfo = playerResponseJson['videoDetails'];
var video = Video(
videoId,
videoInfo['author'],
null,
videoInfo['title'],
videoInfo['shortDescription'],
ThumbnailSet(videoId),
Duration(seconds: int.parse(videoInfo['lengthSeconds'])),
videoInfo['keywords']?.cast<String>() ?? const <String>[],
Statistics(int.parse(videoInfo['viewCount']), 0, 0));
// Extract if it is a live stream.
var isLiveStream = playerResponseJson['videoDetails']['isLive'] == true;
var streamingData = playerResponseJson['streamingData'];
var validUntil = DateTime.now()
.add(Duration(seconds: int.parse(streamingData['expiresInSeconds'])));
var hlsManifestUrl =
isLiveStream ? streamingData['hlsManifestUrl'] : null;
var dashManifestUrl =
isLiveStream ? null : streamingData['dashManifestUrl'];
var muxedStreamInfosUrlEncoded =
isLiveStream ? null : videoInfoDic['url_encoded_fmt_stream_map'];
var adaptiveStreamInfosUrlEncoded =
isLiveStream ? null : videoInfoDic['adaptive_fmts'];
var muxedStreamInfosJson = isLiveStream ? null : streamingData['formats'];
var adaptiveStreamInfosJson =
isLiveStream ? null : streamingData['adaptiveFormats'];
return PlayerConfiguration(
playerSourceUrl,
dashManifestUrl,
hlsManifestUrl,
muxedStreamInfosUrlEncoded,
adaptiveStreamInfosUrlEncoded,
muxedStreamInfosJson,
adaptiveStreamInfosJson,
video,
validUntil);
}
var previewVideoId = playAbility['errorScreen']
['playerLegacyDesktopYpcTrailerRenderer']['trailerVideoId'] as String;
if (!previewVideoId.isNullOrWhiteSpace) {
throw VideoRequiresPurchaseException(videoId, previewVideoId);
}
// If the video requires purchase - throw (approach two)
var previewVideoInfoRaw = playAbility['errorScreen']['ypcTrailerRenderer']
['playerVars'] as String;
if (!previewVideoInfoRaw.isNullOrWhiteSpace) {
var previewVideoInfoDic = Uri.splitQueryString(previewVideoInfoRaw);
var previewVideoId = previewVideoInfoDic['video_id'];
throw VideoRequiresPurchaseException(videoId, previewVideoId);
}
return null;
}
Future<PlayerConfiguration> _getPlayerConfigWatchPage(String videoId) async {
var videoWatchPage = await getVideoWatchPage(videoId);
if (videoWatchPage == null) {
return null;
}
var playerConfigScript = videoWatchPage
.querySelectorAll('script')
.map((e) => e.text)
.firstWhere((e) => e.contains('ytplayer.config ='));
if (playerConfigScript == null) {
var errorReason =
videoWatchPage.querySelector('#unavailable-message').text.trim();
throw VideoUnplayableException(videoId, errorReason);
}
// Workaround: Couldn't get RegExp to work. TODO: Find working regexp
var startIndex = playerConfigScript.indexOf('ytplayer.config =');
var endIndex = playerConfigScript.indexOf(';ytplayer.load =');
var playerConfigRaw =
playerConfigScript.substring(startIndex + 17, endIndex);
var playerConfigJson = json.decode(playerConfigRaw);
var playerResponseJson =
json.decode(playerConfigJson['args']['player_response']);
var playerSourceUrl =
'https://youtube.com${playerConfigJson['assets']['js']}';
var videoInfo = playerResponseJson['videoDetails'];
var video = Video(
videoId,
videoInfo['author'],
null,
videoInfo['title'],
videoInfo['shortDescription'],
ThumbnailSet(videoId),
Duration(seconds: int.parse(videoInfo['lengthSeconds'])),
videoInfo['keywords']?.cast<String>() ?? const <String>[],
Statistics(int.parse(videoInfo['viewCount']), 0, 0));
var isLiveStream = playerResponseJson['videoDetails']['isLive'] == true;
var streamingData = playerResponseJson['streamingData'];
var validUntil = DateTime.now()
.add(Duration(seconds: int.parse(streamingData['expiresInSeconds'])));
var hlsManifestUrl = isLiveStream ? streamingData['hlsManifestUrl'] : null;
var dashManifestUrl =
isLiveStream ? null : streamingData['dashManifestUrl'];
var muxedStreamInfosUrlEncoded = isLiveStream
? null
: playerConfigJson['args']['url_encoded_fmt_stream_map'];
var adaptiveStreamInfosUrlEncoded =
isLiveStream ? null : playerConfigJson['args']['adaptive_fmts'];
var muxedStreamInfosJson = isLiveStream ? null : streamingData['formats'];
var adaptiveStreamInfosJson =
isLiveStream ? null : streamingData['adaptiveFormats'];
return PlayerConfiguration(
playerSourceUrl,
dashManifestUrl,
hlsManifestUrl,
muxedStreamInfosUrlEncoded,
adaptiveStreamInfosUrlEncoded,
muxedStreamInfosJson,
adaptiveStreamInfosJson,
video,
validUntil);
}
/// Returns the video info dictionary for a given video.
Future<Map<String, String>> getVideoInfoDictionary(String videoId) async {
var eurl = Uri.encodeComponent('https://youtube.googleapis.com/v/$videoId');
var url = 'https://youtube.com/get_video_info?video_id=$videoId'
'&el=embedded&eurl=$eurl&hl=en';
var raw = (await client.get(url)).body;
return Uri.splitQueryString(raw);
}
/// Return a [Video] instance.
/// Use this to extract general info about a video.
Future<Video> getVideo(String id) async {
var videoId = parseVideoId(id);
if (videoId == null) {
throw ArgumentError.value(id, 'videoId', 'Invalid video id');
}
var videoInfoDic = await getVideoInfoDictionary(videoId);
var playerResponseJson = json.decode(videoInfoDic['player_response']);
var status = playerResponseJson['playabilityStatus']['status']
?.toLowerCase() as String;
if (status.isNullOrWhiteSpace || status == 'error') {
throw VideoUnavailableException(videoId);
}
var details = playerResponseJson['videoDetails'];
var title = details['title'];
var author = details['author'];
var description = details['shortDescription'];
var duration = Duration(seconds: int.parse(details['lengthSeconds']));
var keyWords = details['keywords']?.cast<String>() ?? const <String>[];
var viewCount = int.tryParse(details['viewCount'] ?? '0') ?? 0;
var videoPageHtml = await getVideoWatchPage(videoId);
var uploadDate = DateTime.parse(videoPageHtml
.querySelector('meta[itemprop="datePublished"]')
.attributes['content']);
var rawLikeCount = videoPageHtml
.querySelector('.like-button-renderer-like-button')
?.text ??
'0';
var likeCount = rawLikeCount.isNullOrWhiteSpace
? 0
: int.parse(rawLikeCount.stripNonDigits);
var rawDislikeCount = videoPageHtml
.querySelector('.like-button-renderer-dislike-button')
?.text ??
'0';
var dislikeCount = rawDislikeCount.isNullOrWhiteSpace
? 0
: int.parse(rawLikeCount.stripNonDigits);
var statistics = Statistics(viewCount, likeCount, dislikeCount);
var thumbnails = ThumbnailSet(videoId);
return Video(videoId, author, uploadDate, title, description, thumbnails,
duration, keyWords, statistics);
}
Future<int> _parseContentLength(
String contentLengthString, String url) async {
var contentLength = int.tryParse(contentLengthString ?? '') ?? -1;
if (contentLength <= 0 && !url.isNullOrWhiteSpace) {
contentLength = _contentLenExp?.firstMatch(url)?.group(1) ?? -1;
}
if (contentLength <= 0 && !url.isNullOrWhiteSpace) {
contentLength = await _requestContentLength(url);
}
return contentLength;
}
Future<int> _requestContentLength(String url) async {
var resp;
try {
resp = await client.head(url);
} on Exception {
return -1;
}
if (!resp.headers.containsKey('content-length')) {
return -1;
}
String contentLengthString = resp.headers['content-length'];
return int.tryParse(contentLengthString ?? '') ?? -1;
}
/// Returns the video watch page document.
Future<Document> getVideoWatchPage(String videoId) async {
var url = 'https://youtube.com/watch?v=$videoId&bpctr=9999999999&hl=en';
var req = await client.get(url);
if (req.statusCode != 200) {
return null;
}
return html.parse(req.body);
}
/// Returns true if the given [videoId] is valid.
static bool validateVideoId(String videoId) {
if (videoId.isNullOrWhiteSpace) {
return false;
}
if (videoId.length != 11) {
return false;
}
return !RegExp(r'[^0-9a-zA-Z_\-]').hasMatch(videoId);
}
/// Parses a video id from url or if given a valid id as url returns itself.
/// Returns null if the id couldn't be extracted.
static String parseVideoId(String url) {
if (url.isNullOrWhiteSpace) {
return null;
}
if (validateVideoId(url)) {
return url;
}
// https://www.youtube.com/watch?v=yIVRs6YSbOM
var regMatch = _regMatchExp.firstMatch(url)?.group(1);
if (!regMatch.isNullOrWhiteSpace && validateVideoId(regMatch)) {
return regMatch;
}
// https://youtu.be/yIVRs6YSbOM
var shortMatch = _shortMatchExp.firstMatch(url)?.group(1);
if (!shortMatch.isNullOrWhiteSpace && validateVideoId(shortMatch)) {
return shortMatch;
}
// https://www.youtube.com/embed/yIVRs6YSbOM
var embedMatch = _embedMatchExp.firstMatch(url)?.group(1);
if (!embedMatch.isNullOrWhiteSpace && validateVideoId(embedMatch)) {
return embedMatch;
}
return null;
}
/// Closes the youtube explode's http client.
void close() {
client.close();
}
/* Export the extension static members. */
/// Parses a playlist [url] returning its id.
/// If the [url] is a valid it is returned itself.
static String parsePlaylistId(String url) =>
PlaylistExtension.parsePlaylistId(url);
/// Returns true if [username] is a valid Youtube username.
static bool validateUsername(String username) =>
ChannelExtension.validateUsername(username);
/// Parses a username from an url.
/// Returns null if the username is not found.
static String parseUsername(String url) =>
ChannelExtension.parseUsername(url);
/// Returns true if [channelId] is a valid Youtube channel id.
static bool validateChannelId(String channelId) =>
ChannelExtension.validateChannelId(channelId);
/// Parses a channel id from an url.
/// Returns null if the username is not found.
static String parseChannelId(String url) =>
ChannelExtension.parseChannelId(url);
/// 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,9 @@
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/search/search_client.dart';
export 'src/videos/videos.dart';
export 'src/youtube_explode_base.dart';

View File

@ -1,6 +1,6 @@
name: youtube_explode_dart
description: A port in dart of the youtube explode library. Supports several API functions.
version: 0.0.16
description: A port in dart of the youtube explode library. Supports several API functions without the need of Youtube API Key.
version: 1.0.0
homepage: https://github.com/Hexer10/youtube_explode_dart
environment:
@ -10,7 +10,7 @@ dependencies:
html: ^0.14.0+3
http: ^0.12.0+4
http_parser: ^3.1.3
xml: ^3.5.0
xml: '>=3.0.0 <5.0.0'
equatable: ^1.1.0
dev_dependencies:

35
test/channel_id_test.dart Normal file
View File

@ -0,0 +1,35 @@
import 'package:test/test.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
void main() {
group('ChannelId', () {
test('ValidChannelId', () {
var channel1 = ChannelId('UCEnBXANsKmyj2r9xVyKoDiQ');
var channel2 = ChannelId('UC46807r_RiRjH8IU-h_DrDQ');
expect(channel1.value, 'UCEnBXANsKmyj2r9xVyKoDiQ');
expect(channel2.value, 'UC46807r_RiRjH8IU-h_DrDQ');
});
test('ValidChannelUrl', () {
var channel1 = ChannelId('youtube.com/channel/UC3xnGqlcL3y-GXz5N3wiTJQ');
var channel2 = ChannelId('youtube.com/channel/UCkQO3QsgTpNTsOw6ujimT5Q');
var channel3 = ChannelId('youtube.com/channel/UCQtjJDOYluum87LA4sI6xcg');
expect(channel1.value, 'UC3xnGqlcL3y-GXz5N3wiTJQ');
expect(channel2.value, 'UCkQO3QsgTpNTsOw6ujimT5Q');
expect(channel3.value, 'UCQtjJDOYluum87LA4sI6xcg');
});
test('InvalidChannelId', () {
expect(() => ChannelId(''), throwsArgumentError);
expect(() => ChannelId('UC3xnGqlcL3y-GXz5N3wiTJ'), throwsArgumentError);
expect(() => ChannelId('UC3xnGqlcL y-GXz5N3wiTJQ'), throwsArgumentError);
});
test('InvalidChannelUrl', () {
expect(() => ChannelId('youtube.com/?channel=UCUC3xnGqlcL3y-GXz5N3wiTJQ'),
throwsArgumentError);
expect(() => ChannelId('youtube.com/channel/asd'), throwsArgumentError);
expect(() => ChannelId('youtube.com/'), throwsArgumentError);
});
});
}

Some files were not shown because too many files have changed in this diff Show More