commit 937382358f080930f9bae2cdae773fc05b8334fc Author: Hexah Date: Thu Feb 20 19:50:10 2020 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e28807 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a44e309 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +- Initial version, created by Stagehand diff --git a/README.md b/README.md new file mode 100644 index 0000000..af388e5 --- /dev/null +++ b/README.md @@ -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). diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..53cee87 --- /dev/null +++ b/analysis_options.yaml @@ -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/** diff --git a/example/example.dart b/example/example.dart new file mode 100644 index 0000000..b7a75c0 --- /dev/null +++ b/example/example.dart @@ -0,0 +1,15 @@ +import 'package:youtube_explode_dart/src/youtube_explode_base.dart'; + +Future 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(); +} diff --git a/example/video_download.dart b/example/video_download.dart new file mode 100644 index 0000000..b2ccffe --- /dev/null +++ b/example/video_download.dart @@ -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 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 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(); +} diff --git a/lib/src/cipher/cipher.dart b/lib/src/cipher/cipher.dart new file mode 100644 index 0000000..f56003d --- /dev/null +++ b/lib/src/cipher/cipher.dart @@ -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 = >{}; + +/// Returns a [Future] that completes with a [List] of [CipherOperation] +Future> 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 = []; + + 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 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.from(url.queryParameters); + + signature = cipherOperations.decipher(signature); + query[cipherDic['sp']] = signature; + + return url.replace(queryParameters: query); +} diff --git a/lib/src/cipher/cipher_operations.dart b/lib/src/cipher/cipher_operations.dart new file mode 100644 index 0000000..53cb6be --- /dev/null +++ b/lib/src/cipher/cipher_operations.dart @@ -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'; +} diff --git a/lib/src/extensions.dart b/lib/src/extensions.dart new file mode 100644 index 0000000..4119de0 --- /dev/null +++ b/lib/src/extensions.dart @@ -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 { + /// 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 on List { + /// Returns the first element of a list or null if empty. + E get firstOrNull { + if (length == 0) { + return null; + } + return first; + } +} diff --git a/lib/src/models/audio_encoding.dart b/lib/src/models/audio_encoding.dart new file mode 100644 index 0000000..b5dc366 --- /dev/null +++ b/lib/src/models/audio_encoding.dart @@ -0,0 +1,11 @@ +/// AudioEncoding +enum AudioEncoding { + /// MPEG-4 Part 3, Advanced Audio Coding (AAC). + aac, + + /// Vorbis. + vorbis, + + /// Opus. + opus +} diff --git a/lib/src/models/audio_stream_info.dart b/lib/src/models/audio_stream_info.dart new file mode 100644 index 0000000..89f31fa --- /dev/null +++ b/lib/src/models/audio_stream_info.dart @@ -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); +} diff --git a/lib/src/models/container.dart b/lib/src/models/container.dart new file mode 100644 index 0000000..e581c16 --- /dev/null +++ b/lib/src/models/container.dart @@ -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 +} diff --git a/lib/src/models/media_stream_info.dart b/lib/src/models/media_stream_info.dart new file mode 100644 index 0000000..1d20930 --- /dev/null +++ b/lib/src/models/media_stream_info.dart @@ -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)'; +} diff --git a/lib/src/models/media_stream_info_set.dart b/lib/src/models/media_stream_info_set.dart new file mode 100644 index 0000000..3ad188d --- /dev/null +++ b/lib/src/models/media_stream_info_set.dart @@ -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 muxed; + + /// Audio-only streams. + final List audio; + + /// Video-only streams. + final List 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); +} diff --git a/lib/src/models/models.dart b/lib/src/models/models.dart new file mode 100644 index 0000000..688b4bc --- /dev/null +++ b/lib/src/models/models.dart @@ -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'; \ No newline at end of file diff --git a/lib/src/models/muxed_stream_info.dart b/lib/src/models/muxed_stream_info.dart new file mode 100644 index 0000000..d44bb24 --- /dev/null +++ b/lib/src/models/muxed_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); +} diff --git a/lib/src/models/player_configuration.dart b/lib/src/models/player_configuration.dart new file mode 100644 index 0000000..2aae2ec --- /dev/null +++ b/lib/src/models/player_configuration.dart @@ -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 muxedStreamInfoJson; + + /// Adaptive stream info. + final List 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); +} diff --git a/lib/src/models/playlist.dart b/lib/src/models/playlist.dart new file mode 100644 index 0000000..978683e --- /dev/null +++ b/lib/src/models/playlist.dart @@ -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