commit
3f6bb46c45
|
@ -1,3 +1,11 @@
|
|||
## 1.0.0
|
||||
- Stable release
|
||||
|
||||
## 1.1.0
|
||||
- Implement for advanced Search parsing from search page. `SearchQuery`.
|
||||
|
||||
<hr>
|
||||
|
||||
## 1.0.0-beta
|
||||
|
||||
- Updated to v5 of YouTube Explode for C#
|
||||
|
|
|
@ -6,17 +6,60 @@ 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:
|
||||
- valid_regexps
|
||||
- prefer_const_constructors
|
||||
- prefer_const_declarations
|
||||
- prefer_const_literals_to_create_immutables
|
||||
- prefer_constructors_over_static_methods
|
||||
- prefer_contains
|
||||
- annotate_overrides
|
||||
- await_only_futures
|
||||
- unawaited_futures
|
||||
rules:
|
||||
- valid_regexps
|
||||
- prefer_const_constructors
|
||||
- prefer_const_declarations
|
||||
- prefer_const_literals_to_create_immutables
|
||||
- prefer_constructors_over_static_methods
|
||||
- prefer_contains
|
||||
- annotate_overrides
|
||||
- await_only_futures
|
||||
- unawaited_futures
|
||||
- avoid_empty_else
|
||||
- avoid_returning_null_for_future
|
||||
- avoid_types_as_parameter_names
|
||||
- control_flow_in_finally
|
||||
- empty_statements
|
||||
- invariant_booleans
|
||||
- iterable_contains_unrelated_type
|
||||
- list_remove_unrelated_type
|
||||
- literal_only_boolean_expressions
|
||||
- no_adjacent_strings_in_list
|
||||
- no_duplicate_case_values
|
||||
- prefer_void_to_null
|
||||
- test_types_in_equals
|
||||
- throw_in_finally
|
||||
- unnecessary_statements
|
||||
- unrelated_type_equality_checks
|
||||
- always_declare_return_types
|
||||
- always_put_control_body_on_new_line
|
||||
- avoid_returning_null_for_void
|
||||
- avoid_setters_without_getters
|
||||
- avoid_shadowing_type_parameters
|
||||
- avoid_unnecessary_containers
|
||||
- avoid_void_async
|
||||
- empty_catches
|
||||
- null_closures
|
||||
- prefer_conditional_assignment
|
||||
- prefer_if_null_operators
|
||||
- prefer_is_empty
|
||||
- prefer_is_not_empty
|
||||
- prefer_is_not_operator
|
||||
- prefer_null_aware_operators
|
||||
- recursive_getters
|
||||
- unnecessary_await_in_return
|
||||
- unnecessary_null_aware_assignments
|
||||
- unnecessary_null_in_if_null_operators
|
||||
- unnecessary_overrides
|
||||
- unnecessary_parenthesis
|
||||
- unnecessary_raw_strings
|
||||
- unnecessary_string_escapes
|
||||
- unnecessary_string_interpolations
|
||||
- use_string_buffers
|
||||
- void_checks
|
||||
- package_names
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- example\**
|
||||
exclude:
|
||||
- example\**
|
||||
|
|
|
@ -45,7 +45,7 @@ class ChannelClient {
|
|||
var playerResponse = videoInfoResponse.playerResponse;
|
||||
|
||||
var channelId = playerResponse.videoChannelId;
|
||||
return await get(ChannelId(channelId));
|
||||
return get(ChannelId(channelId));
|
||||
}
|
||||
|
||||
/// Enumerates videos uploaded by the specified channel.
|
||||
|
|
|
@ -28,7 +28,7 @@ class ChannelId extends Equatable {
|
|||
return false;
|
||||
}
|
||||
|
||||
return !RegExp('[^0-9a-zA-Z_\-]').hasMatch(id);
|
||||
return !RegExp(r'[^0-9a-zA-Z_\-]').hasMatch(id);
|
||||
}
|
||||
|
||||
/// Parses a channel id from an url.
|
||||
|
@ -61,7 +61,7 @@ class ChannelId extends Equatable {
|
|||
}
|
||||
|
||||
@override
|
||||
String toString() => '$value';
|
||||
String toString() => value;
|
||||
|
||||
@override
|
||||
List<Object> get props => [value];
|
||||
|
|
|
@ -44,7 +44,7 @@ extension ListDecipher on Iterable<CipherOperation> {
|
|||
}
|
||||
|
||||
/// List Utility.
|
||||
extension ListFirst<E> on List<E> {
|
||||
extension ListFirst<E> on Iterable<E> {
|
||||
/// Returns the first element of a list or null if empty.
|
||||
E get firstOrNull {
|
||||
if (length == 0) {
|
||||
|
|
|
@ -32,6 +32,7 @@ class PlaylistClient {
|
|||
id = PlaylistId.fromString(id);
|
||||
var encounteredVideoIds = <String>{};
|
||||
var index = 0;
|
||||
// ignore: literal_only_boolean_expressions
|
||||
while (true) {
|
||||
var response =
|
||||
await PlaylistResponse.get(_httpClient, id.value, index: index);
|
||||
|
|
|
@ -5,7 +5,7 @@ class PlaylistId {
|
|||
static final _regMatchExp =
|
||||
RegExp(r'youtube\..+?/playlist.*?list=(.*?)(?:&|/|$)');
|
||||
static final _compositeMatchExp = RegExp(
|
||||
r'https://www.youtube.com/watch?v=b8m9zhNAgKs&list=PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr');
|
||||
'https://www.youtube.com/watch?v=b8m9zhNAgKs&list=PL9tY0BWXOZFuFEG_GtOBZ8-8wbkH-NVAr');
|
||||
static final _shortCompositeMatchExp =
|
||||
RegExp(r'youtu\.be/.*?/.*?list=(.*?)(?:&|/|$)');
|
||||
static final _embedCompositeMatchExp =
|
||||
|
|
|
@ -9,6 +9,7 @@ import 'exceptions/exceptions.dart';
|
|||
Future<T> retry<T>(FutureOr<T> function()) async {
|
||||
var retryCount = 5;
|
||||
|
||||
// ignore: literal_only_boolean_expressions
|
||||
while (true) {
|
||||
try {
|
||||
return await function();
|
||||
|
|
|
@ -0,0 +1,254 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:html/parser.dart' as parser;
|
||||
|
||||
import '../../extensions/helpers_extension.dart';
|
||||
import '../../playlists/playlist_id.dart';
|
||||
import '../../retry.dart';
|
||||
import '../../search/related_query.dart';
|
||||
import '../../search/search_playlist.dart';
|
||||
import '../../search/search_video.dart';
|
||||
import '../../videos/videos.dart';
|
||||
import '../youtube_http_client.dart';
|
||||
|
||||
class SearchPage {
|
||||
final String queryString;
|
||||
final Document _root;
|
||||
|
||||
_InitialData _initialData;
|
||||
|
||||
_InitialData get initialData =>
|
||||
_initialData ??= _InitialData(json.decode(_matchJson(_extractJson(
|
||||
_root
|
||||
.querySelectorAll('script')
|
||||
.map((e) => e.text)
|
||||
.toList()
|
||||
.firstWhere((e) => e.contains('window["ytInitialData"] =')),
|
||||
'window["ytInitialData"] ='))));
|
||||
|
||||
String _xsrfToken;
|
||||
|
||||
static final _xsfrTokenExp = RegExp('"XSRF_TOKEN":"(.+?)"');
|
||||
|
||||
String get xsfrToken => _xsrfToken ??= _xsfrTokenExp
|
||||
.firstMatch(_root
|
||||
.querySelectorAll('script')
|
||||
.firstWhere((e) => _xsfrTokenExp.hasMatch(e.text))
|
||||
.text)
|
||||
.group(1);
|
||||
|
||||
String _extractJson(String html, String separator) {
|
||||
return _matchJson(
|
||||
html.substring(html.indexOf(separator) + separator.length));
|
||||
}
|
||||
|
||||
String _matchJson(String str) {
|
||||
var bracketCount = 0;
|
||||
int lastI;
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
lastI = i;
|
||||
if (str[i] == '{') {
|
||||
bracketCount++;
|
||||
} else if (str[i] == '}') {
|
||||
bracketCount--;
|
||||
} else if (str[i] == ';') {
|
||||
if (bracketCount == 0) {
|
||||
return str.substring(0, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return str.substring(0, lastI + 1);
|
||||
}
|
||||
|
||||
SearchPage(this._root, this.queryString,
|
||||
[_InitialData initalData, String xsfrToken])
|
||||
: _initialData = initalData,
|
||||
_xsrfToken = xsfrToken;
|
||||
|
||||
Future<SearchPage> nextPage(YoutubeHttpClient httpClient) {
|
||||
if (initialData.continuation == '') {
|
||||
return null;
|
||||
}
|
||||
return get(httpClient, queryString,
|
||||
ctoken: initialData.continuation,
|
||||
itct: initialData.clickTrackingParams,
|
||||
xsrfToken: xsfrToken);
|
||||
}
|
||||
|
||||
static Future<SearchPage> get(
|
||||
YoutubeHttpClient httpClient, String queryString,
|
||||
{String ctoken, String itct, String xsrfToken}) {
|
||||
var url =
|
||||
'https://www.youtube.com/results?search_query=${Uri.encodeQueryComponent(queryString)}';
|
||||
if (ctoken != null) {
|
||||
assert(itct != null, 'If ctoken is not null itct cannot be null');
|
||||
url += '&pbj=1';
|
||||
url += '&ctoken=${Uri.encodeQueryComponent(ctoken)}';
|
||||
url += '&continuation=${Uri.encodeQueryComponent(ctoken)}';
|
||||
url += '&itct=${Uri.encodeQueryComponent(itct)}';
|
||||
}
|
||||
return retry(() async {
|
||||
Map<String, String> body;
|
||||
if (xsrfToken != null) {
|
||||
body = {'session_token': xsrfToken};
|
||||
}
|
||||
var raw = await httpClient.postString(url);
|
||||
if (ctoken != null) {
|
||||
return SearchPage(
|
||||
null, queryString, _InitialData(json.decode(raw)[1]), xsrfToken);
|
||||
}
|
||||
return SearchPage.parse(raw, queryString);
|
||||
});
|
||||
}
|
||||
|
||||
SearchPage.parse(String raw, this.queryString) : _root = parser.parse(raw);
|
||||
}
|
||||
|
||||
class _InitialData {
|
||||
// Json parsed map
|
||||
final Map<String, dynamic> _root;
|
||||
|
||||
_InitialData(this._root);
|
||||
|
||||
/* Cache results */
|
||||
|
||||
List<dynamic> _searchContent;
|
||||
List<dynamic> _relatedVideos;
|
||||
List<RelatedQuery> _relatedQueries;
|
||||
String _continuation;
|
||||
String _clickTrackingParams;
|
||||
|
||||
List<Map<String, dynamic>> getContentContext(Map<String, dynamic> root) {
|
||||
if (root['contents'] != null) {
|
||||
return _root['contents']['twoColumnSearchResultsRenderer']
|
||||
['primaryContents']['sectionListRenderer']['contents']
|
||||
.first['itemSectionRenderer']['contents']
|
||||
.cast<Map<String, dynamic>>();
|
||||
}
|
||||
if (root['response'] != null) {
|
||||
return _root['response']['continuationContents']
|
||||
['itemSectionContinuation']['contents']
|
||||
.cast<Map<String, dynamic>>();
|
||||
}
|
||||
throw Exception('Couldn\'t find the content data');
|
||||
}
|
||||
|
||||
Map<String, dynamic> getContinuationContext(Map<String, dynamic> root) {
|
||||
if (_root['contents'] != null) {
|
||||
return _root['contents']['twoColumnSearchResultsRenderer']
|
||||
['primaryContents']['sectionListRenderer']['contents']
|
||||
?.first['itemSectionRenderer']['continuations']
|
||||
?.first
|
||||
?.getValue('nextContinuationData')
|
||||
?.cast<String, dynamic>();
|
||||
}
|
||||
if (_root['response'] != null) {
|
||||
return _root['response']['continuationContents']
|
||||
['itemSectionContinuation']['continuations']
|
||||
?.first['nextContinuationData']
|
||||
?.cast<String, dynamic>();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Contains only [SearchVideo] or [SearchPlaylist]
|
||||
List<dynamic> get searchContent => _searchContent ??= getContentContext(_root)
|
||||
.map(_parseContent)
|
||||
.where((e) => e != null)
|
||||
.toList();
|
||||
|
||||
List<RelatedQuery> get relatedQueries =>
|
||||
(_relatedQueries ??= getContentContext(_root)
|
||||
?.where((e) => e.containsKey('horizontalCardListRenderer'))
|
||||
?.map((e) => e['horizontalCardListRenderer']['cards'])
|
||||
?.firstOrNull
|
||||
?.map((e) => e['searchRefinementCardRenderer'])
|
||||
?.map((e) => RelatedQuery(
|
||||
e['searchEndpoint']['searchEndpoint']['query'],
|
||||
VideoId(Uri.parse(e['thumbnail']['thumbnails'].first['url'])
|
||||
.pathSegments[1])))
|
||||
?.toList()
|
||||
?.cast<RelatedQuery>()) ??
|
||||
const [];
|
||||
|
||||
List<dynamic> get relatedVideos =>
|
||||
(_relatedVideos ??= getContentContext(_root)
|
||||
?.where((e) => e.containsKey('shelfRenderer'))
|
||||
?.map((e) =>
|
||||
e['shelfRenderer']['content']['verticalListRenderer']['items'])
|
||||
?.firstOrNull
|
||||
?.map(_parseContent)
|
||||
?.toList()) ??
|
||||
const [];
|
||||
|
||||
String get continuation => _continuation ??=
|
||||
getContinuationContext(_root)?.getValue('continuation') ?? '';
|
||||
|
||||
String get clickTrackingParams => _clickTrackingParams ??=
|
||||
getContinuationContext(_root)?.getValue('clickTrackingParams') ?? '';
|
||||
|
||||
int get estimatedResults => int.parse(_root['estimatedResults'] ?? 0);
|
||||
|
||||
dynamic _parseContent(dynamic content) {
|
||||
if (content == null) {
|
||||
return null;
|
||||
}
|
||||
if (content.containsKey('videoRenderer')) {
|
||||
Map<String, dynamic> renderer = content['videoRenderer'];
|
||||
//TODO: Add it's a live
|
||||
return SearchVideo(
|
||||
VideoId(renderer['videoId']),
|
||||
_parseRuns(renderer['title']),
|
||||
_parseRuns(renderer['ownerText']),
|
||||
_parseRuns(renderer['descriptionSnippet']),
|
||||
renderer.get('lengthText')?.getValue('simpleText') ?? '',
|
||||
int.parse(renderer['viewCountText']['simpleText']
|
||||
.toString()
|
||||
.stripNonDigits()
|
||||
.nullIfWhitespace ??
|
||||
'0'));
|
||||
}
|
||||
if (content.containsKey('radioRenderer')) {
|
||||
var renderer = content['radioRenderer'];
|
||||
|
||||
return SearchPlaylist(
|
||||
PlaylistId(renderer['playlistId']),
|
||||
renderer['title']['simpleText'],
|
||||
int.parse(_parseRuns(renderer['videoCountText'])
|
||||
.stripNonDigits()
|
||||
.nullIfWhitespace ??
|
||||
0));
|
||||
}
|
||||
// Here ignore 'horizontalCardListRenderer' & 'shelfRenderer'
|
||||
return null;
|
||||
}
|
||||
|
||||
String _parseRuns(Map<dynamic, dynamic> runs) =>
|
||||
runs?.getValue('runs')?.map((e) => e['text'])?.join() ?? '';
|
||||
}
|
||||
|
||||
// ['contents']['twoColumnSearchResultsRenderer']['primaryContents']['sectionListRenderer']['contents'].first['itemSectionRenderer']
|
||||
// ['contents'] -> @See ContentsList
|
||||
// ['continuations'] -> Data to see more
|
||||
|
||||
//ContentsList:
|
||||
// Key -> 'videoRenderer'
|
||||
// videoId --> VideoId
|
||||
// title['runs'].loop -> ['text'] -> concatenate --> "Video Title"
|
||||
// descriptionSnippet['runs'].loop -> ['text'] -> concatenate --> "Video Description snippet"
|
||||
// ownerText['runs'].first -> ['text'] --> "Video Author"
|
||||
// lengthText['simpleText'] -> Parse format H:M:S -> "Video Duration"
|
||||
// viewCountText['simpleText'] -> Strip non digit -> int.parse --> "Video View Count"
|
||||
//
|
||||
// Key -> 'radioRenderer'
|
||||
// playlistId -> PlaylistId
|
||||
// title['simpleText'] --> "Playlist Title"
|
||||
//
|
||||
// Key -> 'horizontalCardListRenderer' // Queries related to this search
|
||||
// cards --> List of Maps -> loop -> ['searchRefinementCardRenderer'].first
|
||||
// thumbnail -> ['thumbnails'].first -> ['url'] --> "Thumbnail url" -> Find video id from id.
|
||||
// searchEndpoint -> ['searchEndpoint'] -> ['query'] -> "Related query string"
|
||||
//
|
||||
// Key -> 'shelfRenderer' // Videos related to this search
|
||||
// contents -> ['verticalListRenderer']['items'] -> loop -> parseContent
|
|
@ -109,22 +109,3 @@ class _StreamInfo extends StreamInfoProvider {
|
|||
@override
|
||||
int get framerate => int.tryParse(_root['fps'] ?? '');
|
||||
}
|
||||
|
||||
extension on String {
|
||||
String get nullIfWhitespace => trim().isEmpty ? null : this;
|
||||
|
||||
bool get isNullOrWhiteSpace {
|
||||
if (this == null) {
|
||||
return true;
|
||||
}
|
||||
if (trim().isEmpty) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
String substringUntil(String separator) => substring(0, indexOf(separator));
|
||||
|
||||
String substringAfter(String separator) =>
|
||||
substring(indexOf(separator) + length);
|
||||
}
|
||||
|
|
|
@ -26,7 +26,6 @@ class WatchPage {
|
|||
bool get isVideoAvailable =>
|
||||
_root.querySelector('meta[property="og:url"]') != null;
|
||||
|
||||
//TODO: Update this to the new "parsing method" w/ regex "label"\s*:\s*"([\d,\.]+) likes"
|
||||
int get videoLikeCount => int.parse(_videoLikeExp
|
||||
.firstMatch(_root.outerHtml)
|
||||
?.group(1)
|
||||
|
@ -39,7 +38,6 @@ class WatchPage {
|
|||
?.nullIfWhitespace ??
|
||||
'0');
|
||||
|
||||
//TODO: Update this to the new "parsing method" w/ regex "label"\s*:\s*"([\d,\.]+) dislikes"
|
||||
int get videoDislikeCount => int.parse(_videoDislikeExp
|
||||
.firstMatch(_root.outerHtml)
|
||||
?.group(1)
|
||||
|
@ -52,14 +50,14 @@ class WatchPage {
|
|||
?.nullIfWhitespace ??
|
||||
'0');
|
||||
|
||||
_PlayerConfig get playerConfig => _PlayerConfig(json.decode(
|
||||
_matchJson(_extractJson(_root.getElementsByTagName('html').first.text))));
|
||||
_PlayerConfig get playerConfig =>
|
||||
_PlayerConfig(json.decode(_matchJson(_extractJson(
|
||||
_root.getElementsByTagName('html').first.text,
|
||||
'ytplayer.config = '))));
|
||||
|
||||
final String configSep = 'ytplayer.config = ';
|
||||
|
||||
String _extractJson(String html) {
|
||||
String _extractJson(String html, String separator) {
|
||||
return _matchJson(
|
||||
html.substring(html.indexOf(configSep) + configSep.length));
|
||||
html.substring(html.indexOf(separator) + separator.length));
|
||||
}
|
||||
|
||||
String _matchJson(String str) {
|
||||
|
|
|
@ -6,9 +6,12 @@ import '../videos/streams/streams.dart';
|
|||
class YoutubeHttpClient {
|
||||
final Client _httpClient = Client();
|
||||
|
||||
final Map<String, String> _userAgent = const {
|
||||
final Map<String, String> _defaultHeaders = const {
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36'
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36',
|
||||
'accept-language': 'en-US,en;q=1.0',
|
||||
'x-youtube-client-name': '1',
|
||||
'x-youtube-client-version': '2.20200609.04.02',
|
||||
};
|
||||
|
||||
/// Throws if something is wrong with the response.
|
||||
|
@ -33,17 +36,35 @@ class YoutubeHttpClient {
|
|||
}
|
||||
|
||||
Future<Response> get(dynamic url, {Map<String, String> headers}) {
|
||||
return _httpClient.get(url, headers: {...?headers, ..._userAgent});
|
||||
return _httpClient.get(url, headers: {...?headers, ..._defaultHeaders});
|
||||
}
|
||||
|
||||
Future<Response> post(dynamic url, {Map<String, String> headers}) {
|
||||
return _httpClient.post(url, headers: {...?headers, ..._defaultHeaders});
|
||||
}
|
||||
|
||||
Future<Response> head(dynamic url, {Map<String, String> headers}) {
|
||||
return _httpClient.head(url, headers: {...?headers, ..._userAgent});
|
||||
return _httpClient.head(url, headers: {...?headers, ..._defaultHeaders});
|
||||
}
|
||||
|
||||
Future<String> getString(dynamic url,
|
||||
{Map<String, String> headers, bool validate = true}) async {
|
||||
var response =
|
||||
await _httpClient.get(url, headers: {...?headers, ..._userAgent});
|
||||
await _httpClient.get(url, headers: {...?headers, ..._defaultHeaders});
|
||||
|
||||
if (validate) {
|
||||
_validateResponse(response, response.statusCode);
|
||||
}
|
||||
|
||||
return response.body;
|
||||
}
|
||||
|
||||
Future<String> postString(dynamic url,
|
||||
{Map<String, String> body,
|
||||
Map<String, String> headers,
|
||||
bool validate = true}) async {
|
||||
var response = await _httpClient.post(url,
|
||||
headers: {...?headers, ..._defaultHeaders}, body: body);
|
||||
|
||||
if (validate) {
|
||||
_validateResponse(response, response.statusCode);
|
||||
|
@ -57,7 +78,7 @@ class YoutubeHttpClient {
|
|||
var url = streamInfo.url;
|
||||
if (!streamInfo.isRateLimited()) {
|
||||
var request = Request('get', url);
|
||||
request.headers.addAll(_userAgent);
|
||||
request.headers.addAll(_defaultHeaders);
|
||||
var response = await request.send();
|
||||
if (validate) {
|
||||
_validateResponse(response, response.statusCode);
|
||||
|
@ -67,7 +88,7 @@ class YoutubeHttpClient {
|
|||
for (var i = 0; i < streamInfo.size.totalBytes; i += 9898989) {
|
||||
var request = Request('get', url);
|
||||
request.headers['range'] = 'bytes=$i-${i + 9898989}';
|
||||
request.headers.addAll(_userAgent);
|
||||
request.headers.addAll(_defaultHeaders);
|
||||
var response = await request.send();
|
||||
if (validate) {
|
||||
_validateResponse(response, response.statusCode);
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import '../videos/video_id.dart';
|
||||
|
||||
///
|
||||
class RelatedQuery {
|
||||
/// Query related to a search query.
|
||||
final String query;
|
||||
|
||||
/// Video related to a search query.
|
||||
final VideoId videoId;
|
||||
|
||||
/// Initialize a [RelatedQuery] instance.
|
||||
RelatedQuery(this.query, this.videoId);
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
library youtube_explode.search;
|
||||
|
||||
export 'related_query.dart';
|
||||
export 'search_client.dart';
|
||||
export 'search_playlist.dart';
|
||||
export 'search_query.dart';
|
||||
export 'search_video.dart';
|
|
@ -0,0 +1,16 @@
|
|||
import '../channels/channel_id.dart';
|
||||
|
||||
/// Metadata related to a search query result (channel)
|
||||
class SearchChannel {
|
||||
/// ChannelId.
|
||||
final ChannelId channelId;
|
||||
|
||||
/// Channel name.
|
||||
final String channelName;
|
||||
|
||||
/// Initialize a [SearchChannel] instance.
|
||||
SearchChannel(this.channelId, this.channelName);
|
||||
|
||||
@override
|
||||
String toString() => '(Channel) $channelName ($channelId)';
|
||||
}
|
|
@ -3,6 +3,7 @@ import '../reverse_engineering/responses/playerlist_response.dart';
|
|||
import '../reverse_engineering/youtube_http_client.dart';
|
||||
import '../videos/video.dart';
|
||||
import '../videos/video_id.dart';
|
||||
import 'search_query.dart';
|
||||
|
||||
/// YouTube search queries.
|
||||
class SearchClient {
|
||||
|
@ -46,4 +47,8 @@ class SearchClient {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queries to YouTube to get the results.
|
||||
Future<SearchQuery> queryFromPage(String searchQuery) =>
|
||||
SearchQuery.search(_httpClient, searchQuery);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import '../playlists/playlist_id.dart';
|
||||
|
||||
/// Metadata related to a search query result (playlist)
|
||||
class SearchPlaylist {
|
||||
/// PlaylistId.
|
||||
final PlaylistId playlistId;
|
||||
|
||||
/// Playlist title.
|
||||
final String playlistTitle;
|
||||
|
||||
/// Playlist video count, cannot be greater than 50.
|
||||
final int playlistVideoCount;
|
||||
|
||||
/// Initialize an instance of [SearchPlaylist]
|
||||
SearchPlaylist(this.playlistId, this.playlistTitle, this.playlistVideoCount);
|
||||
|
||||
@override
|
||||
String toString() => '(Playlist) $playlistTitle ($playlistId)';
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import '../reverse_engineering/responses/search_page.dart';
|
||||
import '../reverse_engineering/youtube_http_client.dart';
|
||||
import 'related_query.dart';
|
||||
|
||||
///
|
||||
class SearchQuery {
|
||||
final YoutubeHttpClient _httpClient;
|
||||
|
||||
/// Search query
|
||||
final String searchQuery;
|
||||
|
||||
final SearchPage _page;
|
||||
|
||||
/// Initializes a SearchQuery
|
||||
SearchQuery(this._httpClient, this.searchQuery, this._page);
|
||||
|
||||
/// Search a video.
|
||||
static Future<SearchQuery> search(
|
||||
YoutubeHttpClient httpClient, String searchQuery) async {
|
||||
var page = await SearchPage.get(httpClient, searchQuery);
|
||||
return SearchQuery(httpClient, searchQuery, page);
|
||||
}
|
||||
|
||||
/// Get the data of the next page.
|
||||
Future<SearchQuery> nextPage() async {
|
||||
var page = await _page.nextPage(_httpClient);
|
||||
if (page == null) {
|
||||
// TODO: Throw custom exception
|
||||
throw Exception('Page limit reached!');
|
||||
}
|
||||
return SearchQuery(_httpClient, searchQuery, page);
|
||||
}
|
||||
|
||||
/// Content of this search.
|
||||
/// Contains either [SearchVideo] or [SearchPlaylist]
|
||||
List<dynamic> get content => _page.initialData.searchContent;
|
||||
|
||||
/// Videos related to this search.
|
||||
/// Contains either [SearchVideo] or [SearchPlaylist]
|
||||
List<dynamic> get relatedVideos => _page.initialData.relatedVideos;
|
||||
|
||||
/// Returns the queries related to this search.
|
||||
List<RelatedQuery> get relatedQueries => _page.initialData.relatedQueries;
|
||||
|
||||
/// Returns the estimated search result count.
|
||||
int get estimatedResults => _page.initialData.estimatedResults;
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import '../videos/video_id.dart';
|
||||
|
||||
/// Metadata related to a search query result (video).
|
||||
class SearchVideo {
|
||||
/// VideoId.
|
||||
final VideoId videoId;
|
||||
|
||||
/// Video title.
|
||||
final String videoTitle;
|
||||
|
||||
/// Video author.
|
||||
final String videoAuthor;
|
||||
|
||||
/// Video description snippet. (Part of the full description if too long)
|
||||
final String videoDescriptionSnippet;
|
||||
|
||||
/// Video duration as String, HH:MM:SS
|
||||
final String videoDuration;
|
||||
|
||||
/// Video View Count
|
||||
final int videoViewCount;
|
||||
|
||||
/// Initialize a [RelatedQuery] instance.
|
||||
SearchVideo(this.videoId, this.videoTitle, this.videoAuthor,
|
||||
this.videoDescriptionSnippet, this.videoDuration, this.videoViewCount);
|
||||
|
||||
@override
|
||||
String toString() => '(Video) $videoTitle ($videoId)';
|
||||
}
|
|
@ -33,6 +33,7 @@ class ClosedCaptionClient {
|
|||
return ClosedCaptionManifest(tracks);
|
||||
}
|
||||
|
||||
///
|
||||
Future<ClosedCaptionTrack> get(ClosedCaptionTrackInfo trackInfo) async {
|
||||
var response = await ClosedCaptionTrackResponse.get(
|
||||
_httpClient, trackInfo.url.toString());
|
||||
|
|
|
@ -43,9 +43,9 @@ class StreamsClient {
|
|||
await PlayerSource.get(_httpClient, playerConfig.sourceUrl);
|
||||
var cipherOperations = playerSource.getCiperOperations();
|
||||
|
||||
var videoInfoReponse = await VideoInfoResponse.get(
|
||||
var videoInfoResponse = await VideoInfoResponse.get(
|
||||
_httpClient, videoId.toString(), playerSource.sts);
|
||||
var playerResponse = videoInfoReponse.playerResponse;
|
||||
var playerResponse = videoInfoResponse.playerResponse;
|
||||
|
||||
var previewVideoId = playerResponse.previewVideoId;
|
||||
if (!previewVideoId.isNullOrWhiteSpace) {
|
||||
|
@ -63,7 +63,7 @@ class StreamsClient {
|
|||
}
|
||||
|
||||
var streamInfoProviders = <StreamInfoProvider>[
|
||||
...videoInfoReponse.streams,
|
||||
...videoInfoResponse.streams,
|
||||
...playerResponse.streams
|
||||
];
|
||||
|
||||
|
|
|
@ -4,6 +4,6 @@ export 'src/channels/channels.dart';
|
|||
export 'src/common/common.dart';
|
||||
export 'src/exceptions/exceptions.dart';
|
||||
export 'src/playlists/playlists.dart';
|
||||
export 'src/search/search_client.dart';
|
||||
export 'src/search/search.dart';
|
||||
export 'src/videos/videos.dart';
|
||||
export 'src/youtube_explode_base.dart';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
name: youtube_explode_dart
|
||||
description: A port in dart of the youtube explode library. Supports several API functions without the need of Youtube API Key.
|
||||
version: 1.0.0
|
||||
version: 1.1.0
|
||||
homepage: https://github.com/Hexer10/youtube_explode_dart
|
||||
|
||||
environment:
|
||||
|
|
|
@ -12,11 +12,17 @@ void main() {
|
|||
yt.close();
|
||||
});
|
||||
|
||||
test('SearchYouTubeVideos', () async {
|
||||
test('SearchYouTubeVideosFromApi', () async {
|
||||
var videos = await yt.search
|
||||
.getVideosAsync('undead corporation megalomania')
|
||||
.toList();
|
||||
expect(videos, isNotEmpty);
|
||||
});
|
||||
test('SearchYouTubeVideosFromPage', () async {
|
||||
var searchQuery = await yt.search.queryFromPage('hello');
|
||||
expect(searchQuery.content, isNotEmpty);
|
||||
expect(searchQuery.relatedVideos, isNotEmpty);
|
||||
expect(searchQuery.relatedQueries, isNotEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -48,9 +48,9 @@ void main() {
|
|||
});
|
||||
|
||||
test('GetMetadataOfInvalidVideo', () async {
|
||||
expect(() async => await yt.videos.get(VideoId('qld9w0b-1ao')),
|
||||
expect(() async => yt.videos.get(VideoId('qld9w0b-1ao')),
|
||||
throwsA(const TypeMatcher<VideoUnplayableException>()));
|
||||
expect(() async => await yt.videos.get(VideoId('pb_hHv3fByo')),
|
||||
expect(() async => yt.videos.get(VideoId('pb_hHv3fByo')),
|
||||
throwsA(const TypeMatcher<VideoUnplayableException>()));
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue