Merge pull request #33 from Hexer10/v5
Update to v5 of YoutubeExplode for C#
This commit is contained in:
commit
d56eabc554
23
CHANGELOG.md
23
CHANGELOG.md
|
@ -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
|
## 0.0.1
|
||||||
|
|
||||||
- Initial version, created by Stagehand
|
- Initial version, created by Stagehand
|
||||||
|
@ -69,4 +85,9 @@
|
||||||
|
|
||||||
## 0.0.16
|
## 0.0.16
|
||||||
|
|
||||||
- When a video is not available(403) a `VideoStreamUnavailableException`
|
- When a video is not available(403) a `VideoStreamUnavailableException`
|
||||||
|
|
||||||
|
## 0.0.17
|
||||||
|
|
||||||
|
- Fixed bug in #23
|
||||||
|
|
||||||
|
|
52
README.md
52
README.md
|
@ -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
|
- Provides static methods to validate IDs and to parse IDs from URLs
|
||||||
- No need for an API key and no usage quotas
|
- No need for an API key and no usage quotas
|
||||||
- All model extend `Equatable` to easily perform equality checks
|
- All model extend `Equatable` to easily perform equality checks
|
||||||
- Download Stream
|
|
||||||
|
|
||||||
## Features not implemented
|
|
||||||
|
|
||||||
- Adaptive streams
|
|
||||||
|
|
||||||
## Differences from YoutubeExplode
|
## Differences from YoutubeExplode
|
||||||
|
|
||||||
- The entry point is `YoutubeExplode`, not `YoutubeClient`.
|
- 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
|
## Install
|
||||||
|
|
||||||
Add the dependency to the pubspec.yaml (Check for the latest version)
|
Add the dependency to the pubspec.yaml (Check for the latest version)
|
||||||
```yaml
|
```yaml
|
||||||
youtube_explode_dart: ^0.0.9
|
youtube_explode_dart: ^1.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Import the library
|
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.
|
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
|
```dart
|
||||||
var id = YoutubeExplode.parseVideoId('https://www.youtube.com/watch?v=OpQFFLBMEPI'); // Returns `OpQFFLBMEPI`
|
|
||||||
|
|
||||||
var yt = YoutubeExplode();
|
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.
|
The [Video][Video] class contains info about the video such as the video title, the duration or the search keywords.
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
var video = yt.getVideo(id); // Returns a Video instance.
|
var video = yt.video.get(id); // Returns a Video instance.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Get video mediaStream
|
## Get video streams
|
||||||
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]).
|
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
|
```dart
|
||||||
var mediaStreams = yt.getVideoMediaStream();
|
var manifest = yt.videos.streamsClient.getManifest(videoId);
|
||||||
|
|
||||||
var muxed = mediaStreams.muxed; // List of `MuxedStreamInfo` sorted by video quality.
|
var muxed = manifest.muxed; // List of `MuxedStreamInfo` sorted by video quality.
|
||||||
var audio = mediaStreams.audio; // List of `AudioStreamInfo` sorted by bitrate.
|
var audio = manifest.audio; // List of `AudioStreamInfo` sorted by bitrate.
|
||||||
var video = mediaStreams.video; // List of `VideoSteamInfo` sorted by video quality.
|
var video = manifest.video; // List of `VideoSteamInfo` sorted by video quality.
|
||||||
|
// There are available manifest.audioOnly and manifest.videoOnly as well.
|
||||||
var videoDetails = mediaStreams.videoDetails; //Returns a `Video` instance. Used to avoid calling `yt.getVideo`.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Be aware, the muxed streams don't hold the best quality, to achieve so, you'd need to merge the audio and video streams.
|
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.
|
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
|
```dart
|
||||||
var trackInfos = await yt.getVideoClosedCaptionTrackInfos(id); // Get the caption track infos
|
var trackInfos = await yt.videos.closedCaptions.getManifest(videoId); // Get the caption track infos
|
||||||
if (trackInfos.isEmpty) {
|
var trackInfo = manifest.getByLanguage(en); // Get english caption.
|
||||||
// No caption is available.
|
var track = await track.getByTime(duration); // Get the caption displayed at `duration`.
|
||||||
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.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cleanup
|
## Cleanup
|
||||||
|
@ -111,12 +85,10 @@ Available on [GitHub][Examples]
|
||||||
---
|
---
|
||||||
|
|
||||||
Check the [api doc][API] for additional information.
|
Check the [api doc][API] for additional information.
|
||||||
More features are provided through extensions.
|
|
||||||
|
|
||||||
[YoutubeExplode]: https://github.com/Tyrrrz/YoutubeExplode/
|
[YoutubeExplode]: https://github.com/Tyrrrz/YoutubeExplode/
|
||||||
|
|
||||||
[Video]: https://pub.dev/documentation/youtube_explode_dart/latest/youtube_explode/Video-class.html
|
[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
|
[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
|
[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]
|
[Examples]: [https://github.com/Hexer10/youtube_explode_dart/tree/master/example]
|
|
@ -13,7 +13,10 @@ linter:
|
||||||
- prefer_const_literals_to_create_immutables
|
- prefer_const_literals_to_create_immutables
|
||||||
- prefer_constructors_over_static_methods
|
- prefer_constructors_over_static_methods
|
||||||
- prefer_contains
|
- prefer_contains
|
||||||
|
- annotate_overrides
|
||||||
|
- await_only_futures
|
||||||
|
- unawaited_futures
|
||||||
|
|
||||||
analyzer:
|
analyzer:
|
||||||
# exclude:
|
exclude:
|
||||||
# - path/to/excluded/files/**
|
- example\**
|
||||||
|
|
|
@ -1,12 +1,9 @@
|
||||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
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 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}');
|
print('Title: ${video.title}');
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
//TODO: Fixing the console printing.
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
@ -14,19 +16,12 @@ Future<void> main() async {
|
||||||
|
|
||||||
var url = stdin.readLineSync().trim();
|
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.
|
// Save the video to the download directory.
|
||||||
Directory('downloads').createSync();
|
Directory('downloads').createSync();
|
||||||
console.hideCursor();
|
console.hideCursor();
|
||||||
|
|
||||||
// Download the video.
|
// Download the video.
|
||||||
await download(id);
|
await download(url);
|
||||||
|
|
||||||
yt.close();
|
yt.close();
|
||||||
console.showCursor();
|
console.showCursor();
|
||||||
|
@ -34,24 +29,28 @@ Future<void> main() async {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> download(String id) async {
|
Future<void> download(String id) async {
|
||||||
// Get the video media stream.
|
// Get video metadata.
|
||||||
var mediaStream = await yt.getVideoMediaStream(id);
|
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).
|
// 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.
|
// Compose the file name removing the unallowed characters in windows.
|
||||||
var fileName =
|
var fileName = '${video.title}.${audio.container.name.toString()}'
|
||||||
'${mediaStream.videoDetails.title}.${audio.container.toString()}'
|
.replaceAll('Container.', '')
|
||||||
.replaceAll('Container.', '')
|
.replaceAll(r'\', '')
|
||||||
.replaceAll(r'\', '')
|
.replaceAll('/', '')
|
||||||
.replaceAll('/', '')
|
.replaceAll('*', '')
|
||||||
.replaceAll('*', '')
|
.replaceAll('?', '')
|
||||||
.replaceAll('?', '')
|
.replaceAll('"', '')
|
||||||
.replaceAll('"', '')
|
.replaceAll('<', '')
|
||||||
.replaceAll('<', '')
|
.replaceAll('>', '')
|
||||||
.replaceAll('>', '')
|
.replaceAll('|', '');
|
||||||
.replaceAll('|', '');
|
|
||||||
var file = File('downloads/$fileName');
|
var file = File('downloads/$fileName');
|
||||||
|
|
||||||
// Create the StreamedRequest to track the download status.
|
// 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);
|
var output = file.openWrite(mode: FileMode.writeOnlyAppend);
|
||||||
|
|
||||||
// Track the file download status.
|
// Track the file download status.
|
||||||
var len = audio.size;
|
var len = audio.size.totalBytes;
|
||||||
var count = 0;
|
var count = 0;
|
||||||
var oldProgress = -1;
|
var oldProgress = -1;
|
||||||
|
|
||||||
// Create the message and set the cursor position.
|
// Create the message and set the cursor position.
|
||||||
var msg = 'Downloading `${mediaStream.videoDetails.title}`: \n';
|
var msg = 'Downloading `${video.title}`(.${audio.container.name}): \n';
|
||||||
var row = console.cursorPosition.row;
|
print(msg);
|
||||||
var col = msg.length - 2;
|
// var row = console.cursorPosition.row;
|
||||||
console.cursorPosition = Coordinate(row, 0);
|
// var col = msg.length - 2;
|
||||||
console.write(msg);
|
// console.cursorPosition = Coordinate(row, 0);
|
||||||
|
// console.write(msg);
|
||||||
|
|
||||||
// Listen for data received.
|
// Listen for data received.
|
||||||
await for (var data in audio.downloadStream()) {
|
await for (var data in audioStream) {
|
||||||
count += data.length;
|
count += data.length;
|
||||||
var progress = ((count / len) * 100).round();
|
var progress = ((count / len) * 100).round();
|
||||||
if (progress != oldProgress) {
|
if (progress != oldProgress) {
|
||||||
console.cursorPosition = Coordinate(row, col);
|
// console.cursorPosition = Coordinate(row, col);
|
||||||
console.write('$progress%');
|
print('$progress%');
|
||||||
oldProgress = progress;
|
oldProgress = progress;
|
||||||
}
|
}
|
||||||
output.add(data);
|
output.add(data);
|
||||||
|
|
|
@ -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)';
|
||||||
|
}
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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];
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
library youtube_explode.channels;
|
||||||
|
|
||||||
|
export 'channel.dart';
|
||||||
|
export 'channel_client.dart';
|
||||||
|
export 'channel_id.dart';
|
||||||
|
export 'username.dart';
|
|
@ -0,0 +1,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());
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
|
||||||
}
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
library youtube_explode.common;
|
||||||
|
|
||||||
|
export 'engagement.dart';
|
||||||
|
export 'thumbnail_set.dart';
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
/// User activity statistics.
|
/// User activity statistics.
|
||||||
class Statistics extends Equatable {
|
class Engagement extends Equatable {
|
||||||
/// View count.
|
/// View count.
|
||||||
final int viewCount;
|
final int viewCount;
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ class Statistics extends Equatable {
|
||||||
final int dislikeCount;
|
final int dislikeCount;
|
||||||
|
|
||||||
/// Initializes an instance of [Statistics]
|
/// 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).
|
/// Average user rating in stars (1 star to 5 stars).
|
||||||
num get avgRating {
|
num get avgRating {
|
||||||
|
@ -22,6 +22,10 @@ class Statistics extends Equatable {
|
||||||
return 1 + 4.0 * likeCount / (likeCount + dislikeCount);
|
return 1 + 4.0 * likeCount / (likeCount + dislikeCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'$viewCount views, $likeCount likes, $dislikeCount dislikes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [viewCount, likeCount, dislikeCount];
|
List<Object> get props => [viewCount, likeCount, dislikeCount];
|
||||||
}
|
}
|
|
@ -1,7 +1,9 @@
|
||||||
library youtube_explode.exceptions;
|
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_requires_purchase_exception.dart';
|
||||||
export 'video_stream_unavailable_exception.dart';
|
|
||||||
export 'video_unavailable_exception.dart';
|
export 'video_unavailable_exception.dart';
|
||||||
export 'video_unplayable_exception.dart';
|
export 'video_unplayable_exception.dart';
|
||||||
|
export 'youtube_explode_exception.dart';
|
||||||
|
|
|
@ -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';
|
||||||
|
}
|
|
@ -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';
|
||||||
|
}
|
|
@ -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';
|
||||||
|
}
|
|
@ -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');
|
|
||||||
}
|
|
|
@ -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 {
|
class VideoRequiresPurchaseException implements VideoUnplayableException {
|
||||||
/// ID of the video.
|
/// Description message
|
||||||
final String videoId;
|
|
||||||
|
|
||||||
/// ID of the preview video.
|
|
||||||
final String previewVideoId;
|
|
||||||
|
|
||||||
/// Initializes an instance of [VideoRequiresPurchaseException]
|
|
||||||
const VideoRequiresPurchaseException(this.videoId, this.previewVideoId);
|
|
||||||
|
|
||||||
@override
|
@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`.';
|
||||||
}
|
}
|
||||||
|
|
|
@ -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';
|
|
||||||
}
|
|
|
@ -1,14 +1,23 @@
|
||||||
|
import '../videos/video_id.dart';
|
||||||
|
import 'exceptions.dart';
|
||||||
|
|
||||||
/// Thrown when a video is not available and cannot be processed.
|
/// Thrown when a video is not available and cannot be processed.
|
||||||
/// This can happen because the video does not exist, is deleted,
|
/// This can happen because the video does not exist, is deleted,
|
||||||
/// is private, or due to other reasons.
|
/// is private, or due to other reasons.
|
||||||
class VideoUnavailableException implements Exception {
|
class VideoUnavailableException implements VideoUnplayableException {
|
||||||
/// ID of the video.
|
/// Description message
|
||||||
final String videoId;
|
@override
|
||||||
|
final String message;
|
||||||
|
|
||||||
/// Initializes an instance of [VideoUnavailableException]
|
/// Initializes an instance of [VideoUnavailableException]
|
||||||
const VideoUnavailableException(this.videoId);
|
VideoUnavailableException(this.message);
|
||||||
|
|
||||||
@override
|
/// Initializes an instance of [VideoUnplayableException] with a [VideoId]
|
||||||
String toString() =>
|
VideoUnavailableException.unavailable(VideoId videoId)
|
||||||
'VideoUnavailableException: Video $videoId is unavailable.';
|
: 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.';
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,34 @@
|
||||||
/// Thrown when a video is not playable and its streams cannot be resolved.
|
import '../videos/video_id.dart';
|
||||||
/// This can happen because the video requires purchase,
|
import 'youtube_explode_exception.dart';
|
||||||
/// is blocked in your country, is controversial, or due to other reasons.
|
|
||||||
class VideoUnplayableException {
|
|
||||||
/// ID of the video.
|
|
||||||
final String videoId;
|
|
||||||
|
|
||||||
/// Reason why the video can't be played.
|
/// Exception thrown when the requested video is unplayable.
|
||||||
final String reason;
|
class VideoUnplayableException implements YoutubeExplodeException {
|
||||||
|
/// Description message
|
||||||
|
@override
|
||||||
|
final String message;
|
||||||
|
|
||||||
/// Initializes an instance of [VideoUnplayableException]
|
/// Initializes an instance of [VideoUnplayableException]
|
||||||
const VideoUnplayableException(this.videoId, [this.reason]);
|
VideoUnplayableException(this.message);
|
||||||
|
|
||||||
String toString() =>
|
/// Initializes an instance of [VideoUnplayableException] with a [VideoId]
|
||||||
'VideoUnplayableException: Video $videoId couldn\'t be played.'
|
VideoUnplayableException.unplayable(VideoId videoId, {String reason = ''})
|
||||||
'${reason == null ? '' : 'Reason: $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';
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,159 +0,0 @@
|
||||||
import 'package:html/dom.dart';
|
|
||||||
import 'package:html/parser.dart' as html;
|
|
||||||
|
|
||||||
import '../models/models.dart';
|
|
||||||
import '../youtube_explode_base.dart';
|
|
||||||
import 'helpers_extension.dart';
|
|
||||||
import 'playlist_extension.dart';
|
|
||||||
|
|
||||||
/// Channel extension for [YoutubeExplode]
|
|
||||||
extension ChannelExtension on YoutubeExplode {
|
|
||||||
static final _usernameRegMatchExp =
|
|
||||||
RegExp(r'youtube\..+?/user/(.*?)(?:\?|&|/|$)');
|
|
||||||
|
|
||||||
static final _idRegMatchExp =
|
|
||||||
RegExp(r'youtube\..+?/channel/(.*?)(?:\?|&|/|$)');
|
|
||||||
|
|
||||||
/// Returns the [Channel] associated with the given channelId.
|
|
||||||
/// Throws an [ArgumentError] if the channel id is not valid.
|
|
||||||
Future<Channel> getChannel(String channelId) async {
|
|
||||||
if (!validateChannelId(channelId)) {
|
|
||||||
throw ArgumentError.value(
|
|
||||||
channelId, 'channelId', 'Invalid YouTube channel id');
|
|
||||||
}
|
|
||||||
|
|
||||||
var channelPage = await getChannelPage(channelId);
|
|
||||||
var channelTitle = channelPage
|
|
||||||
.querySelector('meta[property="og:title"]')
|
|
||||||
.attributes['content'];
|
|
||||||
var channelImage = channelPage
|
|
||||||
.querySelector('meta[property="og:image"]')
|
|
||||||
.attributes['content'];
|
|
||||||
|
|
||||||
return Channel(channelId, channelTitle, Uri.parse(channelImage));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a channel id from a username.
|
|
||||||
/// Returns null if the username is not found.
|
|
||||||
Future<String> getChannelId(String username) async {
|
|
||||||
if (!validateUsername(username)) {
|
|
||||||
throw ArgumentError.value(
|
|
||||||
username, 'username', 'Invalid YouTube username');
|
|
||||||
}
|
|
||||||
|
|
||||||
var userPage = await _getUserPage(username);
|
|
||||||
if (userPage == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var channelUrl =
|
|
||||||
userPage.querySelector('meta[property="og:url"]').attributes['content'];
|
|
||||||
|
|
||||||
return channelUrl.replaceFirst('/channel/', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns all the videos uploaded by a channel up to [maxPages] count.
|
|
||||||
Future<List<Video>> getChannelUploads(String channelId,
|
|
||||||
[int maxPages = 5]) async {
|
|
||||||
if (!validateChannelId(channelId)) {
|
|
||||||
throw ArgumentError.value(
|
|
||||||
channelId, 'channelId', 'Invalid YouTube channel id');
|
|
||||||
}
|
|
||||||
|
|
||||||
var playlistId = 'UU${channelId.replaceFirst('UC', '')}';
|
|
||||||
var playlist = await getPlaylist(playlistId, maxPages);
|
|
||||||
|
|
||||||
return playlist.videos;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the channel id for a given video.
|
|
||||||
Future<String> getChannelIdFromVideo(String videoId) async {
|
|
||||||
if (!YoutubeExplode.validateVideoId(videoId)) {
|
|
||||||
throw ArgumentError.value(videoId, 'videoId', 'Invalid YouTube video id');
|
|
||||||
}
|
|
||||||
var watchPage = await getVideoWatchPage(videoId);
|
|
||||||
var href = watchPage
|
|
||||||
.querySelector('.yt-user-info')
|
|
||||||
.querySelector('a')
|
|
||||||
.attributes['href'];
|
|
||||||
return href.replaceFirst('/channel/', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the channel page document.
|
|
||||||
Future<Document> getChannelPage(String channelId) async {
|
|
||||||
var url = 'https://www.youtube.com/channel/$channelId?hl=en';
|
|
||||||
var raw = (await client.get(url)).body;
|
|
||||||
|
|
||||||
return html.parse(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Document> _getUserPage(String username) async {
|
|
||||||
var url = 'https://www.youtube.com/user/$username?hl=en';
|
|
||||||
var req = await client.get(url);
|
|
||||||
if (req.statusCode != 200) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html.parse(req);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if [username] is a valid Youtube username.
|
|
||||||
static bool validateUsername(String username) {
|
|
||||||
if (username.isNullOrWhiteSpace) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (username.length > 20) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return !RegExp(r'[^0-9a-zA-Z]').hasMatch(username);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses a username from an url.
|
|
||||||
/// Returns null if the username is not found.
|
|
||||||
static String parseUsername(String url) {
|
|
||||||
if (url.isNullOrWhiteSpace) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var regMatch = _usernameRegMatchExp.firstMatch(url)?.group(1);
|
|
||||||
if (!regMatch.isNullOrWhiteSpace && validateUsername(regMatch)) {
|
|
||||||
return regMatch;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if [channelId] is a valid Youtube channel id.
|
|
||||||
static bool validateChannelId(String channelId) {
|
|
||||||
if (channelId.isNullOrWhiteSpace) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
channelId = channelId.toLowerCase();
|
|
||||||
|
|
||||||
if (!channelId.startsWith('uc')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channelId.length != 24) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return !RegExp(r'[^0-9a-zA-Z_\-]').hasMatch(channelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses a channel id from an url.
|
|
||||||
/// Returns null if the username is not found.
|
|
||||||
static String parseChannelId(String url) {
|
|
||||||
if (url.isNullOrWhiteSpace) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var regMatch = _idRegMatchExp.firstMatch(url)?.group(1);
|
|
||||||
if (!regMatch.isNullOrWhiteSpace && validateChannelId(regMatch)) {
|
|
||||||
return regMatch;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
|
|
||||||
import '../exceptions/exceptions.dart';
|
|
||||||
import '../models/models.dart';
|
|
||||||
|
|
||||||
/// Download extension for [MediaStreamInfo]
|
|
||||||
extension DownloadExtension on MediaStreamInfo {
|
|
||||||
static final _rateBypassExp = RegExp('ratebypass[=/]yes');
|
|
||||||
|
|
||||||
/// Returns the stream of this media stream object.
|
|
||||||
/// The download is split in multiple requests using the `range` parameter.
|
|
||||||
///
|
|
||||||
Stream<List<int>> downloadStream() async* {
|
|
||||||
var req = await http.head(url);
|
|
||||||
if (req.statusCode != 200) {
|
|
||||||
throw VideoStreamUnavailableException(req.statusCode, url);
|
|
||||||
}
|
|
||||||
|
|
||||||
var maxSize = _rateBypassExp.hasMatch(url.toString()) ? 9898989 : size + 1;
|
|
||||||
var total = 0;
|
|
||||||
|
|
||||||
for (var i = 1; total < size; i++) {
|
|
||||||
var req = http.Request('get', url);
|
|
||||||
req.headers['range'] = 'bytes=$total-${total + maxSize}';
|
|
||||||
var resp = await req.send();
|
|
||||||
yield* resp.stream;
|
|
||||||
total += maxSize + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,8 +0,0 @@
|
||||||
library youtube_explode.extensions;
|
|
||||||
|
|
||||||
export 'caption_extension.dart';
|
|
||||||
export 'channel_extension.dart';
|
|
||||||
export 'download_extension.dart';
|
|
||||||
export 'helpers_extension.dart';
|
|
||||||
export 'playlist_extension.dart';
|
|
||||||
export 'search_extension.dart';
|
|
|
@ -1,7 +1,12 @@
|
||||||
import '../cipher/cipher_operations.dart';
|
library _youtube_explode.extensions;
|
||||||
|
|
||||||
|
import '../reverse_engineering/cipher/cipher_operations.dart';
|
||||||
|
|
||||||
/// Utility for Strings.
|
/// Utility for Strings.
|
||||||
extension StringUtility on String {
|
extension StringUtility on String {
|
||||||
|
/// Returns null if this string is whitespace.
|
||||||
|
String get nullIfWhitespace => trim().isEmpty ? null : this;
|
||||||
|
|
||||||
/// Returns true if the string is null or empty.
|
/// Returns true if the string is null or empty.
|
||||||
bool get isNullOrWhiteSpace {
|
bool get isNullOrWhiteSpace {
|
||||||
if (this == null) {
|
if (this == null) {
|
||||||
|
@ -13,14 +18,21 @@ extension StringUtility on String {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns null if this string is a whitespace.
|
||||||
|
String substringUntil(String separator) => substring(0, indexOf(separator));
|
||||||
|
|
||||||
|
///
|
||||||
|
String substringAfter(String separator) =>
|
||||||
|
substring(indexOf(separator) + separator.length);
|
||||||
|
|
||||||
static final _exp = RegExp(r'\D');
|
static final _exp = RegExp(r'\D');
|
||||||
|
|
||||||
/// Strips out all non digit characters.
|
/// Strips out all non digit characters.
|
||||||
String get stripNonDigits => replaceAll(_exp, '');
|
String stripNonDigits() => replaceAll(_exp, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List decipher utility.
|
/// List decipher utility.
|
||||||
extension ListDecipher on List<CipherOperation> {
|
extension ListDecipher on Iterable<CipherOperation> {
|
||||||
/// Apply every CipherOperation on the [signature]
|
/// Apply every CipherOperation on the [signature]
|
||||||
String decipher(String signature) {
|
String decipher(String signature) {
|
||||||
for (var operation in this) {
|
for (var operation in this) {
|
||||||
|
@ -41,3 +53,39 @@ extension ListFirst<E> on List<E> {
|
||||||
return first;
|
return first;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Uri utility
|
||||||
|
extension UriUtility on Uri {
|
||||||
|
/// Returns a new Uri with the new query parameters set.
|
||||||
|
Uri setQueryParam(String key, String value) {
|
||||||
|
var query = Map<String, String>.from(queryParameters);
|
||||||
|
|
||||||
|
query[key] = value;
|
||||||
|
|
||||||
|
return replace(queryParameters: query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,156 +0,0 @@
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import '../models/models.dart';
|
|
||||||
import '../parser.dart' as parser;
|
|
||||||
import '../youtube_explode_base.dart';
|
|
||||||
import 'helpers_extension.dart';
|
|
||||||
|
|
||||||
/// Playlist extension for [YoutubeExplode]
|
|
||||||
extension PlaylistExtension on YoutubeExplode {
|
|
||||||
static final _regMatchExp =
|
|
||||||
RegExp(r'youtube\..+?/playlist.*?list=(.*?)(?:&|/|$)');
|
|
||||||
static final _compositeMatchExp = RegExp(
|
|
||||||
r'https://www.youtube.com/watch?v=b8m9zhNAgKs&list=PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr');
|
|
||||||
static final _shortCompositeMatchExp =
|
|
||||||
RegExp(r'youtu\.be/.*?/.*?list=(.*?)(?:&|/|$)');
|
|
||||||
static final _embedCompositeMatchExp =
|
|
||||||
RegExp(r'youtube\..+?/embed/.*?/.*?list=(.*?)(?:&|/|$)');
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _getPlayListJson(
|
|
||||||
String playlistId, int index) async {
|
|
||||||
var url =
|
|
||||||
'https://youtube.com/list_ajax?style=json&action_get_list=1&list=$playlistId&index=$index&hl=en';
|
|
||||||
var raw = (await client.get(url)).body;
|
|
||||||
|
|
||||||
return json.decode(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a [Future] that completes with a [Playlist].
|
|
||||||
/// If the id is not valid an [ArgumentError] is thrown.
|
|
||||||
Future<Playlist> getPlaylist(String playlistId, [int maxPages = 500]) async {
|
|
||||||
if (!validatePlaylistId(playlistId)) {
|
|
||||||
throw ArgumentError.value(playlistId, 'videoId', 'Invalid video id');
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> playlistJson;
|
|
||||||
var page = 1;
|
|
||||||
var index = 0;
|
|
||||||
var videoIds = <String>[];
|
|
||||||
var videos = <Video>[];
|
|
||||||
do {
|
|
||||||
playlistJson = await _getPlayListJson(playlistId, index);
|
|
||||||
var countDelta = 0;
|
|
||||||
for (var videoJson in playlistJson['video'] as List<dynamic>) {
|
|
||||||
var videoId = videoJson['encrypted_id'];
|
|
||||||
var author = videoJson['author'];
|
|
||||||
var uploadDate = DateTime.fromMillisecondsSinceEpoch(
|
|
||||||
videoJson['time_created'] * 1000);
|
|
||||||
var title = videoJson['title'];
|
|
||||||
var description = videoJson['description'];
|
|
||||||
var duration = Duration(seconds: videoJson['length_seconds']);
|
|
||||||
var viewCount =
|
|
||||||
int.parse((videoJson['views'] as String).stripNonDigits);
|
|
||||||
var likeCount = videoJson['likes'];
|
|
||||||
var dislikeCount = videoJson['dislikes'];
|
|
||||||
var keyWords = RegExp(r'"[^\"]+"|\S+')
|
|
||||||
.allMatches(videoJson['keywords'])
|
|
||||||
.map((e) => e.group(0))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
var statistics = Statistics(viewCount, likeCount, dislikeCount);
|
|
||||||
var thumbnails = ThumbnailSet(videoId);
|
|
||||||
if (!videoIds.contains(videoId)) {
|
|
||||||
videoIds.add(videoId);
|
|
||||||
videos.add(Video(videoId, author, uploadDate, title, description,
|
|
||||||
thumbnails, duration, keyWords, statistics));
|
|
||||||
countDelta++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (countDelta <= 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
index += countDelta;
|
|
||||||
page++;
|
|
||||||
} while (page <= maxPages);
|
|
||||||
|
|
||||||
var author = playlistJson['author'];
|
|
||||||
var title = playlistJson['title'];
|
|
||||||
var description = playlistJson['description'];
|
|
||||||
var viewCount = playlistJson['views'] ?? 0;
|
|
||||||
var likeCount = playlistJson['likes'] ?? 0;
|
|
||||||
var dislikeCount = playlistJson['dislikes'] ?? 0;
|
|
||||||
var type = parser.playlistTypeFromId(playlistId);
|
|
||||||
|
|
||||||
var statistics = Statistics(viewCount, likeCount, dislikeCount);
|
|
||||||
return Playlist(
|
|
||||||
playlistId, author, title, type, description, statistics, videos);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the given [playlistId] is valid.
|
|
||||||
static bool validatePlaylistId(String playlistId) {
|
|
||||||
playlistId = playlistId.toLowerCase();
|
|
||||||
|
|
||||||
if (playlistId.isNullOrWhiteSpace) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch later
|
|
||||||
if (playlistId == 'wl') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// My mix playlist
|
|
||||||
if (playlistId == 'rdmm') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!playlistId.startsWith('pl') &&
|
|
||||||
!playlistId.startsWith('rd') &&
|
|
||||||
!playlistId.startsWith('ul') &&
|
|
||||||
!playlistId.startsWith('uu') &&
|
|
||||||
!playlistId.startsWith('pu') &&
|
|
||||||
!playlistId.startsWith('ol') &&
|
|
||||||
!playlistId.startsWith('ll') &&
|
|
||||||
!playlistId.startsWith('fl')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playlistId.length < 13) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return !RegExp(r'[^0-9a-zA-Z_\-]').hasMatch(playlistId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses a playlist [url] returning its id.
|
|
||||||
/// If the [url] is a valid it is returned itself.
|
|
||||||
static String parsePlaylistId(String url) {
|
|
||||||
if (url.isNullOrWhiteSpace) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var regMatch = _regMatchExp.firstMatch(url)?.group(1);
|
|
||||||
if (!regMatch.isNullOrWhiteSpace && validatePlaylistId(regMatch)) {
|
|
||||||
return regMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
var compositeMatch = _compositeMatchExp.firstMatch(url)?.group(1);
|
|
||||||
if (!compositeMatch.isNullOrWhiteSpace &&
|
|
||||||
validatePlaylistId(compositeMatch)) {
|
|
||||||
return compositeMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
var shortCompositeMatch = _shortCompositeMatchExp.firstMatch(url)?.group(1);
|
|
||||||
if (!shortCompositeMatch.isNullOrWhiteSpace &&
|
|
||||||
validatePlaylistId(shortCompositeMatch)) {
|
|
||||||
return shortCompositeMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
var embedCompositeMatch = _embedCompositeMatchExp.firstMatch(url)?.group(1);
|
|
||||||
if (!embedCompositeMatch.isNullOrWhiteSpace &&
|
|
||||||
validatePlaylistId(embedCompositeMatch)) {
|
|
||||||
return embedCompositeMatch;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,58 +0,0 @@
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import '../models/models.dart';
|
|
||||||
import '../youtube_explode_base.dart';
|
|
||||||
import 'helpers_extension.dart';
|
|
||||||
|
|
||||||
/// Search extension for [YoutubeExplode]
|
|
||||||
extension SearchExtension on YoutubeExplode {
|
|
||||||
Future<Map<String, dynamic>> _getSearchResults(String query, int page) async {
|
|
||||||
var url =
|
|
||||||
'https://youtube.com/search_ajax?style=json&search_query=${Uri.encodeQueryComponent(query)}&page=$page&hl=en';
|
|
||||||
var raw = (await client.get(url)).body;
|
|
||||||
|
|
||||||
return json.decode(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Searches videos using given query up to [maxPages] count.
|
|
||||||
Future<List<Video>> searchVideos(String query, [int maxPages = 5]) async {
|
|
||||||
var videos = <Video>[];
|
|
||||||
for (var page = 1; page <= maxPages; page++) {
|
|
||||||
var resultsJson = await _getSearchResults(query, page);
|
|
||||||
|
|
||||||
var countDelta = 0;
|
|
||||||
var videosJson = resultsJson['video'] as List<dynamic>;
|
|
||||||
if (videosJson == null) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var videoJson in videosJson) {
|
|
||||||
var id = videoJson['encrypted_id'];
|
|
||||||
var author = videoJson['author'];
|
|
||||||
var uploadDate = DateTime.fromMillisecondsSinceEpoch(1581602398 * 1000);
|
|
||||||
var title = videoJson['title'];
|
|
||||||
var description = videoJson['description'];
|
|
||||||
var duration = Duration(seconds: videoJson['length_seconds']);
|
|
||||||
var viewCount =
|
|
||||||
int.parse((videoJson['views'] as String).stripNonDigits);
|
|
||||||
var likeCount = videoJson['likes'];
|
|
||||||
var dislikeCount = videoJson['dislikes'];
|
|
||||||
var keyWords = RegExp(r'"[^\"]+"|\S+')
|
|
||||||
.allMatches(videoJson['keywords'])
|
|
||||||
.map((e) => e.group(0))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
var statistics = Statistics(viewCount, likeCount, dislikeCount);
|
|
||||||
var thumbnails = ThumbnailSet(id);
|
|
||||||
videos.add(Video(id, author, uploadDate, title, description, thumbnails,
|
|
||||||
duration, keyWords, statistics));
|
|
||||||
|
|
||||||
countDelta++;
|
|
||||||
}
|
|
||||||
if (countDelta <= 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return videos;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
|
|
||||||
/// Information about a YouTube channel.
|
|
||||||
class Channel extends Equatable {
|
|
||||||
/// ID of this channel.
|
|
||||||
final String id;
|
|
||||||
|
|
||||||
/// Title of this channel.
|
|
||||||
final String title;
|
|
||||||
|
|
||||||
/// Logo image URL of this channel.
|
|
||||||
final Uri logoUrl;
|
|
||||||
|
|
||||||
/// Initializes an instance of [Channel]
|
|
||||||
const Channel(this.id, this.title, this.logoUrl);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object> get props => [id, title, logoUrl];
|
|
||||||
}
|
|
|
@ -1,30 +0,0 @@
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
|
|
||||||
/// Text that gets displayed at specific time during video playback,
|
|
||||||
/// as part of a [ClosedCaptionTrack]
|
|
||||||
class ClosedCaption extends Equatable {
|
|
||||||
/// Text displayed by this caption.
|
|
||||||
final String text;
|
|
||||||
|
|
||||||
/// Time at which this caption starts being displayed.
|
|
||||||
final Duration offset;
|
|
||||||
|
|
||||||
/// Duration this caption is displayed.
|
|
||||||
/// Negative if not found.
|
|
||||||
final Duration duration;
|
|
||||||
|
|
||||||
/// Initializes an instance of [ClosedCaption]
|
|
||||||
const ClosedCaption(this.text, this.offset, this.duration);
|
|
||||||
|
|
||||||
/// Time at which this caption starts being displayed.
|
|
||||||
Duration get start => offset;
|
|
||||||
|
|
||||||
/// Time at which this caption ends being displayed.
|
|
||||||
Duration get end => duration + offset;
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'Caption: $text ($offset - $end)';
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object> get props => [text, offset, duration];
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
|
|
||||||
import '../models.dart';
|
|
||||||
|
|
||||||
/// Set of captions that get displayed during video playback.
|
|
||||||
class ClosedCaptionTrack extends Equatable {
|
|
||||||
/// Metadata associated with this track.
|
|
||||||
final ClosedCaptionTrackInfo info;
|
|
||||||
|
|
||||||
/// Collection of closed captions that belong to this track.
|
|
||||||
final List<ClosedCaption> captions;
|
|
||||||
|
|
||||||
/// Initializes an instance of [ClosedCaptionTrack]
|
|
||||||
const ClosedCaptionTrack(this.info, this.captions);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object> get props => [info, captions];
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
/// AudioEncoding
|
|
||||||
enum AudioEncoding {
|
|
||||||
/// MPEG-4 Part 3, Advanced Audio Coding (AAC).
|
|
||||||
aac,
|
|
||||||
|
|
||||||
/// Vorbis.
|
|
||||||
vorbis,
|
|
||||||
|
|
||||||
/// Opus.
|
|
||||||
opus
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
import '../models.dart';
|
|
||||||
|
|
||||||
/// Metadata associated with a certain [MediaStream] that contains only audio.
|
|
||||||
class AudioStreamInfo extends MediaStreamInfo {
|
|
||||||
/// Bitrate (bits/s) of the associated stream.
|
|
||||||
final int bitrate;
|
|
||||||
|
|
||||||
/// Audio encoding of the associated stream.
|
|
||||||
final AudioEncoding audioEncoding;
|
|
||||||
|
|
||||||
/// Initializes an instance of [AudioStreamInfo]
|
|
||||||
AudioStreamInfo(int tag, Uri url, Container container, int size, this.bitrate,
|
|
||||||
this.audioEncoding)
|
|
||||||
: super(tag, url, container, size);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object> get props => super.props..addAll([bitrate, audioEncoding]);
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
/// Media stream container type.
|
|
||||||
enum Container {
|
|
||||||
/// MPEG-4 Part 14 (.mp4).
|
|
||||||
mp4,
|
|
||||||
|
|
||||||
/// Web Media (.webm).
|
|
||||||
webM,
|
|
||||||
|
|
||||||
/// 3rd Generation Partnership Project (.3gpp).
|
|
||||||
tgpp
|
|
||||||
}
|
|
|
@ -1,27 +0,0 @@
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
|
|
||||||
import '../models.dart';
|
|
||||||
|
|
||||||
/// Metadata associated with a certain [MediaStream]
|
|
||||||
class MediaStreamInfo extends Equatable {
|
|
||||||
/// Unique tag that identifies the properties of the associated stream.
|
|
||||||
final int itag;
|
|
||||||
|
|
||||||
/// URL of the endpoint that serves the associated stream.
|
|
||||||
final Uri url;
|
|
||||||
|
|
||||||
/// Container of the associated stream.
|
|
||||||
final Container container;
|
|
||||||
|
|
||||||
/// Content length (bytes) of the associated stream.
|
|
||||||
final int size;
|
|
||||||
|
|
||||||
/// Initializes an instance of [MediaStreamInfo]
|
|
||||||
const MediaStreamInfo(this.itag, this.url, this.container, this.size);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => '$itag ($container)';
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object> get props => [itag, url, container, size];
|
|
||||||
}
|
|
|
@ -1,34 +0,0 @@
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
|
|
||||||
import '../models.dart';
|
|
||||||
|
|
||||||
/// Set of all available media stream infos.
|
|
||||||
class MediaStreamInfoSet extends Equatable {
|
|
||||||
/// Muxed streams.
|
|
||||||
final List<MuxedStreamInfo> muxed;
|
|
||||||
|
|
||||||
/// Audio-only streams.
|
|
||||||
final List<AudioStreamInfo> audio;
|
|
||||||
|
|
||||||
/// Video-only streams.
|
|
||||||
final List<VideoStreamInfo> video;
|
|
||||||
|
|
||||||
/// Raw HTTP Live Streaming (HLS) URL to the m3u8 playlist.
|
|
||||||
/// Null if not a live stream.
|
|
||||||
final String hlsLiveStreamUrl;
|
|
||||||
|
|
||||||
/// Video details associated with the stream info set.
|
|
||||||
/// Some values might be null.
|
|
||||||
final Video videoDetails;
|
|
||||||
|
|
||||||
/// Date until this media set is valid.
|
|
||||||
final DateTime validUntil;
|
|
||||||
|
|
||||||
/// Initializes an instance of [MediaStreamInfoSet].
|
|
||||||
const MediaStreamInfoSet(this.muxed, this.audio, this.video,
|
|
||||||
this.hlsLiveStreamUrl, this.videoDetails, this.validUntil);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object> get props =>
|
|
||||||
[muxed, audio, video, hlsLiveStreamUrl, videoDetails, validUntil];
|
|
||||||
}
|
|
|
@ -1,43 +0,0 @@
|
||||||
import '../models.dart';
|
|
||||||
|
|
||||||
/// Metadata associated with a certain [MediaStream]
|
|
||||||
/// that contains both audio and video.
|
|
||||||
class MuxedStreamInfo extends MediaStreamInfo {
|
|
||||||
/// Audio encoding of the associated stream.
|
|
||||||
final AudioEncoding audioEncoding;
|
|
||||||
|
|
||||||
/// Video encoding of the associated stream.
|
|
||||||
final VideoEncoding videoEncoding;
|
|
||||||
|
|
||||||
/// Video quality label of the associated stream.
|
|
||||||
final String videoQualityLabel;
|
|
||||||
|
|
||||||
/// Video quality of the associated stream.
|
|
||||||
final VideoQuality videoQuality;
|
|
||||||
|
|
||||||
/// Video resolution of the associated stream.
|
|
||||||
final VideoResolution videoResolution;
|
|
||||||
|
|
||||||
/// Initializes an instance of [MuxedStreamInfo]
|
|
||||||
const MuxedStreamInfo(
|
|
||||||
int itag,
|
|
||||||
Uri url,
|
|
||||||
Container container,
|
|
||||||
int size,
|
|
||||||
this.audioEncoding,
|
|
||||||
this.videoEncoding,
|
|
||||||
this.videoQualityLabel,
|
|
||||||
this.videoQuality,
|
|
||||||
this.videoResolution)
|
|
||||||
: super(itag, url, container, size);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object> get props => super.props
|
|
||||||
..addAll([
|
|
||||||
audioEncoding,
|
|
||||||
videoEncoding,
|
|
||||||
videoQualityLabel,
|
|
||||||
videoQuality,
|
|
||||||
videoResolution
|
|
||||||
]);
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
/// Video encoding.
|
|
||||||
enum VideoEncoding {
|
|
||||||
/// MPEG-4 Part 2.
|
|
||||||
mp4v,
|
|
||||||
@Deprecated('Not available anymore')
|
|
||||||
|
|
||||||
/// H263.
|
|
||||||
h263,
|
|
||||||
|
|
||||||
/// MPEG-4 Part 10, H264, Advanced Video Coding (AVC).
|
|
||||||
h264,
|
|
||||||
|
|
||||||
/// VP8.
|
|
||||||
vp8,
|
|
||||||
|
|
||||||
/// VP9.
|
|
||||||
vp9,
|
|
||||||
|
|
||||||
/// AV1.
|
|
||||||
av1
|
|
||||||
}
|
|
|
@ -1,47 +0,0 @@
|
||||||
import '../models.dart';
|
|
||||||
|
|
||||||
/// Metadata associated with a certain [MediaStream]that contains only video.
|
|
||||||
class VideoStreamInfo extends MediaStreamInfo {
|
|
||||||
/// Bitrate (bits/s) of the associated stream.
|
|
||||||
final int bitrate;
|
|
||||||
|
|
||||||
/// Video encoding of the associated stream.
|
|
||||||
final VideoEncoding videoEncoding;
|
|
||||||
|
|
||||||
/// Video quality label of the associated stream.
|
|
||||||
final String videoQualityLabel;
|
|
||||||
|
|
||||||
/// Video quality of the associated stream.
|
|
||||||
final VideoQuality videoQuality;
|
|
||||||
|
|
||||||
/// Video resolution of the associated stream.
|
|
||||||
final VideoResolution videoResolution;
|
|
||||||
|
|
||||||
/// Video framerate (FPS) of the associated stream.
|
|
||||||
final int framerate;
|
|
||||||
|
|
||||||
/// Initializes an instance of [VideoStreamInfo]
|
|
||||||
const VideoStreamInfo(
|
|
||||||
int itag,
|
|
||||||
Uri url,
|
|
||||||
Container container,
|
|
||||||
int size,
|
|
||||||
this.bitrate,
|
|
||||||
this.videoEncoding,
|
|
||||||
this.videoQualityLabel,
|
|
||||||
this.videoQuality,
|
|
||||||
this.videoResolution,
|
|
||||||
this.framerate)
|
|
||||||
: super(itag, url, container, size);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object> get props => super.props
|
|
||||||
..addAll([
|
|
||||||
bitrate,
|
|
||||||
videoEncoding,
|
|
||||||
videoQualityLabel,
|
|
||||||
videoQuality,
|
|
||||||
videoResolution,
|
|
||||||
framerate
|
|
||||||
]);
|
|
||||||
}
|
|
|
@ -1,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';
|
|
|
@ -1,58 +0,0 @@
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
|
|
||||||
import 'models.dart';
|
|
||||||
|
|
||||||
/// Player configuration.
|
|
||||||
class PlayerConfiguration extends Equatable {
|
|
||||||
/// Player source url.
|
|
||||||
final String playerSourceUrl;
|
|
||||||
|
|
||||||
/// Dash manifest url.
|
|
||||||
final String dashManifestUrl;
|
|
||||||
|
|
||||||
/// Live stream raw url.
|
|
||||||
final String hlsManifestUrl;
|
|
||||||
|
|
||||||
/// Muxed stream url encoded.
|
|
||||||
final String muxedStreamInfosUrlEncoded;
|
|
||||||
|
|
||||||
/// Adaptive stream url encoded.
|
|
||||||
final String adaptiveStreamInfosUrlEncoded;
|
|
||||||
|
|
||||||
/// Muxed stream info.
|
|
||||||
final List<dynamic> muxedStreamInfoJson;
|
|
||||||
|
|
||||||
/// Adaptive stream info.
|
|
||||||
final List<dynamic> adaptiveStreamInfosJson;
|
|
||||||
|
|
||||||
/// Video associated with this player configuration.
|
|
||||||
final Video video;
|
|
||||||
|
|
||||||
/// Date until this player configuration is valid.
|
|
||||||
final DateTime validUntil;
|
|
||||||
|
|
||||||
/// Initializes an instance of [PlayerConfiguration]
|
|
||||||
const PlayerConfiguration(
|
|
||||||
this.playerSourceUrl,
|
|
||||||
this.dashManifestUrl,
|
|
||||||
this.hlsManifestUrl,
|
|
||||||
this.muxedStreamInfosUrlEncoded,
|
|
||||||
this.adaptiveStreamInfosUrlEncoded,
|
|
||||||
this.muxedStreamInfoJson,
|
|
||||||
this.adaptiveStreamInfosJson,
|
|
||||||
this.video,
|
|
||||||
this.validUntil);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object> get props => [
|
|
||||||
playerSourceUrl,
|
|
||||||
dashManifestUrl,
|
|
||||||
hlsManifestUrl,
|
|
||||||
muxedStreamInfosUrlEncoded,
|
|
||||||
adaptiveStreamInfosUrlEncoded,
|
|
||||||
muxedStreamInfoJson,
|
|
||||||
adaptiveStreamInfosJson,
|
|
||||||
video,
|
|
||||||
validUntil
|
|
||||||
];
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
|
|
||||||
import 'models.dart';
|
|
||||||
|
|
||||||
/// Information about a YouTube playlist.
|
|
||||||
class Playlist extends Equatable {
|
|
||||||
/// ID of this playlist.
|
|
||||||
final String id;
|
|
||||||
|
|
||||||
/// Author of this playlist.
|
|
||||||
final String author;
|
|
||||||
|
|
||||||
/// Title of this playlist.
|
|
||||||
final String title;
|
|
||||||
|
|
||||||
/// The type of this playlist.
|
|
||||||
final PlaylistType type;
|
|
||||||
|
|
||||||
/// Description of this playlist.
|
|
||||||
final String description;
|
|
||||||
|
|
||||||
/// Statistics of this playlist.
|
|
||||||
final Statistics statistics;
|
|
||||||
|
|
||||||
/// Collection of videos contained in this playlist.
|
|
||||||
final List<Video> videos;
|
|
||||||
|
|
||||||
/// Initializes an instance of [Playlist]
|
|
||||||
Playlist(this.id, this.author, this.title, this.type, this.description,
|
|
||||||
this.statistics, this.videos);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object> get props =>
|
|
||||||
[id, author, title, type, description, statistics, videos];
|
|
||||||
}
|
|
|
@ -1,30 +0,0 @@
|
||||||
/// Playlist type.
|
|
||||||
enum PlaylistType {
|
|
||||||
/// Regular playlist created by a user.
|
|
||||||
normal,
|
|
||||||
|
|
||||||
/// Mix playlist generated to group similar videos.
|
|
||||||
videoMix,
|
|
||||||
|
|
||||||
/// Mix playlist generated to group similar videos uploaded
|
|
||||||
/// by the same channel.
|
|
||||||
channelVideoMix,
|
|
||||||
|
|
||||||
/// Playlist generated from channel uploads.
|
|
||||||
channelVideos,
|
|
||||||
|
|
||||||
/// Playlist generated from popular channel uploads.
|
|
||||||
popularChannelVideos,
|
|
||||||
|
|
||||||
/// Playlist generated from automated music videos.
|
|
||||||
musicAlbum,
|
|
||||||
|
|
||||||
/// System playlist for videos liked by a user.
|
|
||||||
likedVideos,
|
|
||||||
|
|
||||||
/// System playlist for videos favorited by a user.
|
|
||||||
favorites,
|
|
||||||
|
|
||||||
/// System playlist for videos user added to watch later.
|
|
||||||
watchLater,
|
|
||||||
}
|
|
|
@ -1,62 +0,0 @@
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
|
|
||||||
import 'models.dart';
|
|
||||||
|
|
||||||
/// Information about a YouTube video.
|
|
||||||
class Video extends Equatable {
|
|
||||||
/// ID of this video.
|
|
||||||
final String id;
|
|
||||||
|
|
||||||
/// Author of this video.
|
|
||||||
final String author;
|
|
||||||
|
|
||||||
/// Upload date of this video.
|
|
||||||
/// null for [MediaStreamInfoSet.videoDetails]
|
|
||||||
final DateTime uploadDate;
|
|
||||||
|
|
||||||
/// Title of this video.
|
|
||||||
final String title;
|
|
||||||
|
|
||||||
/// Description of this video.
|
|
||||||
final String description;
|
|
||||||
|
|
||||||
/// Thumbnails of this video.
|
|
||||||
final ThumbnailSet thumbnailSet;
|
|
||||||
|
|
||||||
/// Duration of this video.
|
|
||||||
final Duration duration;
|
|
||||||
|
|
||||||
/// Search keywords of this video.
|
|
||||||
final List<String> keyWords;
|
|
||||||
|
|
||||||
/// Statistics of this video.
|
|
||||||
final Statistics statistics;
|
|
||||||
|
|
||||||
/// Initializes an instance of [Video]
|
|
||||||
const Video(
|
|
||||||
this.id,
|
|
||||||
this.author,
|
|
||||||
this.uploadDate,
|
|
||||||
this.title,
|
|
||||||
this.description,
|
|
||||||
this.thumbnailSet,
|
|
||||||
this.duration,
|
|
||||||
this.keyWords,
|
|
||||||
this.statistics);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'Video($id): $title';
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object> get props => [
|
|
||||||
id,
|
|
||||||
author,
|
|
||||||
uploadDate,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
thumbnailSet,
|
|
||||||
duration,
|
|
||||||
keyWords,
|
|
||||||
statistics
|
|
||||||
];
|
|
||||||
}
|
|
|
@ -1,290 +0,0 @@
|
||||||
import 'models/models.dart';
|
|
||||||
|
|
||||||
/// Parse the string as [Container].
|
|
||||||
/// Throws an [ArgumentError] if the string matches no container.
|
|
||||||
Container stringToContainer(String str) {
|
|
||||||
str = str.toLowerCase().trim(); // Case-insensitive.
|
|
||||||
|
|
||||||
if (str == 'mp4') {
|
|
||||||
return Container.mp4;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str == 'webm') {
|
|
||||||
return Container.webM;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str == '3gpp') {
|
|
||||||
return Container.tgpp;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw ArgumentError.value(str, 'str', 'Unrecognized container');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse the string as [AudioEncoding].
|
|
||||||
/// /// Throws an [ArgumentError] if the string matches no audio encoding.
|
|
||||||
AudioEncoding audioEncodingFromString(String str) {
|
|
||||||
str = str.toLowerCase().trim();
|
|
||||||
|
|
||||||
if (str.startsWith('mp4a')) {
|
|
||||||
return AudioEncoding.aac;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str.startsWith('vorbis')) {
|
|
||||||
return AudioEncoding.vorbis;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str.startsWith('opus')) {
|
|
||||||
return AudioEncoding.opus;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw ArgumentError.value(str, 'str', 'Unrecognized audio encoding');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses the string as [VideoEncoding].
|
|
||||||
/// Throws an [ArgumentError] if the string matches no video encoding.
|
|
||||||
VideoEncoding videoEncodingFromString(String str) {
|
|
||||||
str = str.toLowerCase().trim();
|
|
||||||
|
|
||||||
if (str.startsWith('mp4v')) {
|
|
||||||
return VideoEncoding.mp4v;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str.startsWith('avc1')) {
|
|
||||||
return VideoEncoding.h264;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str.startsWith('vp8')) {
|
|
||||||
return VideoEncoding.vp8;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str.startsWith('vp9')) {
|
|
||||||
return VideoEncoding.vp9;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str.startsWith('av01')) {
|
|
||||||
return VideoEncoding.av1;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw ArgumentError.value(str, 'str', 'Unrecognized video encoding');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses the itag as [VideoQuality]
|
|
||||||
/// Throws an [ArgumentError] if the itag matches no video quality.
|
|
||||||
VideoQuality videoQualityFromITag(int itag) {
|
|
||||||
var q = _qualityMap[itag];
|
|
||||||
if (q == null) {
|
|
||||||
throw ArgumentError.value(itag, 'itag', 'Unrecognized itag');
|
|
||||||
}
|
|
||||||
return q;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert a video quality to a [String].
|
|
||||||
String videoQualityToLabel(VideoQuality quality) =>
|
|
||||||
// ignore: prefer_interpolation_to_compose_strings
|
|
||||||
quality.toString().replaceAll(RegExp(r'\D'), '') + 'p';
|
|
||||||
|
|
||||||
/// Returns a [VideoResolution] from its [VideoQuality]
|
|
||||||
VideoResolution videoQualityToResolution(VideoQuality quality) {
|
|
||||||
var r = _resolutionMap[quality];
|
|
||||||
if (r == null) {
|
|
||||||
throw ArgumentError.value(quality, 'quality', 'Unrecognized video quality');
|
|
||||||
}
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses the label as [VideoQuality]
|
|
||||||
/// Throws an [ArgumentError] if the string matches no video quality.
|
|
||||||
VideoQuality videoQualityFromLabel(String label) {
|
|
||||||
label = label.toLowerCase();
|
|
||||||
|
|
||||||
if (label.startsWith('144p')) {
|
|
||||||
return VideoQuality.low144;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (label.startsWith('240p')) {
|
|
||||||
return VideoQuality.low144;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (label.startsWith('360p')) {
|
|
||||||
return VideoQuality.medium360;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (label.startsWith('480p')) {
|
|
||||||
return VideoQuality.medium480;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (label.startsWith('720p')) {
|
|
||||||
return VideoQuality.high720;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (label.startsWith('1080p')) {
|
|
||||||
return VideoQuality.high1080;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (label.startsWith('1440p')) {
|
|
||||||
return VideoQuality.high1440;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (label.startsWith('2160p')) {
|
|
||||||
return VideoQuality.high2160;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (label.startsWith('2880p')) {
|
|
||||||
return VideoQuality.high2880;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (label.startsWith('3072p')) {
|
|
||||||
return VideoQuality.high3072;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (label.startsWith('4320p')) {
|
|
||||||
return VideoQuality.high4320;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw ArgumentError.value(label, 'label', 'Unrecognized video quality label');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses the playlist id as [PlaylistType]
|
|
||||||
/// Throws an [ArgumentError] if the string matches no playlist type.
|
|
||||||
PlaylistType playlistTypeFromId(String id) {
|
|
||||||
id = id.toLowerCase();
|
|
||||||
|
|
||||||
if (id.startsWith('pl')) {
|
|
||||||
return PlaylistType.normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.startsWith('rd')) {
|
|
||||||
return PlaylistType.videoMix;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.startsWith('ul')) {
|
|
||||||
return PlaylistType.channelVideoMix;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.startsWith('uu')) {
|
|
||||||
return PlaylistType.channelVideos;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.startsWith('pu')) {
|
|
||||||
return PlaylistType.popularChannelVideos;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.startsWith('ol')) {
|
|
||||||
return PlaylistType.musicAlbum;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.startsWith('ll')) {
|
|
||||||
return PlaylistType.likedVideos;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.startsWith('fl')) {
|
|
||||||
return PlaylistType.favorites;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.startsWith('ml')) {
|
|
||||||
return PlaylistType.watchLater;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw ArgumentError.value(id, 'id', 'Unrecognized playlist type');
|
|
||||||
}
|
|
||||||
|
|
||||||
const _qualityMap = <int, VideoQuality>{
|
|
||||||
5: VideoQuality.low144,
|
|
||||||
6: VideoQuality.low240,
|
|
||||||
13: VideoQuality.low144,
|
|
||||||
17: VideoQuality.low144,
|
|
||||||
18: VideoQuality.medium360,
|
|
||||||
22: VideoQuality.high720,
|
|
||||||
34: VideoQuality.medium360,
|
|
||||||
35: VideoQuality.medium480,
|
|
||||||
36: VideoQuality.low240,
|
|
||||||
37: VideoQuality.high1080,
|
|
||||||
38: VideoQuality.high3072,
|
|
||||||
43: VideoQuality.medium360,
|
|
||||||
44: VideoQuality.medium480,
|
|
||||||
45: VideoQuality.high720,
|
|
||||||
46: VideoQuality.high1080,
|
|
||||||
59: VideoQuality.medium480,
|
|
||||||
78: VideoQuality.medium480,
|
|
||||||
82: VideoQuality.medium360,
|
|
||||||
83: VideoQuality.medium480,
|
|
||||||
84: VideoQuality.high720,
|
|
||||||
85: VideoQuality.high1080,
|
|
||||||
91: VideoQuality.low144,
|
|
||||||
92: VideoQuality.low240,
|
|
||||||
93: VideoQuality.medium360,
|
|
||||||
94: VideoQuality.medium480,
|
|
||||||
95: VideoQuality.high720,
|
|
||||||
96: VideoQuality.high1080,
|
|
||||||
100: VideoQuality.medium360,
|
|
||||||
101: VideoQuality.medium480,
|
|
||||||
102: VideoQuality.high720,
|
|
||||||
132: VideoQuality.low240,
|
|
||||||
151: VideoQuality.low144,
|
|
||||||
133: VideoQuality.low240,
|
|
||||||
134: VideoQuality.medium360,
|
|
||||||
135: VideoQuality.medium480,
|
|
||||||
136: VideoQuality.high720,
|
|
||||||
137: VideoQuality.high1080,
|
|
||||||
138: VideoQuality.high4320,
|
|
||||||
160: VideoQuality.low144,
|
|
||||||
212: VideoQuality.medium480,
|
|
||||||
213: VideoQuality.medium480,
|
|
||||||
214: VideoQuality.high720,
|
|
||||||
215: VideoQuality.high720,
|
|
||||||
216: VideoQuality.high1080,
|
|
||||||
217: VideoQuality.high1080,
|
|
||||||
264: VideoQuality.high1440,
|
|
||||||
266: VideoQuality.high2160,
|
|
||||||
298: VideoQuality.high720,
|
|
||||||
299: VideoQuality.high1080,
|
|
||||||
399: VideoQuality.high1080,
|
|
||||||
398: VideoQuality.high720,
|
|
||||||
397: VideoQuality.medium480,
|
|
||||||
396: VideoQuality.medium360,
|
|
||||||
395: VideoQuality.low240,
|
|
||||||
394: VideoQuality.low144,
|
|
||||||
167: VideoQuality.medium360,
|
|
||||||
168: VideoQuality.medium480,
|
|
||||||
169: VideoQuality.high720,
|
|
||||||
170: VideoQuality.high1080,
|
|
||||||
218: VideoQuality.medium480,
|
|
||||||
219: VideoQuality.medium480,
|
|
||||||
242: VideoQuality.low240,
|
|
||||||
243: VideoQuality.medium360,
|
|
||||||
244: VideoQuality.medium480,
|
|
||||||
245: VideoQuality.medium480,
|
|
||||||
246: VideoQuality.medium480,
|
|
||||||
247: VideoQuality.high720,
|
|
||||||
248: VideoQuality.high1080,
|
|
||||||
271: VideoQuality.high1440,
|
|
||||||
272: VideoQuality.high2160,
|
|
||||||
278: VideoQuality.low144,
|
|
||||||
302: VideoQuality.high720,
|
|
||||||
303: VideoQuality.high1080,
|
|
||||||
308: VideoQuality.high1440,
|
|
||||||
313: VideoQuality.high2160,
|
|
||||||
315: VideoQuality.high2160,
|
|
||||||
330: VideoQuality.low144,
|
|
||||||
331: VideoQuality.low240,
|
|
||||||
332: VideoQuality.medium360,
|
|
||||||
333: VideoQuality.medium480,
|
|
||||||
334: VideoQuality.high720,
|
|
||||||
335: VideoQuality.high1080,
|
|
||||||
336: VideoQuality.high1440,
|
|
||||||
337: VideoQuality.high2160,
|
|
||||||
};
|
|
||||||
|
|
||||||
const _resolutionMap = <VideoQuality, VideoResolution>{
|
|
||||||
VideoQuality.low144: VideoResolution(256, 144),
|
|
||||||
VideoQuality.low240: VideoResolution(426, 240),
|
|
||||||
VideoQuality.medium360: VideoResolution(640, 360),
|
|
||||||
VideoQuality.medium480: VideoResolution(854, 480),
|
|
||||||
VideoQuality.high720: VideoResolution(1280, 720),
|
|
||||||
VideoQuality.high1080: VideoResolution(1920, 1080),
|
|
||||||
VideoQuality.high1440: VideoResolution(2560, 1440),
|
|
||||||
VideoQuality.high2160: VideoResolution(3840, 2160),
|
|
||||||
VideoQuality.high2880: VideoResolution(5120, 2880),
|
|
||||||
VideoQuality.high3072: VideoResolution(4096, 3072),
|
|
||||||
VideoQuality.high4320: VideoResolution(7680, 4320),
|
|
||||||
};
|
|
|
@ -0,0 +1,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)';
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
library youtube_explode.playlists;
|
||||||
|
|
||||||
|
export 'playlist.dart';
|
||||||
|
export 'playlist_client.dart';
|
||||||
|
export 'playlist_id.dart';
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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'));
|
||||||
|
}
|
|
@ -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'));
|
||||||
|
}
|
|
@ -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']}';
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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';
|
|
@ -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;
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -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];
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
/// Part of a closed caption (usually a single word).
|
||||||
|
class ClosedCaptionPart {
|
||||||
|
/// Text displayed by this caption part.
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
/// Time at which this caption part starts being displayed
|
||||||
|
/// (relative to the caption's own offset).
|
||||||
|
final Duration offset;
|
||||||
|
|
||||||
|
/// Initializes an instance of [ClosedCaptionPart]
|
||||||
|
ClosedCaptionPart(this.text, this.offset);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => text;
|
||||||
|
}
|
|
@ -0,0 +1,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);
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
import '../models.dart';
|
import 'language.dart';
|
||||||
|
|
||||||
/// Metadata associated with a certain [ClosedCaptionTrack]
|
/// Metadata associated with a certain [ClosedCaptionTrack]
|
||||||
class ClosedCaptionTrackInfo extends Equatable {
|
class ClosedCaptionTrackInfo extends Equatable {
|
||||||
|
@ -16,6 +16,9 @@ class ClosedCaptionTrackInfo extends Equatable {
|
||||||
/// Initializes an instance of [ClosedCaptionTrackInfo]
|
/// Initializes an instance of [ClosedCaptionTrackInfo]
|
||||||
const ClosedCaptionTrackInfo(this.url, this.language, {this.isAutoGenerated});
|
const ClosedCaptionTrackInfo(this.url, this.language, {this.isAutoGenerated});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'CC Track ($language)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [url, language, isAutoGenerated];
|
List<Object> get props => [url, language, isAutoGenerated];
|
||||||
}
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
export 'closed_caption.dart';
|
||||||
|
export 'closed_caption_client.dart';
|
||||||
|
export 'closed_caption_manifest.dart';
|
||||||
|
export 'closed_caption_part.dart';
|
||||||
|
export 'closed_caption_track.dart';
|
||||||
|
export 'closed_caption_track_info.dart';
|
|
@ -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)';
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -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()}';
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Stream container.
|
||||||
|
class Container with EquatableMixin {
|
||||||
|
/// Container name.
|
||||||
|
/// Can be used as file extension
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// Initializes an instance of [Container]
|
||||||
|
Container._(this.name);
|
||||||
|
|
||||||
|
/// MPEG-4 Part 14 (.mp4).
|
||||||
|
static final Container mp4 = Container._('mp4');
|
||||||
|
|
||||||
|
/// Web Media (.webm).
|
||||||
|
static final Container webM = Container._('webm');
|
||||||
|
|
||||||
|
/// 3rd Generation Partnership Project (.3gpp).
|
||||||
|
static final Container tgpp = Container._('3gpp');
|
||||||
|
|
||||||
|
/// Parse a container from name.
|
||||||
|
static Container parse(String name) {
|
||||||
|
if (name.toLowerCase() == 'mp4') {
|
||||||
|
return Container.mp4;
|
||||||
|
}
|
||||||
|
if (name.toLowerCase() == 'webm') {
|
||||||
|
return Container.webM;
|
||||||
|
}
|
||||||
|
if (name.toLowerCase() == '3gpp') {
|
||||||
|
return Container.tgpp;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ArgumentError.value(name, 'name', 'Valid values: mp4, webm, 3gpp');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [name];
|
||||||
|
}
|
|
@ -0,0 +1,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];
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -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';
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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>();
|
||||||
|
}
|
|
@ -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';
|
|
@ -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);
|
||||||
|
}
|
|
@ -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';
|
||||||
|
}
|
|
@ -1,7 +1,5 @@
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
|
|
||||||
/// Width and height of a video.
|
/// Width and height of a video.
|
||||||
class VideoResolution extends Equatable {
|
class VideoResolution {
|
||||||
/// Viewport width.
|
/// Viewport width.
|
||||||
final int width;
|
final int width;
|
||||||
|
|
||||||
|
@ -13,7 +11,4 @@ class VideoResolution extends Equatable {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => '${width}x$height';
|
String toString() => '${width}x$height';
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object> get props => [width, height];
|
|
||||||
}
|
}
|
|
@ -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));
|
||||||
|
}
|
|
@ -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)';
|
||||||
|
}
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
export 'streams/streams.dart';
|
||||||
|
export 'video.dart';
|
||||||
|
export 'video_client.dart';
|
||||||
|
export 'video_id.dart';
|
|
@ -1,541 +1,41 @@
|
||||||
import 'dart:convert';
|
library youtube_explode.base;
|
||||||
|
|
||||||
import 'package:html/dom.dart';
|
import 'channels/channels.dart';
|
||||||
import 'package:html/parser.dart' as html;
|
import 'playlists/playlist_client.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'reverse_engineering/youtube_http_client.dart';
|
||||||
import 'package:http_parser/http_parser.dart' show MediaType;
|
import 'search/search_client.dart';
|
||||||
|
import 'videos/video_client.dart';
|
||||||
|
|
||||||
import 'cipher/cipher.dart';
|
/// Library entry point.
|
||||||
import 'exceptions/exceptions.dart';
|
|
||||||
import 'extensions/extensions.dart';
|
|
||||||
import 'models/models.dart';
|
|
||||||
import 'parser.dart' as parser;
|
|
||||||
|
|
||||||
/// YoutubeExplode entry class.
|
|
||||||
class YoutubeExplode {
|
class YoutubeExplode {
|
||||||
static final _regMatchExp = RegExp(r'youtube\..+?/watch.*?v=(.*?)(?:&|/|$)');
|
final YoutubeHttpClient _httpClient;
|
||||||
static final _shortMatchExp = RegExp(r'youtu\.be/(.*?)(?:\?|&|/|$)');
|
|
||||||
static final _embedMatchExp = RegExp(r'youtube\..+?/embed/(.*?)(?:\?|&|/|$)');
|
|
||||||
static final _playerConfigExp = RegExp(
|
|
||||||
r"yt\.setConfig\({'PLAYER_CONFIG':(.*)}\);",
|
|
||||||
multiLine: true,
|
|
||||||
caseSensitive: false);
|
|
||||||
static final _contentLenExp = RegExp(r'clen=(\d+)');
|
|
||||||
|
|
||||||
/// HTTP Client.
|
/// Queries related to YouTube videos.
|
||||||
// Visible only for extensions.
|
VideoClient get videos => _videos;
|
||||||
http.Client client;
|
|
||||||
|
|
||||||
/// Initialize [YoutubeExplode] class and http client.
|
/// Queries related to YouTube playlists.
|
||||||
YoutubeExplode() : client = http.Client();
|
PlaylistClient get playlists => _playlists;
|
||||||
|
|
||||||
/// Returns a [Future] that completes with a [MediaStreamInfoSet]
|
/// Queries related to YouTube channels.
|
||||||
/// Use this to extract the muxed, audio and video streams from a video.
|
ChannelClient get channels => _channels;
|
||||||
Future<MediaStreamInfoSet> getVideoMediaStream(String videoId) async {
|
|
||||||
if (!validateVideoId(videoId)) {
|
|
||||||
throw ArgumentError.value(videoId, 'videoId', 'Invalid video id');
|
|
||||||
}
|
|
||||||
|
|
||||||
var playerConfiguration = await getPlayerConfiguration(videoId);
|
/// YouTube search queries.
|
||||||
|
SearchClient get search => _search;
|
||||||
|
|
||||||
var muxedStreamInfoMap = <int, MuxedStreamInfo>{};
|
/// Initializes an instance of [YoutubeClient].
|
||||||
var audioStreamInfoMap = <int, AudioStreamInfo>{};
|
YoutubeExplode() : _httpClient = YoutubeHttpClient() {
|
||||||
var videoStreamInfoMap = <int, VideoStreamInfo>{};
|
_videos = VideoClient(_httpClient);
|
||||||
|
_playlists = PlaylistClient(_httpClient);
|
||||||
var muxedStreamInfoDics =
|
_channels = ChannelClient(_httpClient);
|
||||||
playerConfiguration.muxedStreamInfosUrlEncoded?.split(',');
|
_search = SearchClient(_httpClient);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the player configuration for a given video.
|
VideoClient _videos;
|
||||||
Future<PlayerConfiguration> getPlayerConfiguration(String videoId) async {
|
PlaylistClient _playlists;
|
||||||
var playerConfiguration = await _getPlayerConfigEmbed(videoId);
|
ChannelClient _channels;
|
||||||
|
SearchClient _search;
|
||||||
|
|
||||||
// If still null try from the watch page.
|
/// Closes the HttpClient assigned to this [YoutubeHttpClient].
|
||||||
playerConfiguration ??= await _getPlayerConfigWatchPage(videoId);
|
/// Should be called after this is not used anymore.
|
||||||
|
void close() => _httpClient.close();
|
||||||
if (playerConfiguration == null) {
|
|
||||||
throw VideoUnavailableException(videoId);
|
|
||||||
}
|
|
||||||
return playerConfiguration;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<PlayerConfiguration> _getPlayerConfigEmbed(String videoId) async {
|
|
||||||
var req = await client.get('https://www.youtube.com/embed/$videoId?&hl=en');
|
|
||||||
if (req.statusCode != 200) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
var body = req.body;
|
|
||||||
var document = html.parse(body);
|
|
||||||
var playerConfigRaw = document
|
|
||||||
.getElementsByTagName('script')
|
|
||||||
.map((e) => e.innerHtml)
|
|
||||||
.map((e) => _playerConfigExp?.firstMatch(e)?.group(1))
|
|
||||||
.firstWhere((s) => s?.trim()?.isNotEmpty ?? false);
|
|
||||||
var playerConfigJson = json.decode(playerConfigRaw);
|
|
||||||
|
|
||||||
// Extract player source URL.
|
|
||||||
var playerSourceUrl =
|
|
||||||
'https://youtube.com${playerConfigJson['assets']['js']}';
|
|
||||||
|
|
||||||
// Get video info dictionary.
|
|
||||||
var videoInfoDic = await getVideoInfoDictionary(videoId);
|
|
||||||
|
|
||||||
var playerResponseJson = json.decode(videoInfoDic['player_response']);
|
|
||||||
var playAbility = playerResponseJson['playabilityStatus'];
|
|
||||||
|
|
||||||
if (playAbility['status'].toString().toLowerCase() == 'error') {
|
|
||||||
throw VideoUnavailableException(videoId);
|
|
||||||
}
|
|
||||||
|
|
||||||
var errorReason = playAbility['reason'] as String;
|
|
||||||
|
|
||||||
// Valid configuration
|
|
||||||
if (errorReason.isNullOrWhiteSpace) {
|
|
||||||
var videoInfo = playerResponseJson['videoDetails'];
|
|
||||||
var video = Video(
|
|
||||||
videoId,
|
|
||||||
videoInfo['author'],
|
|
||||||
null,
|
|
||||||
videoInfo['title'],
|
|
||||||
videoInfo['shortDescription'],
|
|
||||||
ThumbnailSet(videoId),
|
|
||||||
Duration(seconds: int.parse(videoInfo['lengthSeconds'])),
|
|
||||||
videoInfo['keywords']?.cast<String>() ?? const <String>[],
|
|
||||||
Statistics(int.parse(videoInfo['viewCount']), 0, 0));
|
|
||||||
|
|
||||||
// Extract if it is a live stream.
|
|
||||||
var isLiveStream = playerResponseJson['videoDetails']['isLive'] == true;
|
|
||||||
|
|
||||||
var streamingData = playerResponseJson['streamingData'];
|
|
||||||
var validUntil = DateTime.now()
|
|
||||||
.add(Duration(seconds: int.parse(streamingData['expiresInSeconds'])));
|
|
||||||
var hlsManifestUrl =
|
|
||||||
isLiveStream ? streamingData['hlsManifestUrl'] : null;
|
|
||||||
var dashManifestUrl =
|
|
||||||
isLiveStream ? null : streamingData['dashManifestUrl'];
|
|
||||||
var muxedStreamInfosUrlEncoded =
|
|
||||||
isLiveStream ? null : videoInfoDic['url_encoded_fmt_stream_map'];
|
|
||||||
var adaptiveStreamInfosUrlEncoded =
|
|
||||||
isLiveStream ? null : videoInfoDic['adaptive_fmts'];
|
|
||||||
var muxedStreamInfosJson = isLiveStream ? null : streamingData['formats'];
|
|
||||||
var adaptiveStreamInfosJson =
|
|
||||||
isLiveStream ? null : streamingData['adaptiveFormats'];
|
|
||||||
|
|
||||||
return PlayerConfiguration(
|
|
||||||
playerSourceUrl,
|
|
||||||
dashManifestUrl,
|
|
||||||
hlsManifestUrl,
|
|
||||||
muxedStreamInfosUrlEncoded,
|
|
||||||
adaptiveStreamInfosUrlEncoded,
|
|
||||||
muxedStreamInfosJson,
|
|
||||||
adaptiveStreamInfosJson,
|
|
||||||
video,
|
|
||||||
validUntil);
|
|
||||||
}
|
|
||||||
|
|
||||||
var previewVideoId = playAbility['errorScreen']
|
|
||||||
['playerLegacyDesktopYpcTrailerRenderer']['trailerVideoId'] as String;
|
|
||||||
if (!previewVideoId.isNullOrWhiteSpace) {
|
|
||||||
throw VideoRequiresPurchaseException(videoId, previewVideoId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the video requires purchase - throw (approach two)
|
|
||||||
var previewVideoInfoRaw = playAbility['errorScreen']['ypcTrailerRenderer']
|
|
||||||
['playerVars'] as String;
|
|
||||||
|
|
||||||
if (!previewVideoInfoRaw.isNullOrWhiteSpace) {
|
|
||||||
var previewVideoInfoDic = Uri.splitQueryString(previewVideoInfoRaw);
|
|
||||||
var previewVideoId = previewVideoInfoDic['video_id'];
|
|
||||||
|
|
||||||
throw VideoRequiresPurchaseException(videoId, previewVideoId);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<PlayerConfiguration> _getPlayerConfigWatchPage(String videoId) async {
|
|
||||||
var videoWatchPage = await getVideoWatchPage(videoId);
|
|
||||||
if (videoWatchPage == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
var playerConfigScript = videoWatchPage
|
|
||||||
.querySelectorAll('script')
|
|
||||||
.map((e) => e.text)
|
|
||||||
.firstWhere((e) => e.contains('ytplayer.config ='));
|
|
||||||
if (playerConfigScript == null) {
|
|
||||||
var errorReason =
|
|
||||||
videoWatchPage.querySelector('#unavailable-message').text.trim();
|
|
||||||
throw VideoUnplayableException(videoId, errorReason);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workaround: Couldn't get RegExp to work. TODO: Find working regexp
|
|
||||||
var startIndex = playerConfigScript.indexOf('ytplayer.config =');
|
|
||||||
var endIndex = playerConfigScript.indexOf(';ytplayer.load =');
|
|
||||||
|
|
||||||
var playerConfigRaw =
|
|
||||||
playerConfigScript.substring(startIndex + 17, endIndex);
|
|
||||||
var playerConfigJson = json.decode(playerConfigRaw);
|
|
||||||
|
|
||||||
var playerResponseJson =
|
|
||||||
json.decode(playerConfigJson['args']['player_response']);
|
|
||||||
var playerSourceUrl =
|
|
||||||
'https://youtube.com${playerConfigJson['assets']['js']}';
|
|
||||||
|
|
||||||
var videoInfo = playerResponseJson['videoDetails'];
|
|
||||||
var video = Video(
|
|
||||||
videoId,
|
|
||||||
videoInfo['author'],
|
|
||||||
null,
|
|
||||||
videoInfo['title'],
|
|
||||||
videoInfo['shortDescription'],
|
|
||||||
ThumbnailSet(videoId),
|
|
||||||
Duration(seconds: int.parse(videoInfo['lengthSeconds'])),
|
|
||||||
videoInfo['keywords']?.cast<String>() ?? const <String>[],
|
|
||||||
Statistics(int.parse(videoInfo['viewCount']), 0, 0));
|
|
||||||
|
|
||||||
var isLiveStream = playerResponseJson['videoDetails']['isLive'] == true;
|
|
||||||
|
|
||||||
var streamingData = playerResponseJson['streamingData'];
|
|
||||||
var validUntil = DateTime.now()
|
|
||||||
.add(Duration(seconds: int.parse(streamingData['expiresInSeconds'])));
|
|
||||||
var hlsManifestUrl = isLiveStream ? streamingData['hlsManifestUrl'] : null;
|
|
||||||
var dashManifestUrl =
|
|
||||||
isLiveStream ? null : streamingData['dashManifestUrl'];
|
|
||||||
var muxedStreamInfosUrlEncoded = isLiveStream
|
|
||||||
? null
|
|
||||||
: playerConfigJson['args']['url_encoded_fmt_stream_map'];
|
|
||||||
var adaptiveStreamInfosUrlEncoded =
|
|
||||||
isLiveStream ? null : playerConfigJson['args']['adaptive_fmts'];
|
|
||||||
var muxedStreamInfosJson = isLiveStream ? null : streamingData['formats'];
|
|
||||||
var adaptiveStreamInfosJson =
|
|
||||||
isLiveStream ? null : streamingData['adaptiveFormats'];
|
|
||||||
|
|
||||||
return PlayerConfiguration(
|
|
||||||
playerSourceUrl,
|
|
||||||
dashManifestUrl,
|
|
||||||
hlsManifestUrl,
|
|
||||||
muxedStreamInfosUrlEncoded,
|
|
||||||
adaptiveStreamInfosUrlEncoded,
|
|
||||||
muxedStreamInfosJson,
|
|
||||||
adaptiveStreamInfosJson,
|
|
||||||
video,
|
|
||||||
validUntil);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the video info dictionary for a given video.
|
|
||||||
Future<Map<String, String>> getVideoInfoDictionary(String videoId) async {
|
|
||||||
var eurl = Uri.encodeComponent('https://youtube.googleapis.com/v/$videoId');
|
|
||||||
var url = 'https://youtube.com/get_video_info?video_id=$videoId'
|
|
||||||
'&el=embedded&eurl=$eurl&hl=en';
|
|
||||||
var raw = (await client.get(url)).body;
|
|
||||||
return Uri.splitQueryString(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a [Video] instance.
|
|
||||||
/// Use this to extract general info about a video.
|
|
||||||
Future<Video> getVideo(String id) async {
|
|
||||||
var videoId = parseVideoId(id);
|
|
||||||
if (videoId == null) {
|
|
||||||
throw ArgumentError.value(id, 'videoId', 'Invalid video id');
|
|
||||||
}
|
|
||||||
|
|
||||||
var videoInfoDic = await getVideoInfoDictionary(videoId);
|
|
||||||
var playerResponseJson = json.decode(videoInfoDic['player_response']);
|
|
||||||
var status = playerResponseJson['playabilityStatus']['status']
|
|
||||||
?.toLowerCase() as String;
|
|
||||||
|
|
||||||
if (status.isNullOrWhiteSpace || status == 'error') {
|
|
||||||
throw VideoUnavailableException(videoId);
|
|
||||||
}
|
|
||||||
|
|
||||||
var details = playerResponseJson['videoDetails'];
|
|
||||||
|
|
||||||
var title = details['title'];
|
|
||||||
var author = details['author'];
|
|
||||||
var description = details['shortDescription'];
|
|
||||||
var duration = Duration(seconds: int.parse(details['lengthSeconds']));
|
|
||||||
var keyWords = details['keywords']?.cast<String>() ?? const <String>[];
|
|
||||||
var viewCount = int.tryParse(details['viewCount'] ?? '0') ?? 0;
|
|
||||||
|
|
||||||
var videoPageHtml = await getVideoWatchPage(videoId);
|
|
||||||
var uploadDate = DateTime.parse(videoPageHtml
|
|
||||||
.querySelector('meta[itemprop="datePublished"]')
|
|
||||||
.attributes['content']);
|
|
||||||
var rawLikeCount = videoPageHtml
|
|
||||||
.querySelector('.like-button-renderer-like-button')
|
|
||||||
?.text ??
|
|
||||||
'0';
|
|
||||||
var likeCount = rawLikeCount.isNullOrWhiteSpace
|
|
||||||
? 0
|
|
||||||
: int.parse(rawLikeCount.stripNonDigits);
|
|
||||||
|
|
||||||
var rawDislikeCount = videoPageHtml
|
|
||||||
.querySelector('.like-button-renderer-dislike-button')
|
|
||||||
?.text ??
|
|
||||||
'0';
|
|
||||||
var dislikeCount = rawDislikeCount.isNullOrWhiteSpace
|
|
||||||
? 0
|
|
||||||
: int.parse(rawLikeCount.stripNonDigits);
|
|
||||||
|
|
||||||
var statistics = Statistics(viewCount, likeCount, dislikeCount);
|
|
||||||
var thumbnails = ThumbnailSet(videoId);
|
|
||||||
|
|
||||||
return Video(videoId, author, uploadDate, title, description, thumbnails,
|
|
||||||
duration, keyWords, statistics);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> _parseContentLength(
|
|
||||||
String contentLengthString, String url) async {
|
|
||||||
var contentLength = int.tryParse(contentLengthString ?? '') ?? -1;
|
|
||||||
|
|
||||||
if (contentLength <= 0 && !url.isNullOrWhiteSpace) {
|
|
||||||
contentLength = _contentLenExp?.firstMatch(url)?.group(1) ?? -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentLength <= 0 && !url.isNullOrWhiteSpace) {
|
|
||||||
contentLength = await _requestContentLength(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return contentLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> _requestContentLength(String url) async {
|
|
||||||
var resp;
|
|
||||||
try {
|
|
||||||
resp = await client.head(url);
|
|
||||||
} on Exception {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (!resp.headers.containsKey('content-length')) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
String contentLengthString = resp.headers['content-length'];
|
|
||||||
return int.tryParse(contentLengthString ?? '') ?? -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the video watch page document.
|
|
||||||
Future<Document> getVideoWatchPage(String videoId) async {
|
|
||||||
var url = 'https://youtube.com/watch?v=$videoId&bpctr=9999999999&hl=en';
|
|
||||||
var req = await client.get(url);
|
|
||||||
if (req.statusCode != 200) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return html.parse(req.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the given [videoId] is valid.
|
|
||||||
static bool validateVideoId(String videoId) {
|
|
||||||
if (videoId.isNullOrWhiteSpace) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoId.length != 11) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return !RegExp(r'[^0-9a-zA-Z_\-]').hasMatch(videoId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses a video id from url or if given a valid id as url returns itself.
|
|
||||||
/// Returns null if the id couldn't be extracted.
|
|
||||||
static String parseVideoId(String url) {
|
|
||||||
if (url.isNullOrWhiteSpace) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validateVideoId(url)) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://www.youtube.com/watch?v=yIVRs6YSbOM
|
|
||||||
var regMatch = _regMatchExp.firstMatch(url)?.group(1);
|
|
||||||
if (!regMatch.isNullOrWhiteSpace && validateVideoId(regMatch)) {
|
|
||||||
return regMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://youtu.be/yIVRs6YSbOM
|
|
||||||
var shortMatch = _shortMatchExp.firstMatch(url)?.group(1);
|
|
||||||
if (!shortMatch.isNullOrWhiteSpace && validateVideoId(shortMatch)) {
|
|
||||||
return shortMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://www.youtube.com/embed/yIVRs6YSbOM
|
|
||||||
var embedMatch = _embedMatchExp.firstMatch(url)?.group(1);
|
|
||||||
if (!embedMatch.isNullOrWhiteSpace && validateVideoId(embedMatch)) {
|
|
||||||
return embedMatch;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Closes the youtube explode's http client.
|
|
||||||
void close() {
|
|
||||||
client.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Export the extension static members. */
|
|
||||||
|
|
||||||
/// Parses a playlist [url] returning its id.
|
|
||||||
/// If the [url] is a valid it is returned itself.
|
|
||||||
static String parsePlaylistId(String url) =>
|
|
||||||
PlaylistExtension.parsePlaylistId(url);
|
|
||||||
|
|
||||||
/// Returns true if [username] is a valid Youtube username.
|
|
||||||
static bool validateUsername(String username) =>
|
|
||||||
ChannelExtension.validateUsername(username);
|
|
||||||
|
|
||||||
/// Parses a username from an url.
|
|
||||||
/// Returns null if the username is not found.
|
|
||||||
static String parseUsername(String url) =>
|
|
||||||
ChannelExtension.parseUsername(url);
|
|
||||||
|
|
||||||
/// Returns true if [channelId] is a valid Youtube channel id.
|
|
||||||
static bool validateChannelId(String channelId) =>
|
|
||||||
ChannelExtension.validateChannelId(channelId);
|
|
||||||
|
|
||||||
/// Parses a channel id from an url.
|
|
||||||
/// Returns null if the username is not found.
|
|
||||||
static String parseChannelId(String url) =>
|
|
||||||
ChannelExtension.parseChannelId(url);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
library youtube_explode;
|
library youtube_explode;
|
||||||
|
|
||||||
|
export 'src/channels/channels.dart';
|
||||||
|
export 'src/common/common.dart';
|
||||||
export 'src/exceptions/exceptions.dart';
|
export 'src/exceptions/exceptions.dart';
|
||||||
export 'src/extensions/extensions.dart'
|
export 'src/playlists/playlists.dart';
|
||||||
hide StringUtility, ListDecipher, ListFirst; // Hide helper extensions.
|
export 'src/search/search_client.dart';
|
||||||
export 'src/models/models.dart';
|
export 'src/videos/videos.dart';
|
||||||
export 'src/youtube_explode_base.dart';
|
export 'src/youtube_explode_base.dart';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
name: youtube_explode_dart
|
name: youtube_explode_dart
|
||||||
description: A port in dart of the youtube explode library. Supports several API functions.
|
description: A port in dart of the youtube explode library. Supports several API functions without the need of Youtube API Key.
|
||||||
version: 0.0.16
|
version: 1.0.0
|
||||||
homepage: https://github.com/Hexer10/youtube_explode_dart
|
homepage: https://github.com/Hexer10/youtube_explode_dart
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
@ -10,7 +10,7 @@ dependencies:
|
||||||
html: ^0.14.0+3
|
html: ^0.14.0+3
|
||||||
http: ^0.12.0+4
|
http: ^0.12.0+4
|
||||||
http_parser: ^3.1.3
|
http_parser: ^3.1.3
|
||||||
xml: ^3.5.0
|
xml: '>=3.0.0 <5.0.0'
|
||||||
equatable: ^1.1.0
|
equatable: ^1.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|
|
@ -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
Loading…
Reference in New Issue