Initial commit
This commit is contained in:
commit
937382358f
|
@ -0,0 +1,15 @@
|
|||
# Files and directories created by pub
|
||||
.dart_tool/
|
||||
.packages
|
||||
# Remove the following pattern if you wish to check in your lock file
|
||||
pubspec.lock
|
||||
|
||||
# Conventional directory for build outputs
|
||||
build/
|
||||
|
||||
# Directory created by dartdoc
|
||||
doc/api/
|
||||
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
|
@ -0,0 +1,3 @@
|
|||
## 0.0.1
|
||||
|
||||
- Initial version, created by Stagehand
|
|
@ -0,0 +1,4 @@
|
|||
A sample command-line application.
|
||||
|
||||
Created from templates made available by Stagehand under a BSD-style
|
||||
[license](https://github.com/dart-lang/stagehand/blob/master/LICENSE).
|
|
@ -0,0 +1,14 @@
|
|||
# Defines a default set of lint rules enforced for
|
||||
# projects at Google. For details and rationale,
|
||||
# see https://github.com/dart-lang/pedantic#enabled-lints.
|
||||
include: package:effective_dart/analysis_options.yaml
|
||||
|
||||
# For lint rules and documentation, see http://dart-lang.github.io/linter/lints.
|
||||
# Uncomment to specify additional rules.
|
||||
# linter:
|
||||
# rules:
|
||||
# - camel_case_types
|
||||
|
||||
analyzer:
|
||||
# exclude:
|
||||
# - path/to/excluded/files/**
|
|
@ -0,0 +1,15 @@
|
|||
import 'package:youtube_explode_dart/src/youtube_explode_base.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
// Parse the video id from the url.
|
||||
var id = YoutubeExplode.parseVideoId(
|
||||
'https://www.youtube.com/watch?v=bo_efYhYU2A');
|
||||
|
||||
var yt = YoutubeExplode();
|
||||
var video = await yt.getVideo(id);
|
||||
|
||||
print('Title: ${video.title}');
|
||||
|
||||
// Close the YoutubeExplode's http client.
|
||||
yt.close();
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dart_console/dart_console.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_console.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
// Initialize the YoutubeExplode instance.
|
||||
final yt = YoutubeExplode();
|
||||
|
||||
final console = Console();
|
||||
|
||||
Future<void> main() async {
|
||||
await Future.delayed(Duration(seconds: 10));
|
||||
|
||||
console.writeLine('Type the video id or url: ');
|
||||
|
||||
var url = stdin.readLineSync().trim();
|
||||
|
||||
// Get the video url.
|
||||
var id = YoutubeExplode.parseVideoId(url);
|
||||
if (id == null) {
|
||||
console.writeLine('Invalid video id or url.');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Save the video to the download directory.
|
||||
Directory('downloads').createSync();
|
||||
console.hideCursor();
|
||||
|
||||
// Download the video.
|
||||
await download(id);
|
||||
|
||||
yt.close();
|
||||
console.showCursor();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
Future<void> download(String id) async {
|
||||
// Get the video media stream.
|
||||
var mediaStream = await yt.getVideoMediaStream(id);
|
||||
|
||||
// Get the last audio track (the one with the highest bitrate).
|
||||
var audio = mediaStream.audio.last;
|
||||
|
||||
// Compose the file name removing the unallowed characters in windows.
|
||||
var fileName =
|
||||
'${mediaStream.videoDetails.title}.${audio.container.toString()}'
|
||||
.replaceAll('Container.', '')
|
||||
.replaceAll(r'\', '')
|
||||
.replaceAll('/', '')
|
||||
.replaceAll('*', '')
|
||||
.replaceAll('?', '')
|
||||
.replaceAll('"', '')
|
||||
.replaceAll('<', '')
|
||||
.replaceAll('>', '')
|
||||
.replaceAll('|', '');
|
||||
var file = File('downloads/$fileName');
|
||||
|
||||
// Create the StreamedRequest to track the download status.
|
||||
var req = http.Request('get', audio.url);
|
||||
var resp = await req.send();
|
||||
|
||||
// Open the file in appendMode.
|
||||
var output = file.openWrite(mode: FileMode.writeOnlyAppend);
|
||||
|
||||
// Track the file download status.
|
||||
var len = resp.contentLength;
|
||||
var count = 0;
|
||||
var oldProgress = -1;
|
||||
|
||||
// Create the message and set the cursor position.
|
||||
var msg = 'Downloading `${mediaStream.videoDetails.title}`: \n';
|
||||
var row = console.cursorPosition.row;
|
||||
var col = msg.length - 2;
|
||||
console.cursorPosition = Coordinate(row, 0);
|
||||
console.write(msg);
|
||||
|
||||
// Listen for data received.
|
||||
return resp.stream.listen((data) {
|
||||
count += data.length;
|
||||
var progress = ((count / len) * 100).round();
|
||||
if (progress != oldProgress) {
|
||||
console.cursorPosition = Coordinate(row, col);
|
||||
console.write('$progress%');
|
||||
oldProgress = progress;
|
||||
}
|
||||
output.add(data);
|
||||
}, onDone: () async {
|
||||
await output.close();
|
||||
}).asFuture();
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
library youtube_explode.cipher;
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../extensions.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 Exception('Could not find decipherer name.');
|
||||
}
|
||||
|
||||
var exp = RegExp(r'(?!h\.)'
|
||||
'${RegExp.escape(deciphererFuncName)}'
|
||||
r'=function\(\w+\)\{(.*?)\}');
|
||||
var decipherFuncBody = exp.firstMatch(raw)?.group(1);
|
||||
if (decipherFuncBody.isNullOrWhiteSpace) {
|
||||
throw Exception('Could not find decipherer body.');
|
||||
}
|
||||
|
||||
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(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,59 @@
|
|||
/// Base CipherOperation
|
||||
abstract class CipherOperation {
|
||||
/// Base Cipher initializer.
|
||||
const CipherOperation();
|
||||
|
||||
/// Decipher function.
|
||||
String decipher(String input);
|
||||
}
|
||||
|
||||
/// Slice Operation
|
||||
class SliceCipherOperation extends CipherOperation {
|
||||
/// Index where to perform the operation.
|
||||
final int index;
|
||||
|
||||
/// Initialize slice operation.
|
||||
const SliceCipherOperation(this.index);
|
||||
|
||||
@override
|
||||
String decipher(String input) => input.substring(index);
|
||||
|
||||
@override
|
||||
String toString() => 'Slice: [$index]';
|
||||
}
|
||||
|
||||
/// Swap Operation.
|
||||
class SwapCipherOperation extends CipherOperation {
|
||||
/// Index where to perform the operation.
|
||||
final int index;
|
||||
|
||||
/// Initialize swap operation.
|
||||
const SwapCipherOperation(this.index);
|
||||
|
||||
@override
|
||||
String decipher(String input) {
|
||||
var runes = input.runes.toList();
|
||||
var first = runes[0];
|
||||
runes[0] = runes[index];
|
||||
runes[index] = first;
|
||||
return String.fromCharCodes(runes);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'Swap: [$index]';
|
||||
}
|
||||
|
||||
/// Reverse Operation.
|
||||
class ReverseCipherOperation extends CipherOperation {
|
||||
/// Initialize reverse operation.
|
||||
const ReverseCipherOperation();
|
||||
|
||||
@override
|
||||
String decipher(String input) {
|
||||
var runes = input.runes.toList().reversed;
|
||||
return String.fromCharCodes(runes);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'Reverse';
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import 'cipher/cipher_operations.dart';
|
||||
|
||||
/// Utility for Strings.
|
||||
extension StringUtility on String {
|
||||
/// Returns true if the string is null or empty.
|
||||
bool get isNullOrWhiteSpace {
|
||||
if (this == null) {
|
||||
return true;
|
||||
}
|
||||
if (trim().isEmpty) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static final _exp = RegExp(r'\D');
|
||||
|
||||
/// Strips out all non digit characters.
|
||||
String get stripNonDigits => replaceAll(_exp, '');
|
||||
}
|
||||
|
||||
/// List decipher utility.
|
||||
extension ListDecipher on List<CipherOperation> {
|
||||
/// Apply the every CipherOperation on the [signature]
|
||||
String decipher(String signature) {
|
||||
for (var operation in this) {
|
||||
signature = operation.decipher(signature);
|
||||
}
|
||||
|
||||
return signature;
|
||||
}
|
||||
}
|
||||
|
||||
/// List Utility.
|
||||
extension ListFirst<E> on List<E> {
|
||||
/// Returns the first element of a list or null if empty.
|
||||
E get firstOrNull {
|
||||
if (length == 0) {
|
||||
return null;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
/// AudioEncoding
|
||||
enum AudioEncoding {
|
||||
/// MPEG-4 Part 3, Advanced Audio Coding (AAC).
|
||||
aac,
|
||||
|
||||
/// Vorbis.
|
||||
vorbis,
|
||||
|
||||
/// Opus.
|
||||
opus
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
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]
|
||||
const AudioStreamInfo(int tag, Uri url, Container container, int size,
|
||||
this.bitrate, this.audioEncoding)
|
||||
: super(tag, url, container, size);
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
/// Media stream container type.
|
||||
enum Container {
|
||||
/// MPEG-4 Part 14 (.mp4).
|
||||
mp4,
|
||||
|
||||
/// Web Media (.webm).
|
||||
webM,
|
||||
|
||||
/// 3rd Generation Partnership Project (.3gpp).
|
||||
tgpp
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import 'models.dart';
|
||||
|
||||
/// Metadata associated with a certain [MediaStream]
|
||||
class MediaStreamInfo {
|
||||
/// 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)';
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import 'package:meta/meta.dart';
|
||||
import 'models.dart';
|
||||
|
||||
/// Set of all available media stream infos.
|
||||
class MediaStreamInfoSet {
|
||||
/// 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);
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
library youtube_explode.models;
|
||||
|
||||
export 'audio_encoding.dart';
|
||||
export 'audio_stream_info.dart';
|
||||
export 'container.dart';
|
||||
export 'media_stream_info.dart';
|
||||
export 'media_stream_info_set.dart';
|
||||
export 'muxed_stream_info.dart';
|
||||
export 'player_configuration.dart';
|
||||
export 'playlist.dart';
|
||||
export 'playlist_type.dart';
|
||||
export 'statistics.dart';
|
||||
export 'thumbnail_set.dart';
|
||||
export 'video.dart';
|
||||
export 'video_encoding.dart';
|
||||
export 'video_quality.dart';
|
||||
export 'video_resolution.dart';
|
||||
export 'video_stream_info.dart';
|
|
@ -0,0 +1,33 @@
|
|||
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 tag,
|
||||
Uri url,
|
||||
Container container,
|
||||
int size,
|
||||
this.audioEncoding,
|
||||
this.videoEncoding,
|
||||
this.videoQualityLabel,
|
||||
this.videoQuality,
|
||||
this.videoResolution)
|
||||
: super(tag, url, container, size);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import 'models.dart';
|
||||
|
||||
/// Player configuration.
|
||||
class PlayerConfiguration {
|
||||
/// 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);
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import 'models.dart';
|
||||
|
||||
/// Information about a YouTube playlist.
|
||||
class Playlist {
|
||||
/// 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);
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
/// 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,
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
/// User activity statistics.
|
||||
class Statistics {
|
||||
/// View count.
|
||||
final int viewCount;
|
||||
|
||||
/// Like count.
|
||||
final int likeCount;
|
||||
|
||||
/// Dislike count.
|
||||
final int dislikeCount;
|
||||
|
||||
/// Initializes an instance of <see cref="Statistics"/>.
|
||||
const Statistics(this.viewCount, this.likeCount, this.dislikeCount);
|
||||
|
||||
/// Average user rating in stars (1 star to 5 stars).
|
||||
num get avgRating {
|
||||
if (likeCount + dislikeCount == 0) {
|
||||
return 0;
|
||||
}
|
||||
return 1 + 4.0 * likeCount / (likeCount + dislikeCount);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/// Set of thumbnails for a video.
|
||||
class ThumbnailSet {
|
||||
/// Video id.
|
||||
final String videoId;
|
||||
|
||||
/// Initializes an instance of [ThumbnailSet]
|
||||
const ThumbnailSet(this.videoId);
|
||||
|
||||
/// Low resolution thumbnail URL.
|
||||
String get lowResUrl => 'https://img.youtube.com/vi/$videoId/default.jpg';
|
||||
|
||||
/// Medium resolution thumbnail URL.
|
||||
String get mediumResUrl =>
|
||||
'https://img.youtube.com/vi/$videoId/mqdefault.jpg';
|
||||
|
||||
/// High resolution thumbnail URL.
|
||||
String get highResUrl => 'https://img.youtube.com/vi/$videoId/hqdefault.jpg';
|
||||
|
||||
/// Standard resolution thumbnail URL.
|
||||
/// Not always available.
|
||||
String get standardResUrl =>
|
||||
'https://img.youtube.com/vi/$videoId/sddefault.jpg';
|
||||
|
||||
/// Max resolution thumbnail URL.
|
||||
/// Not always available.
|
||||
String get maxResUrl =>
|
||||
'https://img.youtube.com/vi/$videoId/maxresdefault.jpg';
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import 'models.dart';
|
||||
|
||||
/// Information about a YouTube video.
|
||||
class Video {
|
||||
/// ID of this video.
|
||||
final String id;
|
||||
|
||||
/// Author of this video.
|
||||
final String author;
|
||||
|
||||
/// Upload date of this video.
|
||||
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);
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/// 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
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/// Video quality.
|
||||
enum VideoQuality {
|
||||
/// Low quality (144p).
|
||||
low144,
|
||||
|
||||
/// Low quality (240p).
|
||||
low240,
|
||||
|
||||
/// Medium quality (360p).
|
||||
medium360,
|
||||
|
||||
/// Medium quality (480p).
|
||||
medium480,
|
||||
|
||||
/// High quality (720p).
|
||||
high720,
|
||||
|
||||
/// High quality (1080p).
|
||||
high1080,
|
||||
|
||||
/// High quality (1440p).
|
||||
high1440,
|
||||
|
||||
/// High quality (2160p).
|
||||
high2160,
|
||||
|
||||
/// High quality (2880p).
|
||||
high2880,
|
||||
|
||||
/// High quality (3072p).
|
||||
high3072,
|
||||
|
||||
/// High quality (4320p).
|
||||
high4320
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/// Width and height of a video.
|
||||
class VideoResolution {
|
||||
/// Viewport width.
|
||||
final int width;
|
||||
|
||||
/// Viewport height.
|
||||
final int height;
|
||||
|
||||
/// Initializes an instance of [VideoResolution]
|
||||
const VideoResolution(this.width, this.height);
|
||||
|
||||
@override
|
||||
String toString() => '${width}x$height';
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
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 tag,
|
||||
Uri url,
|
||||
Container container,
|
||||
int size,
|
||||
this.bitrate,
|
||||
this.videoEncoding,
|
||||
this.videoQualityLabel,
|
||||
this.videoQuality,
|
||||
this.videoResolution,
|
||||
this.framerate)
|
||||
: super(tag, url, container, size);
|
||||
}
|
|
@ -0,0 +1,290 @@
|
|||
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,157 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'extensions.dart';
|
||||
import 'models/models.dart';
|
||||
import 'parser.dart' as parser;
|
||||
import 'youtube_explode_base.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'] * 100);
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,402 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:html/parser.dart' as html;
|
||||
import 'cipher/cipher.dart';
|
||||
import 'extensions.dart';
|
||||
import 'models/models.dart';
|
||||
import 'parser.dart' as parser;
|
||||
import 'playlist_extension.dart';
|
||||
|
||||
/// YoutubeExplode entry class.
|
||||
class YoutubeExplode {
|
||||
static final _regMatchExp = RegExp(r'youtube\..+?/watch.*?v=(.*?)(?:&|/|$)');
|
||||
static final _shortMatchExp = RegExp(r'youtu\.be/(.*?)(?:\?|&|/|$)');
|
||||
static final _embedMatchExp = RegExp(r'youtube\..+?/embed/(.*?)(?:\?|&|/|$)');
|
||||
static final _playerConfigRegexp = RegExp(
|
||||
r"yt\.setConfig\({'PLAYER_CONFIG':(.*)}\);",
|
||||
multiLine: true,
|
||||
caseSensitive: false);
|
||||
static final _contentLenRegexp = RegExp(r'clen=(\d+)');
|
||||
|
||||
/// HTTP Client.
|
||||
// Visible only for extensions.
|
||||
http.Client client;
|
||||
|
||||
/// Initialize [YoutubeExplode] class and http client.
|
||||
YoutubeExplode() : client = http.Client();
|
||||
|
||||
/// Returns a [Future] that completes with a [MediaStreamInfoSet]
|
||||
/// Use this to extract the muxed, audio and video streams from a video.
|
||||
Future<MediaStreamInfoSet> getVideoMediaStream(String videoId) async {
|
||||
if (!validateVideoId(videoId)) {
|
||||
throw ArgumentError.value(
|
||||
videoId, 'videoId', 'Invalid video id');
|
||||
}
|
||||
|
||||
var playerConfiguration = await getPlayerConfiguration(videoId);
|
||||
|
||||
var muxedStreamInfoMap = <int, MuxedStreamInfo>{};
|
||||
var audioStreamInfoMap = <int, AudioStreamInfo>{};
|
||||
var videoStreamInfoMap = <int, VideoStreamInfo>{};
|
||||
|
||||
var muxedStreamInfoDics =
|
||||
playerConfiguration.muxedStreamInfosUrlEncoded?.split(',');
|
||||
if (muxedStreamInfoDics != null) {
|
||||
// TODO: Implement muxedStreamInfoDics
|
||||
throw UnsupportedError(
|
||||
'muxedStreamInfoDics not null not implemented yet.');
|
||||
}
|
||||
|
||||
if (playerConfiguration.muxedStreamInfoJson != null) {
|
||||
for (var streamInfoJson in playerConfiguration.muxedStreamInfoJson) {
|
||||
var itag = streamInfoJson['itag'] as int;
|
||||
var urlString = streamInfoJson['url'] as String;
|
||||
Uri url;
|
||||
|
||||
if (urlString.isNullOrWhiteSpace &&
|
||||
!playerConfiguration.playerSourceUrl.isNullOrWhiteSpace) {
|
||||
var cipher = streamInfoJson['cipher'] as String;
|
||||
url = await decipherUrl(
|
||||
playerConfiguration.playerSourceUrl, cipher, client);
|
||||
}
|
||||
url ??= Uri.parse(urlString);
|
||||
|
||||
var contentLength =
|
||||
_parseContentLength(streamInfoJson['contentLength'], urlString);
|
||||
|
||||
// Extract container
|
||||
var mimeType = ContentType.parse(streamInfoJson['mimeType'] as String);
|
||||
|
||||
var container = parser.stringToContainer(mimeType.subType);
|
||||
var codecs = mimeType.parameters['codecs'].split(',');
|
||||
|
||||
// Extract audio encoding
|
||||
var audioEncoding = parser.audioEncodingFromString(codecs.last);
|
||||
|
||||
// Extract video encoding
|
||||
var videoEncoding = parser.videoEncodingFromString(codecs.first);
|
||||
|
||||
// Extract video quality from itag.
|
||||
var videoQuality = parser.videoQualityFromITag(itag);
|
||||
|
||||
// Get video quality label
|
||||
var videoQualityLabel = parser.videoQualityToLabel(videoQuality);
|
||||
|
||||
// Get video resolution
|
||||
var resolution = parser.videoQualityToResolution(videoQuality);
|
||||
|
||||
assert(url != null);
|
||||
assert(contentLength != null && contentLength != -1);
|
||||
muxedStreamInfoMap[itag] = MuxedStreamInfo(
|
||||
itag,
|
||||
url,
|
||||
container,
|
||||
contentLength,
|
||||
audioEncoding,
|
||||
videoEncoding,
|
||||
videoQualityLabel,
|
||||
videoQuality,
|
||||
resolution);
|
||||
}
|
||||
}
|
||||
|
||||
var adaptiveStreamInfoDics =
|
||||
playerConfiguration.adaptiveStreamInfosUrlEncoded?.split(',');
|
||||
if (adaptiveStreamInfoDics != null) {
|
||||
// TODO: Implement adaptiveStreamInfoDics
|
||||
throw UnsupportedError(
|
||||
'adaptiveStreamInfoDics not null not implemented yet.');
|
||||
}
|
||||
|
||||
if (playerConfiguration.adaptiveStreamInfosJson != null) {
|
||||
for (var streamInfoJson in playerConfiguration.adaptiveStreamInfosJson) {
|
||||
var itag = streamInfoJson['itag'] as int;
|
||||
var urlString = streamInfoJson['url'] as String;
|
||||
var bitrate = streamInfoJson['bitrate'] as int;
|
||||
Uri url;
|
||||
|
||||
if (urlString.isNullOrWhiteSpace &&
|
||||
!playerConfiguration.playerSourceUrl.isNullOrWhiteSpace) {
|
||||
var cipher = streamInfoJson['cipher'] as String;
|
||||
url = await decipherUrl(
|
||||
playerConfiguration.playerSourceUrl, cipher, client);
|
||||
}
|
||||
url ??= Uri.parse(urlString);
|
||||
|
||||
var contentLength =
|
||||
_parseContentLength(streamInfoJson['contentLength'], urlString);
|
||||
|
||||
// Extract container
|
||||
var mimeType = ContentType.parse(streamInfoJson['mimeType'] as String);
|
||||
|
||||
var container = parser.stringToContainer(mimeType.subType);
|
||||
var codecs = mimeType.parameters['codecs'].toLowerCase();
|
||||
|
||||
// Audio only
|
||||
if (streamInfoJson['audioSampleRate'] != null) {
|
||||
var audioEncoding = parser.audioEncodingFromString(codecs);
|
||||
audioStreamInfoMap[itag] = AudioStreamInfo(
|
||||
itag, url, container, contentLength, bitrate, audioEncoding);
|
||||
} else {
|
||||
// Video only
|
||||
var videoEncoding = codecs == 'unknown'
|
||||
? VideoEncoding.av1
|
||||
: parser.videoEncodingFromString(codecs);
|
||||
|
||||
var videoQualityLabel = streamInfoJson['qualityLabel'] as String;
|
||||
var videoQuality = parser.videoQualityFromLabel(videoQualityLabel);
|
||||
|
||||
var width = streamInfoJson['width'] as int;
|
||||
var height = streamInfoJson['height'] as int;
|
||||
var resolution = VideoResolution(width, height);
|
||||
|
||||
var framerate = streamInfoJson['fps'];
|
||||
|
||||
videoStreamInfoMap[itag] = VideoStreamInfo(
|
||||
itag,
|
||||
url,
|
||||
container,
|
||||
contentLength,
|
||||
bitrate,
|
||||
videoEncoding,
|
||||
videoQualityLabel,
|
||||
videoQuality,
|
||||
resolution,
|
||||
framerate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sortedMuxed = muxedStreamInfoMap.values.toList()
|
||||
..sort((a, b) => a.videoQuality.index.compareTo(b.videoQuality.index));
|
||||
var sortedAudio = audioStreamInfoMap.values.toList()
|
||||
..sort((a, b) => a.bitrate.compareTo(b.bitrate));
|
||||
var sortedVideo = videoStreamInfoMap.values.toList()
|
||||
..sort((a, b) => a.videoQuality.index.compareTo(b.videoQuality.index));
|
||||
return MediaStreamInfoSet(
|
||||
sortedMuxed,
|
||||
sortedAudio,
|
||||
sortedVideo,
|
||||
playerConfiguration.hlsManifestUrl,
|
||||
playerConfiguration.video,
|
||||
playerConfiguration.validUntil);
|
||||
}
|
||||
|
||||
/// Returns the player configuration for a given video.
|
||||
Future<PlayerConfiguration> getPlayerConfiguration(String videoId) async {
|
||||
var body = (await client.get(
|
||||
'https://www.youtube.com/embed/$videoId?disable_polymer=true&hl=en'))
|
||||
.body;
|
||||
var document = html.parse(body);
|
||||
var playerConfigRaw = document
|
||||
.getElementsByTagName('script')
|
||||
.map((e) => e.innerHtml)
|
||||
.map((e) => _playerConfigRegexp?.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 Exception('Video [$videoId] is unavailable');
|
||||
}
|
||||
|
||||
var errorReason = playAbility['reason'] as String;
|
||||
|
||||
// Valid configuration
|
||||
if (errorReason.isNullOrWhiteSpace) {
|
||||
// Extract if it is a live stream.
|
||||
var isLiveStream = playerResponseJson['videoDetails']['isLive'] == true;
|
||||
|
||||
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>(),
|
||||
Statistics(int.parse(videoInfo['viewCount']), 0, 0));
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
throw UnimplementedError(
|
||||
'Get from video watch page or purchase video not implemented yet');
|
||||
}
|
||||
|
||||
/// Returns the video info dictionary for a given vide.
|
||||
Future<Map<String, String>> getVideoInfoDictionary(String videoId) async {
|
||||
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';
|
||||
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 Exception('Video [$videoId] is unavailable');
|
||||
}
|
||||
|
||||
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>();
|
||||
var viewCount = int.tryParse(details['viewCount'] ?? '0') ?? 0;
|
||||
|
||||
var videoPageHtml = await _getVideoWatchPageHtml(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);
|
||||
}
|
||||
|
||||
int _parseContentLength(String contentLengthString, String url) {
|
||||
var contentLength = int.tryParse(contentLengthString) ?? -1;
|
||||
if (contentLength <= 0) {
|
||||
contentLength = _contentLenRegexp?.firstMatch(url)?.group(1) ?? -1;
|
||||
}
|
||||
|
||||
if (contentLength <= 0) {
|
||||
// TODO: Implement get request to get length.
|
||||
// print('Not implemented');
|
||||
return -1;
|
||||
}
|
||||
return contentLength;
|
||||
}
|
||||
|
||||
Future<Document> _getVideoWatchPageHtml(String videoId) async {
|
||||
var url =
|
||||
'https://youtube.com/watch?v=$videoId&disable_polymer=true&bpctr=9999999999&hl=en';
|
||||
var raw = (await client.get(url)).body;
|
||||
|
||||
return html.parse(raw);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
/// Closes the youtube explode's http client.
|
||||
void close() {
|
||||
client.close();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
library youtube_explode;
|
||||
|
||||
export 'src/models/models.dart';
|
||||
export 'src/playlist_extension.dart';
|
||||
export 'src/youtube_explode_base.dart';
|
|
@ -0,0 +1,15 @@
|
|||
name: youtube_explode_dart
|
||||
description: A port in dart of the youtube explode library.
|
||||
version: 0.0.1
|
||||
# homepage: https://www.example.com
|
||||
|
||||
environment:
|
||||
sdk: '>=2.7.0 <3.0.0'
|
||||
|
||||
dependencies:
|
||||
html: ^0.14.0+3
|
||||
http: ^0.12.0+4
|
||||
|
||||
dev_dependencies:
|
||||
effective_dart: ^1.2.1
|
||||
dart_console: ^0.5.0
|
Loading…
Reference in New Issue