Rework of `SearchClient`, introduces breaking changes.

Fix #197.
This commit is contained in:
Mattia 2022-04-13 23:19:09 +02:00
parent 0ce24bf017
commit e231b595a8
19 changed files with 182 additions and 169 deletions

View File

@ -1,3 +1,13 @@
## 1.11.0
- BREAKING CHANGE: Removed `SearchClient.getVideosFromPage`, use `SearchClient.search` or `SearchClient.search.search`.
- BREAKING CHANGE: `SearchClient.search` now returns `VideoSearchList` (List<Video>).
- BREAKING CHANGE: Remove the `filter` variable, now use `SearchFilter`.
- To get the filters use static access on `FeatureFilters`, `UploadDateFilter`, `TypesFilter`, `DurationFilters`, `SortFilters`.
- Introduced `SearchClient.searchContent` to search for videos, channels and playlists.
- Introduced `SearchClient.searchRaw` to manually parse the content and also get related videos and estimated results.
- Fix #197: Fixed `withHighestBitrate()`.
- Introduced: `List<VideoStreamInfo>.bestQuality`.
## 1.10.10+2
- Fix #194: Now closed-captions allow malformed utf8 as well.

View File

@ -5,7 +5,6 @@ import '../../../youtube_explode_dart.dart';
import '../../extensions/helpers_extension.dart';
import '../../retry.dart';
import '../../search/base_search_content.dart';
import '../../search/search_channel.dart';
import '../models/initial_data.dart';
import '../models/youtube_page.dart';

View File

@ -1,2 +1,6 @@
/// This can either be a [SearchVideo] or [SearchPlaylist]
import 'search_channel.dart';
import 'search_playlist.dart';
import 'search_video.dart';
/// This can either be a [SearchVideo], [SearchPlaylist], [SearchChannel]
mixin BaseSearchContent {}

View File

@ -3,6 +3,7 @@
/// {@category Search}
library youtube_explode.search;
export 'search_channel.dart';
export 'search_client.dart';
export 'search_filter.dart';
export 'search_list.dart';

View File

@ -2,9 +2,7 @@ import 'dart:convert';
import '../../youtube_explode_dart.dart';
import '../extensions/helpers_extension.dart';
import '../retry.dart';
import '../reverse_engineering/pages/search_page.dart';
import 'base_search_content.dart';
/// YouTube search queries.
class SearchClient {
@ -16,12 +14,12 @@ class SearchClient {
/// Enumerates videos returned by the specified search query
/// (from the video search page).
/// The videos are sent in batch of 20 videos.
/// You [SearchList.nextPage] to get the next batch of videos.
Future<SearchList> getVideos(String searchQuery,
{SearchFilter filter = const SearchFilter('')}) async {
/// You [VideoSearchList.nextPage] to get the next batch of videos.
Future<VideoSearchList> search(String searchQuery,
{SearchFilter filter = TypeFilters.video}) async {
final page = await SearchPage.get(_httpClient, searchQuery, filter: filter);
return SearchList(
return VideoSearchList(
page.searchContent
.whereType<SearchVideo>()
.map((e) => Video(
@ -42,38 +40,31 @@ class SearchClient {
_httpClient);
}
/// Enumerates videos returned by the specified search query
/// (from the video search page).
/// Contains only instances of [SearchVideo] or [SearchPlaylist]
@Deprecated(
'Since version 1.9.0 this is the same as [SearchClient.getVideos].')
Stream<BaseSearchContent> getVideosFromPage(String searchQuery,
{bool onlyVideos = true,
SearchFilter filter = const SearchFilter('')}) async* {
SearchPage? page;
// ignore: literal_only_boolean_expressions
for (;;) {
if (page == null) {
page = await retry(
_httpClient,
() async =>
SearchPage.get(_httpClient, searchQuery, filter: filter));
} else {
page = await page.nextPage(_httpClient);
if (page == null) {
return;
}
}
@Deprecated('Use SearchClient.search')
Future<VideoSearchList> getVideos(String searchQuery,
{SearchFilter filter = TypeFilters.video}) =>
search(searchQuery, filter: filter);
if (onlyVideos) {
yield* Stream.fromIterable(
page!.searchContent.whereType<SearchVideo>());
} else {
yield* Stream.fromIterable(page!.searchContent);
}
}
/// Enumerates results returned by the specified search query.
/// The contents are sent in batch of 20 elements.
/// The list can either contain a [SearchVideo], [SearchPlaylist] or a [SearchChannel].
/// You [SearchList.nextPage] to get the next batch of content.
Future<SearchList> searchContent(String searchQuery,
{SearchFilter filter = const SearchFilter('')}) async {
final page = await SearchPage.get(_httpClient, searchQuery, filter: filter);
return SearchList(page.searchContent, page, _httpClient);
}
/// Enumerates results returned by the specified search query.
/// The contents are sent in batch of 20 elements.
/// The list can either contain a [SearchVideo], [SearchPlaylist] or a [SearchChannel].
/// You [SearchList.nextPage] to get the next batch of content.
/// Same as [SearchClient.search]
Future<VideoSearchList> call(String searchQuery,
{SearchFilter filter = const SearchFilter('')}) async =>
search(searchQuery, filter: filter);
/// Returns the suggestions youtube provide while search on the page.
Future<List<String>> getQuerySuggestions(String query) async {
final request = await _httpClient.get(
@ -87,24 +78,14 @@ class SearchClient {
}
/// Queries to YouTube to get the results.
@Deprecated('Use getVideosFromPage instead - '
'Should be used only to get related videos')
Future<SearchQuery> queryFromPage(String searchQuery) =>
SearchQuery.search(_httpClient, searchQuery);
/// You need to manually read [SearchQuery.content] and/or [SearchQuery.relatedVideos].
/// For most cases [SearchClient.search] is enough.
Future<SearchQuery> searchRaw(String searchQuery,
{SearchFilter filter = const SearchFilter('')}) =>
SearchQuery.search(_httpClient, searchQuery, filter: filter);
@Deprecated('Use searchRaw')
Future<SearchQuery> queryFromPage(String searchQuery,
{SearchFilter filter = const SearchFilter('')}) =>
searchRaw(searchQuery, filter: filter);
}
/*
channelId = ChannelId.fromString(channelId);
var page = await ChannelUploadPage.get(
_httpClient, channelId.value, videoSorting.code);
yield* Stream.fromIterable(page.initialData.uploads);
// ignore: literal_only_boolean_expressions
while (true) {
page = await page.nextPage(_httpClient);
if (page == null) {
return;
}
yield* Stream.fromIterable(page.initialData.uploads);
}
*/

View File

@ -1,5 +1,3 @@
import 'search_client.dart';
class SearchFilter {
/// The value fo the 'sp' argument.
final String value;
@ -7,126 +5,103 @@ class SearchFilter {
const SearchFilter(this.value);
}
/// Video filters to be used with [SearchClient.getVideos]
class Filters {
const Filters._();
/// Features filters.
FeatureFilters get features => const FeatureFilters._();
/// Upload date filters.
UploadDateFilter get uploadDate => const UploadDateFilter._();
/// Types filters.
TypeFilters get types => const TypeFilters._();
/// Duration filters.
DurationFilters get duration => const DurationFilters._();
/// Videos sorting.
SortFilters get sort => const SortFilters._();
}
/// Video filters to be used with [SearchClient.getVideos]
const filters = Filters._();
class FeatureFilters {
const FeatureFilters._();
/// Live video.
SearchFilter get live => const SearchFilter('EgJAAQ%253D%253D');
static const SearchFilter live = SearchFilter('EgJAAQ%253D%253D');
/// 4K video.
SearchFilter get v4k => const SearchFilter('EgJwAQ%253D%253D');
static const SearchFilter v4k = SearchFilter('EgJwAQ%253D%253D');
/// HD video.
SearchFilter get hd => const SearchFilter('EgIgAQ%253D%253D');
static const SearchFilter hd = SearchFilter('EgIgAQ%253D%253D');
/// Subtitled video.
SearchFilter get subTitles => const SearchFilter('EgIoAQ%253D%253D');
static const SearchFilter subTitles = SearchFilter('EgIoAQ%253D%253D');
/// Creative comments video.
SearchFilter get creativeCommons => const SearchFilter('EgIwAQ%253D%253D');
static const SearchFilter creativeCommons = SearchFilter('EgIwAQ%253D%253D');
/// 360° video.
SearchFilter get v360 => const SearchFilter('EgJ4AQ%253D%253D');
static const SearchFilter v360 = SearchFilter('EgJ4AQ%253D%253D');
/// VR 180° video.
SearchFilter get vr180 => const SearchFilter('EgPQAQE%253D');
static const SearchFilter vr180 = SearchFilter('EgPQAQE%253D');
/// 3D video.
SearchFilter get v3D => const SearchFilter('EgI4AQ%253D%253D');
static const SearchFilter v3D = SearchFilter('EgI4AQ%253D%253D');
/// HDR video.
SearchFilter get hdr => const SearchFilter('EgPIAQE%253D');
static const SearchFilter hdr = SearchFilter('EgPIAQE%253D');
/// Video with location.
SearchFilter get location => const SearchFilter('EgO4AQE%253D');
static const SearchFilter location = SearchFilter('EgO4AQE%253D');
/// Purchased video.
SearchFilter get purchased => const SearchFilter('EgJIAQ%253D%253D');
static const SearchFilter purchased = SearchFilter('EgJIAQ%253D%253D');
}
class UploadDateFilter {
const UploadDateFilter._();
/// Videos uploaded in the last hour.
SearchFilter get lastHour => const SearchFilter('EgIIAQ%253D%253D');
static const SearchFilter lastHour = SearchFilter('EgIIAQ%253D%253D');
/// Videos uploaded today.
SearchFilter get today => const SearchFilter('EgIIAg%253D%253D');
static const SearchFilter today = SearchFilter('EgIIAg%253D%253D');
/// Videos uploaded in the last week.
SearchFilter get lastWeek => const SearchFilter('EgIIAw%253D%253D');
static const SearchFilter lastWeek = SearchFilter('EgIIAw%253D%253D');
/// Videos uploaded in the last month.
SearchFilter get lastMonth => const SearchFilter('EgIIBA%253D%253D');
static const SearchFilter lastMonth = SearchFilter('EgIIBA%253D%253D');
/// Videos uploaded in the last year.
SearchFilter get lastYear => const SearchFilter('EgIIBQ%253D%253D');
static const SearchFilter lastYear = SearchFilter('EgIIBQ%253D%253D');
}
class TypeFilters {
const TypeFilters._();
/// Videos.
SearchFilter get video => const SearchFilter('EgIQAQ%253D%253D');
static const SearchFilter video = SearchFilter('EgIQAQ%253D%253D');
/// Channels.
SearchFilter get channel => const SearchFilter('EgIQAg%253D%253D');
static const SearchFilter channel = SearchFilter('EgIQAg%253D%253D');
/// Playlists.
SearchFilter get playlist => const SearchFilter('EgIQAw%253D%253D');
static const SearchFilter playlist = SearchFilter('EgIQAw%253D%253D');
/// Movies.
SearchFilter get movie => const SearchFilter('EgIQBA%253D%253D');
static const SearchFilter movie = SearchFilter('EgIQBA%253D%253D');
/// Shows.
SearchFilter get show => const SearchFilter('EgIQBQ%253D%253D');
static const SearchFilter show = SearchFilter('EgIQBQ%253D%253D');
}
class DurationFilters {
const DurationFilters._();
/// Short videos, < 4 minutes.
SearchFilter get short => const SearchFilter('EgIYAQ%253D%253D');
static const SearchFilter short = SearchFilter('EgIYAQ%253D%253D');
/// Long videos, > 20 minutes.
SearchFilter get long => const SearchFilter('EgIYAg%253D%253D');
static const SearchFilter long = SearchFilter('EgIYAg%253D%253D');
}
class SortFilters {
const SortFilters._();
/// Sort by relevance (default).
SearchFilter get relevance => const SearchFilter('CAASAhAB');
static const SearchFilter relevance = SearchFilter('CAASAhAB');
/// Sort by upload date (default).
SearchFilter get uploadDate => const SearchFilter('CAI%253D');
static const SearchFilter uploadDate = SearchFilter('CAI%253D');
/// Sort by view count (default).
SearchFilter get viewCount => const SearchFilter('CAM%253D');
static const SearchFilter viewCount = SearchFilter('CAM%253D');
/// Sort by rating (default).
SearchFilter get rating => const SearchFilter('CAE%253D');
static const SearchFilter rating = SearchFilter('CAE%253D');
}

View File

@ -5,16 +5,17 @@ import 'package:collection/collection.dart';
import '../../youtube_explode_dart.dart';
import '../extensions/helpers_extension.dart';
import '../reverse_engineering/pages/search_page.dart';
import 'base_search_content.dart';
/// This list contains search videos.
///This behaves like a [List] but has the [SearchList.nextPage] to get the next batch of videos.
class SearchList extends DelegatingList<Video> {
class SearchList<T extends BaseSearchContent> extends DelegatingList<T> {
final SearchPage _page;
final YoutubeHttpClient _httpClient;
/// Construct an instance of [SearchList]
/// See [SearchList]
SearchList(List<Video> base, this._page, this._httpClient) : super(base);
SearchList(List<T> base, this._page, this._httpClient) : super(base);
/// Fetches the next batch of videos or returns null if there are no more
/// results.
@ -23,7 +24,28 @@ class SearchList extends DelegatingList<Video> {
if (page == null) {
return null;
}
return SearchList(
return SearchList<T>(page.searchContent as List<T>, page, _httpClient);
}
}
class VideoSearchList extends DelegatingList<Video> {
final SearchPage _page;
final YoutubeHttpClient _httpClient;
/// Construct an instance of [SearchList]
/// See [SearchList]
VideoSearchList(List<Video> base, this._page, this._httpClient) : super(base);
/// Fetches the next batch of videos or returns null if there are no more
/// results.
Future<VideoSearchList?> nextPage() async {
final page = await _page.nextPage(_httpClient);
if (page == null) {
return null;
}
return VideoSearchList(
page.searchContent
.whereType<SearchVideo>()
.map((e) => Video(

View File

@ -1,5 +1,5 @@
import '../../youtube_explode_dart.dart';
import '../reverse_engineering/pages/search_page.dart';
import '../reverse_engineering/youtube_http_client.dart';
///
class SearchQuery {
@ -15,8 +15,9 @@ class SearchQuery {
/// Search a video.
static Future<SearchQuery> search(
YoutubeHttpClient httpClient, String searchQuery) async {
var page = await SearchPage.get(httpClient, searchQuery);
YoutubeHttpClient httpClient, String searchQuery,
{SearchFilter filter = const SearchFilter('')}) async {
var page = await SearchPage.get(httpClient, searchQuery, filter: filter);
return SearchQuery(httpClient, searchQuery, page);
}
@ -31,7 +32,7 @@ class SearchQuery {
}
/// Content of this search.
/// Contains either [SearchVideo] or [SearchPlaylist]
/// Contains either [SearchVideo], [SearchPlaylist] or [SearchChannel]
List<dynamic> get content => _page.searchContent;
/// Videos related to this search.

View File

@ -29,7 +29,7 @@ class AudioOnlyStreamInfo with StreamInfo, AudioStreamInfo {
final String audioCodec;
@override
@JsonKey(toJson: mediaTypeTojson, fromJson: mediaTypeFromJson)
@JsonKey(toJson: mediaTypeToJson, fromJson: mediaTypeFromJson)
final MediaType codec;
@override

View File

@ -30,7 +30,7 @@ Map<String, dynamic> _$AudioOnlyStreamInfoToJson(
'size': instance.size,
'bitrate': instance.bitrate,
'audioCodec': instance.audioCodec,
'codec': mediaTypeTojson(instance.codec),
'codec': mediaTypeToJson(instance.codec),
'fragments': instance.fragments,
'qualityLabel': instance.qualityLabel,
};

View File

@ -61,7 +61,7 @@ class MuxedStreamInfo with StreamInfo, AudioStreamInfo, VideoStreamInfo {
/// Stream codec.
@override
@JsonKey(toJson: mediaTypeTojson, fromJson: mediaTypeFromJson)
@JsonKey(toJson: mediaTypeToJson, fromJson: mediaTypeFromJson)
final MediaType codec;
/// Stream codec.
@ -85,7 +85,8 @@ class MuxedStreamInfo with StreamInfo, AudioStreamInfo, VideoStreamInfo {
);
@override
String toString() => 'Muxed ($tag | $qualityLabel | $container)';
String toString() =>
'Muxed ($tag | ${videoResolution}p${framerate.framesPerSecond} | $container)';
factory MuxedStreamInfo.fromJson(Map<String, dynamic> json) =>
_$MuxedStreamInfoFromJson(json);

View File

@ -34,7 +34,7 @@ Map<String, dynamic> _$MuxedStreamInfoToJson(MuxedStreamInfo instance) =>
'videoQuality': _$VideoQualityEnumMap[instance.videoQuality],
'videoResolution': instance.videoResolution,
'framerate': instance.framerate,
'codec': mediaTypeTojson(instance.codec),
'codec': mediaTypeToJson(instance.codec),
'qualityLabel': instance.qualityLabel,
};

View File

@ -37,12 +37,12 @@ mixin StreamInfo {
/// Extension for Iterables of StreamInfo.
extension StreamInfoIterableExt<T extends StreamInfo> on Iterable<T> {
/// Gets the stream with highest bitrate.
T withHighestBitrate() => sortByBitrate().last;
T withHighestBitrate() => sortByBitrate().first;
/// Gets the video streams sorted by bitrate in ascending order.
/// This returns new list without editing the original list.
List<T> sortByBitrate() =>
toList()..sort((a, b) => a.bitrate.compareTo(b.bitrate));
toList()..sort((a, b) => b.bitrate.compareTo(a.bitrate));
/// Print a formatted text of all the streams. Like youtube-dl -F option.
String describe() {
@ -100,5 +100,5 @@ class _Column {
}
}
String mediaTypeTojson(MediaType value) => value.toString();
String mediaTypeToJson(MediaType value) => value.toString();
MediaType mediaTypeFromJson(String value) => MediaType.parse(value);

View File

@ -6,7 +6,7 @@ export 'framerate.dart';
export 'muxed_stream_info.dart';
export 'stream_container.dart';
export 'stream_context.dart';
export 'stream_info.dart' hide mediaTypeFromJson, mediaTypeTojson;
export 'stream_info.dart' hide mediaTypeFromJson, mediaTypeToJson;
export 'stream_manifest.dart';
export 'streams_client.dart';
export 'video_only_stream_info.dart';

View File

@ -47,7 +47,7 @@ class VideoOnlyStreamInfo with StreamInfo, VideoStreamInfo {
final List<Fragment> fragments;
@override
@JsonKey(toJson: mediaTypeTojson, fromJson: mediaTypeFromJson)
@JsonKey(toJson: mediaTypeToJson, fromJson: mediaTypeFromJson)
final MediaType codec;
VideoOnlyStreamInfo(
@ -65,7 +65,8 @@ class VideoOnlyStreamInfo with StreamInfo, VideoStreamInfo {
this.codec);
@override
String toString() => 'Video-only ($tag | $videoResolution | $container)';
String toString() =>
'Video-only ($tag | ${videoResolution}p${framerate.framesPerSecond} | $container)';
factory VideoOnlyStreamInfo.fromJson(Map<String, dynamic> json) =>
_$VideoOnlyStreamInfoFromJson(json);

View File

@ -38,7 +38,7 @@ Map<String, dynamic> _$VideoOnlyStreamInfoToJson(
'videoResolution': instance.videoResolution,
'framerate': instance.framerate,
'fragments': instance.fragments,
'codec': mediaTypeTojson(instance.codec),
'codec': mediaTypeToJson(instance.codec),
};
const _$VideoQualityEnumMap = {

View File

@ -32,11 +32,7 @@ extension VideoStreamInfoExtension<T extends VideoStreamInfo> on Iterable<T> {
Set<String> getAllVideoQualitiesLabel() => map((e) => e.qualityLabel).toSet();
/// Gets the stream with best video quality.
@Deprecated(
'This is actually a typo, and actually returns the videos sorted by the best *video quality*. Now use the `bestQuality` getter ')
T withHighestBitrate() => sortByVideoQuality().last;
T get bestQuality => sortByVideoQuality().last;
T get bestQuality => sortByVideoQuality().first;
/// Gets the video streams sorted by highest video quality
/// (then by framerate) in ascending order.

View File

@ -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.10.10+2
version: 1.11.0
homepage: https://github.com/Hexer10/youtube_explode_dart

View File

@ -1,6 +1,11 @@
import 'dart:math';
import 'package:test/test.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
final rnd = Random();
const letters = r'abcdefghilmnopqrstuvzjkwy1234567890!@#$%^&*()_+{}|"?><|~`|';
void main() {
YoutubeExplode? yt;
setUp(() {
@ -12,47 +17,64 @@ void main() {
});
test('Search a youtube video from the search page', () async {
var videos = await yt!.search.getVideos('undead corporation megalomania');
var videos = await yt!.search.search('undead corporation megalomania');
expect(videos, isNotEmpty);
});
test('Search a youtube video from the search page-2', () async {
var videos = await yt!.search
// ignore: deprecated_member_use_from_same_package
.getVideosFromPage('hello')
.where((e) => e is SearchVideo) // Take only the videos.
.cast<SearchVideo>()
.take(10) // Take on 10 results.
.toList();
expect(videos, hasLength(10));
var video = videos.first;
expect(video.id, isNotNull);
expect(video.title, isNotEmpty);
expect(video.author, isNotEmpty);
expect(video.description, isNotEmpty);
expect(video.duration, isNotEmpty);
expect(video.viewCount, greaterThan(0));
expect(video.thumbnails, isNotEmpty);
test('Search with no results', () async {
var videos = await yt!.search.search(
List.generate(1300, (_) => letters[rnd.nextInt(letters.length)])
.join());
expect(videos, isEmpty);
var nextPage = await videos.nextPage();
expect(nextPage, isNull);
});
// Seems all search key also have result now, so hide this test case
// test('Search with no results - old', () async {
// var query =
// // ignore: deprecated_member_use_from_same_package
// await yt!.search.queryFromPage('g;jghEOGHJeguEPOUIhjegoUEHGOGHPSASG');
// expect(query.content, isEmpty);
// expect(query.relatedVideos, isEmpty);
// var nextPage = await query.nextPage();
// expect(nextPage, isNull);
// });
test('Search only channels', () async {
var channels = await yt!.search
.searchContent('PewDiePie', filter: TypeFilters.channel);
expect(channels.every((e) => e is SearchChannel), isTrue);
});
test('Search youtube videos have thumbnails - old', () async {
// ignore: deprecated_member_use_from_same_package
var searchQuery = await yt!.search.queryFromPage('hello');
expect(searchQuery.content.first, isA<SearchVideo>());
test('Search only playlists', () async {
var channels =
await yt!.search.searchContent('Banana', filter: TypeFilters.playlist);
expect(channels.every((e) => e is SearchPlaylist), isTrue);
});
var video = searchQuery.content.first as SearchVideo;
expect(video.thumbnails, isNotEmpty);
test('Search only movies', () async {
var channels =
await yt!.search.searchContent('Banana', filter: TypeFilters.playlist);
expect(channels.every((e) => e is SearchPlaylist), isTrue);
});
test('Search test search filters', () async {
var featureSearch =
await yt!.search.searchContent('hello', filter: FeatureFilters.hd);
expect(featureSearch, isNotEmpty);
var uploadSearch = await yt!.search
.searchContent('hello', filter: UploadDateFilter.lastHour);
expect(uploadSearch, isNotEmpty);
var durationSearch =
await yt!.search.searchContent('hello', filter: DurationFilters.long);
expect(durationSearch, isNotEmpty);
var sortSearch =
await yt!.search.searchContent('hello', filter: SortFilters.viewCount);
expect(sortSearch, isNotEmpty);
});
test('Search raw', () async {
var search = await yt!.search.searchRaw('hello');
expect(search.content, isNotEmpty);
expect(search.relatedVideos, isNotEmpty);
expect(search.estimatedResults, greaterThan(1));
});
test('Get youtube search suggestions', () async {
var suggestions = await yt!.search.getQuerySuggestions('hello');
expect(suggestions, isNotEmpty);
});
}