From 0cfdb5e575f428212102194d80c9bf36f18d301f Mon Sep 17 00:00:00 2001 From: Hexah Date: Wed, 3 Jun 2020 13:18:37 +0200 Subject: [PATCH] Progress on v5 --- CHANGELOG.md | 6 +- lib/src/channel/channel.dart | 22 + lib/src/channel/channel_client.dart | 47 +- lib/src/channel/channel_id.dart | 53 ++ lib/src/channel/username.dart | 43 ++ .../video_requires_purchase_exception.dart | 20 +- .../video_unplayable_exception.dart | 2 +- lib/src/extensions/helpers_extension.dart | 26 +- lib/src/models/video_id.dart | 2 + lib/src/reverse_engineering/heuristics.dart | 196 +++++++ .../responses/dash_manifest.dart | 14 +- .../responses/embed_page.dart | 13 +- .../responses/player_response.dart | 11 +- .../responses/player_source.dart | 2 +- .../responses/responses.dart | 10 + .../responses/video_info_response.dart | 23 +- .../responses/watch_page.dart | 150 ++++- .../reverse_engineering.dart | 5 +- .../streams/audio_only_stream_info.dart | 32 ++ lib/src/videos/streams/audio_stream_info.dart | 16 + lib/src/videos/streams/bitrate.dart | 52 ++ lib/src/videos/streams/container.dart | 38 ++ lib/src/videos/streams/filesize.dart | 55 ++ lib/src/videos/streams/framerate.dart | 30 + lib/src/videos/streams/muxed_stream_info.dart | 64 +++ lib/src/videos/streams/stream_client.dart | 254 ++++++++ lib/src/videos/streams/stream_context.dart | 13 + lib/src/videos/streams/stream_info.dart | 36 ++ lib/src/videos/streams/stream_manifest.dart | 41 ++ lib/src/videos/streams/streams.dart | 15 + .../streams/video_only_stream_info.dart | 56 ++ lib/src/videos/streams/video_quality.dart | 36 ++ lib/src/videos/streams/video_resolution.dart | 16 + lib/src/videos/streams/video_stream_info.dart | 48 ++ lib/src/videos/video_id.dart | 69 +++ lib/src/videos/videos.dart | 2 + lib/src/youtube_explode_base.dart | 543 ------------------ pubspec.yaml | 6 +- tools/test.dart | 36 +- 39 files changed, 1471 insertions(+), 632 deletions(-) create mode 100644 lib/src/channel/channel.dart create mode 100644 lib/src/channel/channel_id.dart create mode 100644 lib/src/channel/username.dart create mode 100644 lib/src/reverse_engineering/heuristics.dart create mode 100644 lib/src/reverse_engineering/responses/responses.dart create mode 100644 lib/src/videos/streams/audio_only_stream_info.dart create mode 100644 lib/src/videos/streams/audio_stream_info.dart create mode 100644 lib/src/videos/streams/bitrate.dart create mode 100644 lib/src/videos/streams/container.dart create mode 100644 lib/src/videos/streams/filesize.dart create mode 100644 lib/src/videos/streams/framerate.dart create mode 100644 lib/src/videos/streams/muxed_stream_info.dart create mode 100644 lib/src/videos/streams/stream_client.dart create mode 100644 lib/src/videos/streams/stream_context.dart create mode 100644 lib/src/videos/streams/stream_info.dart create mode 100644 lib/src/videos/streams/stream_manifest.dart create mode 100644 lib/src/videos/streams/streams.dart create mode 100644 lib/src/videos/streams/video_only_stream_info.dart create mode 100644 lib/src/videos/streams/video_quality.dart create mode 100644 lib/src/videos/streams/video_resolution.dart create mode 100644 lib/src/videos/streams/video_stream_info.dart create mode 100644 lib/src/videos/video_id.dart create mode 100644 lib/src/videos/videos.dart delete mode 100644 lib/src/youtube_explode_base.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e3fbcff..8db7353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,4 +69,8 @@ ## 0.0.16 -- When a video is not available(403) a `VideoStreamUnavailableException` \ No newline at end of file +- When a video is not available(403) a `VideoStreamUnavailableException` + +## 0.0.17 + +- Fixed bug in #23 \ No newline at end of file diff --git a/lib/src/channel/channel.dart b/lib/src/channel/channel.dart new file mode 100644 index 0000000..19e2da9 --- /dev/null +++ b/lib/src/channel/channel.dart @@ -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)'; +} diff --git a/lib/src/channel/channel_client.dart b/lib/src/channel/channel_client.dart index 38ccd1a..896875e 100644 --- a/lib/src/channel/channel_client.dart +++ b/lib/src/channel/channel_client.dart @@ -1,3 +1,46 @@ +import '../reverse_engineering/reverse_engineering.dart'; +import '../videos/video_id.dart'; +import 'channel.dart'; +import 'channel_id.dart'; +import 'username.dart'; +import '../extensions/helpers_extension.dart'; + +/// Queries related to YouTube channels. class ChannelClient { - ChannelClient._(); -} \ No newline at end of file + final YoutubeHttpClient _httpClient; + + /// Initializes an instance of [ChannelClient] + ChannelClient(this._httpClient); + + /// Gets the metadata associated with the specified channel. + Future get(ChannelId 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. + Future getByUsername(Username username) async { + 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 getByVideo(VideoId videoId) async { + var videoInfoResponse = + await VideoInfoResponse.get(_httpClient, videoId.value); + var playerReponse = videoInfoResponse.playerResponse; + + var channelId = playerReponse.videoChannelId; + return await get(ChannelId(channelId)); + } + + /// Enumerates videos uploaded by the specified channel. + void getUploads(ChannelId id) async { + var playlist = 'UU${id.value.substringAfter('UC')}'; + //TODO: Finish this after playlist + } +} diff --git a/lib/src/channel/channel_id.dart b/lib/src/channel/channel_id.dart new file mode 100644 index 0000000..f90042c --- /dev/null +++ b/lib/src/channel/channel_id.dart @@ -0,0 +1,53 @@ +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) ?? + ArgumentError.value(value, 'value', 'Invalid 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; + } + + @override + List get props => [value]; +} diff --git a/lib/src/channel/username.dart b/lib/src/channel/username.dart new file mode 100644 index 0000000..3ab6970 --- /dev/null +++ b/lib/src/channel/username.dart @@ -0,0 +1,43 @@ +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) ?? + ArgumentError.value( + urlOrUsername, 'urlOrUsername', 'Invalid 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); + } + + 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; + } +} diff --git a/lib/src/exceptions/video_requires_purchase_exception.dart b/lib/src/exceptions/video_requires_purchase_exception.dart index 4afa685..3386854 100644 --- a/lib/src/exceptions/video_requires_purchase_exception.dart +++ b/lib/src/exceptions/video_requires_purchase_exception.dart @@ -1,24 +1,18 @@ -import '../models/models.dart'; - +import '../videos/video_id.dart'; import 'video_unplayable_exception.dart'; /// Exception thrown when the requested video requires purchase. class VideoRequiresPurchaseException implements VideoUnplayableException { /// Description message + @override final String message; /// VideoId instance final VideoId previewVideoId; - /// Initializes an instance of [VideoRequiresPurchaseException] - VideoRequiresPurchaseException(this.message, this.previewVideoId); - - /// Initializes an instance of [VideoUnplayableException] with a [VideoId] - VideoRequiresPurchaseException.unavailable(this.previewVideoId) - : message = 'Video \'$previewVideoId\' 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.'; + /// 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`.'; } diff --git a/lib/src/exceptions/video_unplayable_exception.dart b/lib/src/exceptions/video_unplayable_exception.dart index e4e688c..e3d6cbc 100644 --- a/lib/src/exceptions/video_unplayable_exception.dart +++ b/lib/src/exceptions/video_unplayable_exception.dart @@ -1,4 +1,4 @@ -import '../models/models.dart'; +import '../videos/video_id.dart'; import 'youtube_explode_exception.dart'; /// Exception thrown when the requested video is unplayable. diff --git a/lib/src/extensions/helpers_extension.dart b/lib/src/extensions/helpers_extension.dart index 56d7830..2135cf1 100644 --- a/lib/src/extensions/helpers_extension.dart +++ b/lib/src/extensions/helpers_extension.dart @@ -2,6 +2,9 @@ import '../reverse_engineering/cipher/cipher_operations.dart'; /// Utility for Strings. extension StringUtility on String { + /// Returns null if this string is whitespace. + String get nullIfWhitespace => trim().isEmpty ? null : this; + /// Returns true if the string is null or empty. bool get isNullOrWhiteSpace { if (this == null) { @@ -13,14 +16,21 @@ extension StringUtility on String { return false; } + /// Returns null if this string is a whitespace. + String substringUntil(String separator) => substring(0, indexOf(separator)); + + /// + String substringAfter(String separator) => + substring(indexOf(separator) + length); + static final _exp = RegExp(r'\D'); /// Strips out all non digit characters. - String get stripNonDigits => replaceAll(_exp, ''); + String stripNonDigits() => replaceAll(_exp, ''); } /// List decipher utility. -extension ListDecipher on List { +extension ListDecipher on Iterable { /// Apply every CipherOperation on the [signature] String decipher(String signature) { for (var operation in this) { @@ -41,3 +51,15 @@ extension ListFirst on List { 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.from(queryParameters); + + query[key] = value; + + return replace(queryParameters: query); + } +} diff --git a/lib/src/models/video_id.dart b/lib/src/models/video_id.dart index ce68303..3c9623e 100644 --- a/lib/src/models/video_id.dart +++ b/lib/src/models/video_id.dart @@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart'; import '../extensions/extensions.dart'; +/// Encapsulates a valid YouTube video ID. class VideoId extends Equatable { static final _regMatchExp = RegExp(r'youtube\..+?/watch.*?v=(.*?)(?:&|/|$)'); static final _shortMatchExp = RegExp(r'youtu\.be/(.*?)(?:\?|&|/|$)'); @@ -15,6 +16,7 @@ class VideoId extends Equatable { : value = parseVideoId(url) ?? ArgumentError('Invalid YouTube video ID or URL: $url.'); + @override String toString() => value; diff --git a/lib/src/reverse_engineering/heuristics.dart b/lib/src/reverse_engineering/heuristics.dart new file mode 100644 index 0000000..c99fd86 --- /dev/null +++ b/lib/src/reverse_engineering/heuristics.dart @@ -0,0 +1,196 @@ +import '../extensions/helpers_extension.dart'; +import '../videos/streams/video_quality.dart'; +import '../videos/streams/video_resolution.dart'; + +const _qualityMap = { + 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.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 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; + } +} diff --git a/lib/src/reverse_engineering/responses/dash_manifest.dart b/lib/src/reverse_engineering/responses/dash_manifest.dart index c013c76..5aa55d5 100644 --- a/lib/src/reverse_engineering/responses/dash_manifest.dart +++ b/lib/src/reverse_engineering/responses/dash_manifest.dart @@ -1,8 +1,8 @@ -import 'package:http_parser/http_parser.dart'; import 'package:xml/xml.dart' as xml; -import 'package:youtube_explode_dart/src/retry.dart'; -import 'package:youtube_explode_dart/src/reverse_engineering/responses/stream_info_provider.dart'; -import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart'; + +import '../../retry.dart'; +import '../reverse_engineering.dart'; +import 'stream_info_provider.dart'; class DashManifest { static final _urlSignatureExp = RegExp(r'/s/(.*?)(?:/|$)'); @@ -22,15 +22,15 @@ class DashManifest { DashManifest.parse(String raw) : _root = xml.parse(raw); - Future get(YoutubeHttpClient httpClient, String url) { + static Future get(YoutubeHttpClient httpClient, dynamic url) { retry(() async { var raw = await httpClient.getString(url); return DashManifest.parse(raw); }); } - String getSignatureFromUrl(String url) => - _urlSignatureExp.firstMatch(url).group(1); + static String getSignatureFromUrl(String url) => + _urlSignatureExp.firstMatch(url)?.group(1); } class _StreamInfo extends StreamInfoProvider { diff --git a/lib/src/reverse_engineering/responses/embed_page.dart b/lib/src/reverse_engineering/responses/embed_page.dart index 2061130..f623fd5 100644 --- a/lib/src/reverse_engineering/responses/embed_page.dart +++ b/lib/src/reverse_engineering/responses/embed_page.dart @@ -4,6 +4,7 @@ import 'package:html/dom.dart'; import 'package:html/parser.dart' as parser; import 'package:youtube_explode_dart/src/retry.dart'; import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart'; + import '../../extensions/extensions.dart'; class EmbedPage { @@ -14,17 +15,23 @@ class EmbedPage { EmbedPage(this._root); - _PlayerConfig get playerconfig => _PlayerConfig(json.decode(_playerConfigJson)); + _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); + .firstWhere((e) => !e.isNullOrWhiteSpace, orElse: () => null); EmbedPage.parse(String raw) : _root = parser.parse(raw); - Future get(YoutubeHttpClient httpClient, String videoId) { + static Future get(YoutubeHttpClient httpClient, String videoId) { var url = 'https://youtube.com/embed/$videoId?hl=en'; return retry(() async { var raw = await httpClient.getString(url); diff --git a/lib/src/reverse_engineering/responses/player_response.dart b/lib/src/reverse_engineering/responses/player_response.dart index 123450e..c5aef92 100644 --- a/lib/src/reverse_engineering/responses/player_response.dart +++ b/lib/src/reverse_engineering/responses/player_response.dart @@ -11,10 +11,6 @@ class PlayerResponse { String get playabilityStatus => _root['playabilityStatus']['status']; - // Can be null - String get getVideoPlayabilityError => - _root.get('playabilityStatus')?.get('reason'); - bool get isVideoAvailable => playabilityStatus != 'error'; bool get isVideoPlayable => playabilityStatus == 'ok'; @@ -57,6 +53,10 @@ class PlayerResponse { bool get isLive => _root['videoDetails'].get('isLive') ?? false; + // Can be null + String get hlsManifestUrl => + _root.get('streamingData')?.get('hlsManifestUrl'); + // Can be null String get dashManifestUrl => _root.get('streamingData')?.get('dashManifestUrl'); @@ -83,6 +83,9 @@ class PlayerResponse { ?.map((e) => ClosedCaptionTrack(e)) ?? const []; + String getVideoPlayabilityError() => + _root.get('playabilityStatus')?.get('reason'); + PlayerResponse.parse(String raw) : _root = json.decode(raw); } diff --git a/lib/src/reverse_engineering/responses/player_source.dart b/lib/src/reverse_engineering/responses/player_source.dart index 1e3c2cc..9999ca3 100644 --- a/lib/src/reverse_engineering/responses/player_source.dart +++ b/lib/src/reverse_engineering/responses/player_source.dart @@ -93,7 +93,7 @@ class PlayerSource { // Same as default constructor PlayerSource.parse(this._root); - Future get(YoutubeHttpClient httpClient, String url) { + static Future get(YoutubeHttpClient httpClient, String url) { return retry(() async { var raw = await httpClient.getString(url); return PlayerSource.parse(raw); diff --git a/lib/src/reverse_engineering/responses/responses.dart b/lib/src/reverse_engineering/responses/responses.dart new file mode 100644 index 0000000..b3b9274 --- /dev/null +++ b/lib/src/reverse_engineering/responses/responses.dart @@ -0,0 +1,10 @@ +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'; \ No newline at end of file diff --git a/lib/src/reverse_engineering/responses/video_info_response.dart b/lib/src/reverse_engineering/responses/video_info_response.dart index 2032d87..386ec82 100644 --- a/lib/src/reverse_engineering/responses/video_info_response.dart +++ b/lib/src/reverse_engineering/responses/video_info_response.dart @@ -1,6 +1,9 @@ import 'package:http_parser/http_parser.dart'; +import 'package:youtube_explode_dart/src/exceptions/exceptions.dart'; +import 'package:youtube_explode_dart/src/retry.dart'; import 'package:youtube_explode_dart/src/reverse_engineering/responses/player_response.dart'; import 'package:youtube_explode_dart/src/reverse_engineering/responses/stream_info_provider.dart'; +import 'package:youtube_explode_dart/src/reverse_engineering/reverse_engineering.dart'; class VideoInfoResponse { final Map _root; @@ -29,6 +32,25 @@ class VideoInfoResponse { const []; Iterable<_StreamInfo> get streams => [...muxedStreams, ...adaptiveStreams]; + + VideoInfoResponse.parse(String raw) : _root = Uri.splitQueryString(raw); + + static Future 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=$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 { @@ -72,7 +94,6 @@ class _StreamInfo extends StreamInfoProvider { bool get isAudioOnly => mimeType.type == 'audio'; @override - // TODO: implement videoQualityLabel String get videoQualityLabel => _root['quality_label']; List get _size => diff --git a/lib/src/reverse_engineering/responses/watch_page.dart b/lib/src/reverse_engineering/responses/watch_page.dart index 65cdf5d..f3a6032 100644 --- a/lib/src/reverse_engineering/responses/watch_page.dart +++ b/lib/src/reverse_engineering/responses/watch_page.dart @@ -1,14 +1,23 @@ -import 'package:html/dom.dart'; -import 'package:youtube_explode_dart/src/reverse_engineering/responses/embed_page.dart'; +import 'dart:convert'; -class VideoPage { +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 '../reverse_engineering.dart'; +import 'player_response.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; - VideoPage(this._root); + WatchPage(this._root); bool get isOk => _root.body.querySelector('#player') != null; @@ -29,36 +38,135 @@ class VideoPage { ?.stripNonDigits() ?? ''); - _PlayerConfig get playerConfig => _PlayerConfig.parse(_root.getElementsByTagName('script').map((e) => e.text).map((e) => _extractJson(e)).firstWhere((e) => e != null)); + _PlayerConfig get playerConfig => _PlayerConfig(json.decode(_root + .getElementsByTagName('script') + .map((e) => e.text) + .map(_extractJson) + .firstWhere((e) => e != null))); + + WatchPage.parse(String raw) : _root = parser.parse(raw); + + static Future 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; + }); + } String _extractJson(String str) { var startIndex = str.indexOf('ytplayer.config ='); var endIndex = str.indexOf(';ytplayer.load ='); - if (startIndex == -1 || endIndex == -1) - return null; + if (startIndex == -1 || endIndex == -1) return null; return str.substring(startIndex + 17, endIndex); } } -class _PlayerConfig { - +class _StreamInfo extends StreamInfoProvider { + final Map _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 get codecs => + mimeType.parameters['codecs'].split(',').map((e) => e.trim()); + + @override + String get videoQualityLabel => _root['quality_label']; + + List 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 { - static final _exp = RegExp(r'\D'); - /// Strips out all non digit characters. - String stripNonDigits() => replaceAll(_exp, ''); +class _PlayerConfig { + // Json parsed map + final Map _root; - String get nullIfWhitespace => trim().isEmpty ? null : this; + _PlayerConfig(this._root); - bool get isNullOrWhiteSpace { - if (this == null) { - return true; + String get sourceUrl => 'https://youtube.com${_root['assets']['js']}'; + + PlayerResponse get playerResponse => + PlayerResponse.parse(_root['args']['player_response']); + + List<_StreamInfo> get muxedStreams => + _root['args'] + .get('url_encoded_fmt_stream_map') + ?.split(',') + ?.map((e) => _StreamInfo(Uri.splitQueryString(e))) ?? + const []; + + List<_StreamInfo> get adaptiveStreams => + _root['args'] + .get('adaptive_fmts') + ?.split(',') + ?.map((e) => _StreamInfo(Uri.splitQueryString(e))) ?? + const []; + + List<_StreamInfo> get streams => [...muxedStreams, ...adaptiveStreams]; +} + +extension _GetOrNull on Map { + V get(K key) { + var v = this[key]; + if (v == null) { + return null; } - if (trim().isEmpty) { - return true; - } - return false; + return v; } } diff --git a/lib/src/reverse_engineering/reverse_engineering.dart b/lib/src/reverse_engineering/reverse_engineering.dart index 6f48309..0d34003 100644 --- a/lib/src/reverse_engineering/reverse_engineering.dart +++ b/lib/src/reverse_engineering/reverse_engineering.dart @@ -1 +1,4 @@ -export 'youtube_http_client.dart'; \ No newline at end of file +export 'cipher/cipher_operations.dart'; +export 'heuristics.dart'; +export 'responses/responses.dart'; +export 'youtube_http_client.dart'; diff --git a/lib/src/videos/streams/audio_only_stream_info.dart b/lib/src/videos/streams/audio_only_stream_info.dart new file mode 100644 index 0000000..09645d9 --- /dev/null +++ b/lib/src/videos/streams/audio_only_stream_info.dart @@ -0,0 +1,32 @@ +import 'package:youtube_explode_dart/src/videos/streams/audio_stream_info.dart'; +import 'package:youtube_explode_dart/src/videos/streams/bitrate.dart'; +import 'package:youtube_explode_dart/src/videos/streams/container.dart'; +import 'package:youtube_explode_dart/src/videos/streams/filesize.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)'; +} diff --git a/lib/src/videos/streams/audio_stream_info.dart b/lib/src/videos/streams/audio_stream_info.dart new file mode 100644 index 0000000..082d9a2 --- /dev/null +++ b/lib/src/videos/streams/audio_stream_info.dart @@ -0,0 +1,16 @@ +import 'package:youtube_explode_dart/src/videos/streams/bitrate.dart'; +import 'package:youtube_explode_dart/src/videos/streams/container.dart'; +import 'package:youtube_explode_dart/src/videos/streams/filesize.dart'; + +import 'stream_info.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); +} diff --git a/lib/src/videos/streams/bitrate.dart b/lib/src/videos/streams/bitrate.dart new file mode 100644 index 0000000..45ad534 --- /dev/null +++ b/lib/src/videos/streams/bitrate.dart @@ -0,0 +1,52 @@ +import 'package:equatable/equatable.dart'; + +/// Encapsulates bitrate. +class Bitrate extends Comparable 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) => null; + + @override + List 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()}'; +} \ No newline at end of file diff --git a/lib/src/videos/streams/container.dart b/lib/src/videos/streams/container.dart new file mode 100644 index 0000000..b52ce83 --- /dev/null +++ b/lib/src/videos/streams/container.dart @@ -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 get props => [name]; +} diff --git a/lib/src/videos/streams/filesize.dart b/lib/src/videos/streams/filesize.dart new file mode 100644 index 0000000..92ae9a6 --- /dev/null +++ b/lib/src/videos/streams/filesize.dart @@ -0,0 +1,55 @@ +import 'package:equatable/equatable.dart'; + +/// Encapsulates file size. +class FileSize extends Comparable 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 + // TODO: implement props + List get props => [totalBytes]; +} diff --git a/lib/src/videos/streams/framerate.dart b/lib/src/videos/streams/framerate.dart new file mode 100644 index 0000000..0a4d033 --- /dev/null +++ b/lib/src/videos/streams/framerate.dart @@ -0,0 +1,30 @@ +import 'package:equatable/equatable.dart'; + +/// Encapsulates framerate. +class Framerate extends Comparable with EquatableMixin { + /// Framerate as frames per second + final double 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 get props => [framesPerSecond]; + + @override + int compareTo(Framerate other) => + framesPerSecond.compareTo(other.framesPerSecond); +} + +void t() { + var t = Framerate(1.1) > Framerate(2.2); +} diff --git a/lib/src/videos/streams/muxed_stream_info.dart b/lib/src/videos/streams/muxed_stream_info.dart new file mode 100644 index 0000000..a6190e1 --- /dev/null +++ b/lib/src/videos/streams/muxed_stream_info.dart @@ -0,0 +1,64 @@ +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 { + 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'; +} diff --git a/lib/src/videos/streams/stream_client.dart b/lib/src/videos/streams/stream_client.dart new file mode 100644 index 0000000..0701ee3 --- /dev/null +++ b/lib/src/videos/streams/stream_client.dart @@ -0,0 +1,254 @@ +import 'package:youtube_explode_dart/src/videos/streams/audio_only_stream_info.dart'; +import 'package:youtube_explode_dart/src/videos/streams/muxed_stream_info.dart'; +import 'package:youtube_explode_dart/src/videos/streams/video_only_stream_info.dart'; +import 'package:youtube_explode_dart/src/videos/streams/video_resolution.dart'; + +import '../../exceptions/exceptions.dart'; +import '../../extensions/helpers_extension.dart'; +import '../../reverse_engineering/reverse_engineering.dart'; +import '../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'; + +/// Queries related to media streams of YouTube videos. +class StreamClient { + final YoutubeHttpClient _httpClient; + + /// Initializes an instance of [StreamsClient] + StreamClient._(this._httpClient); + + Future _getDashManifest( + Uri dashManifestUrl, Iterable 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 _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()); + } + + var streamInfoProviders = [ + ...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 _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 = [ + ...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 _getManifest(StreamContext streamContext) async { + // To make sure there are no duplicates streams, group them by tag + var streams = {}; + + for (var streamInfo in streamContext.streamInfoProviders) { + var tag = streamInfo.tag; + var url = Uri.parse(streamInfo.url); + + // Signature + var signature = streamInfo.signature; + var signatureParameters = streamInfo.signatureParameter; + + if (!signature.isNullOrWhiteSpace) { + signature = streamContext.cipherOperations.decipher(signature); + url = url.setQueryParam(signatureParameters, 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 getManifest(VideoId videoId) async { + // 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 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; + } + + + //TODO: Test this + /// Gets the actual stream which is identified by the specified metadata. + Stream> get(StreamInfo streamInfo) { + return _httpClient.getStream(streamInfo.url); + } + + //TODO: Implement CopyToAsync + + //TODO: Implement DownloadAsync +} diff --git a/lib/src/videos/streams/stream_context.dart b/lib/src/videos/streams/stream_context.dart new file mode 100644 index 0000000..48baecc --- /dev/null +++ b/lib/src/videos/streams/stream_context.dart @@ -0,0 +1,13 @@ +import '../../reverse_engineering/reverse_engineering.dart'; + +/// +class StreamContext { + /// + final Iterable streamInfoProviders; + + /// + final Iterable cipherOperations; + + /// + StreamContext(this.streamInfoProviders, this.cipherOperations); +} diff --git a/lib/src/videos/streams/stream_info.dart b/lib/src/videos/streams/stream_info.dart new file mode 100644 index 0000000..8d3abeb --- /dev/null +++ b/lib/src/videos/streams/stream_info.dart @@ -0,0 +1,36 @@ +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; + + /// + StreamInfo(this.tag, this.url, this.container, this.size, this.bitrate); +} + +/// Extensions for [StreamInfo] +extension StreamInfoExt on StreamInfo { + static final _exp = RegExp('ratebypass[=/]yes'); + + bool _isRateLimited() => _exp.hasMatch(url.toString()); + + /// Gets the stream with highest bitrate. + static StreamInfo getHighestBitrate(List streams) => + (streams..sort((a, b) => a.bitrate.compareTo(b.bitrate))).last; +} diff --git a/lib/src/videos/streams/stream_manifest.dart b/lib/src/videos/streams/stream_manifest.dart new file mode 100644 index 0000000..f869ca8 --- /dev/null +++ b/lib/src/videos/streams/stream_manifest.dart @@ -0,0 +1,41 @@ +import 'dart:collection'; + +import 'package:youtube_explode_dart/src/videos/streams/audio_only_stream_info.dart'; +import 'package:youtube_explode_dart/src/videos/streams/muxed_stream_info.dart'; +import 'package:youtube_explode_dart/src/videos/streams/video_only_stream_info.dart'; +import 'package:youtube_explode_dart/src/videos/streams/video_stream_info.dart'; + +import 'audio_stream_info.dart'; +import 'stream_info.dart'; + +/// Manifest that contains information about available media streams +/// in a specific video. +class StreamManifest { + /// Available streams. + final UnmodifiableListView streams; + + /// Initializes an instance of [StreamManifest] + StreamManifest(Iterable streams) + : streams = UnmodifiableListView(streams); + + /// Gets streams that contain audio + /// (which includes muxed and audio-only streams). + Iterable getAudio() => streams.whereType(); + + /// Gets streams that contain video + /// (which includes muxed and video-only streams). + Iterable getVideo() => streams.whereType(); + + /// Gets muxed streams (contain both audio and video). + /// Note that muxed streams are limited in quality and don't go beyond 720p30. + Iterable getMuxed() => streams.whereType(); + + /// Gets audio-only streams (no video). + Iterable getAudioOnly() => + streams.whereType(); + + /// Gets video-only streams (no audio). + /// These streams have the widest range of qualities available. + Iterable getVideoOnly() => + streams.whereType(); +} diff --git a/lib/src/videos/streams/streams.dart b/lib/src/videos/streams/streams.dart new file mode 100644 index 0000000..33cd1b6 --- /dev/null +++ b/lib/src/videos/streams/streams.dart @@ -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_client.dart'; +export 'stream_context.dart'; +export 'stream_info.dart'; +export 'stream_manifest.dart'; +export 'video_only_stream_info.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/videos/streams/video_only_stream_info.dart b/lib/src/videos/streams/video_only_stream_info.dart new file mode 100644 index 0000000..4cc8a32 --- /dev/null +++ b/lib/src/videos/streams/video_only_stream_info.dart @@ -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'; +} diff --git a/lib/src/videos/streams/video_quality.dart b/lib/src/videos/streams/video_quality.dart new file mode 100644 index 0000000..0d93932 --- /dev/null +++ b/lib/src/videos/streams/video_quality.dart @@ -0,0 +1,36 @@ + +/// 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 +} diff --git a/lib/src/videos/streams/video_resolution.dart b/lib/src/videos/streams/video_resolution.dart new file mode 100644 index 0000000..150883d --- /dev/null +++ b/lib/src/videos/streams/video_resolution.dart @@ -0,0 +1,16 @@ +import 'package:equatable/equatable.dart'; + +/// 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'; +} diff --git a/lib/src/videos/streams/video_stream_info.dart b/lib/src/videos/streams/video_stream_info.dart new file mode 100644 index 0000000..052eba6 --- /dev/null +++ b/lib/src/videos/streams/video_stream_info.dart @@ -0,0 +1,48 @@ +import 'package:youtube_explode_dart/src/videos/streams/bitrate.dart'; +import 'package:youtube_explode_dart/src/videos/streams/container.dart'; +import 'package:youtube_explode_dart/src/videos/streams/filesize.dart'; + +import 'framerate.dart'; +import 'stream_info.dart'; +import 'video_quality.dart'; +import 'video_resolution.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); +} + +// TODO: Implement VideoStreamExtension +// https://github.com/Tyrrrz/YoutubeExplode/blob/136b72bf8ca00fea7d6a686694dd91a485ca2c83/YoutubeExplode/Videos/Streams/IVideoStreamInfo.cs#L37-L60 +/* +/// Extensions for [VideoStreamInfo[ +extension VideoStreamInfoExtension on VideoStreamInfo { + +}*/ diff --git a/lib/src/videos/video_id.dart b/lib/src/videos/video_id.dart new file mode 100644 index 0000000..db40092 --- /dev/null +++ b/lib/src/videos/video_id.dart @@ -0,0 +1,69 @@ +import 'package:equatable/equatable.dart'; + +import '../extensions/extensions.dart'; + +/// Encapsulates a valid YouTube video ID. +class VideoId extends Equatable { + static final _regMatchExp = RegExp(r'youtube\..+?/watch.*?v=(.*?)(?:&|/|$)'); + static final _shortMatchExp = RegExp(r'youtu\.be/(.*?)(?:\?|&|/|$)'); + static final _embedMatchExp = RegExp(r'youtube\..+?/embed/(.*?)(?:\?|&|/|$)'); + + /// ID as string. + final String value; + + /// Initializes an instance of [VideoId] with a url or video id. + VideoId(String urlOrUrl) + : value = parseVideoId(urlOrUrl) ?? + ArgumentError.value( + urlOrUrl, 'urlOrUrl', 'Invalid YouTube video ID or URL.'); + + @override + String toString() => value; + + @override + List 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; + } +} diff --git a/lib/src/videos/videos.dart b/lib/src/videos/videos.dart new file mode 100644 index 0000000..1327ffe --- /dev/null +++ b/lib/src/videos/videos.dart @@ -0,0 +1,2 @@ +export 'streams/streams.dart'; +export 'video_id.dart'; diff --git a/lib/src/youtube_explode_base.dart b/lib/src/youtube_explode_base.dart deleted file mode 100644 index 70f4c5d..0000000 --- a/lib/src/youtube_explode_base.dart +++ /dev/null @@ -1,543 +0,0 @@ -import 'dart:convert'; - -import 'package:html/dom.dart'; -import 'package:html/parser.dart' as html; -import 'package:http/http.dart' as http; -import 'package:http_parser/http_parser.dart' show MediaType; - -import 'cipher/cipher.dart'; -import 'exceptions/exceptions.dart'; -import 'extensions/extensions.dart'; -import 'models/models.dart'; -import 'parser.dart' as parser; - -import 'channel/channel_client.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 _playerConfigExp = RegExp( - r"yt\.setConfig\({'PLAYER_CONFIG':(.*)}\);", - multiLine: true, - caseSensitive: false); - static final _contentLenExp = RegExp(r'clen=(\d+)'); - - /// HTTP Client. - // Visible only for extensions. - final 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 getVideoMediaStream(String videoId) async { - if (!validateVideoId(videoId)) { - throw ArgumentError.value(videoId, 'videoId', 'Invalid video id'); - } - - var playerConfiguration = await getPlayerConfiguration(videoId); - - var muxedStreamInfoMap = {}; - var audioStreamInfoMap = {}; - var videoStreamInfoMap = {}; - - 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 = 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['cipher'] 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. - Future getPlayerConfiguration(String videoId) async { - var playerConfiguration = await _getPlayerConfigEmbed(videoId); - - // If still null try from the watch page. - playerConfiguration ??= await _getPlayerConfigWatchPage(videoId); - - if (playerConfiguration == null) { - throw VideoUnavailableException(videoId); - } - return playerConfiguration; - } - - Future _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() ?? const [], - 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 _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() ?? const [], - 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> 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