diff --git a/YoutubeExplode.Tests/SearchSpecs.cs b/YoutubeExplode.Tests/SearchSpecs.cs index 0224d0e9..3f94911a 100644 --- a/YoutubeExplode.Tests/SearchSpecs.cs +++ b/YoutubeExplode.Tests/SearchSpecs.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Xunit; using YoutubeExplode.Common; +using YoutubeExplode.Search; namespace YoutubeExplode.Tests; @@ -125,4 +126,50 @@ public async Task I_can_get_channel_results_from_a_search_query() // Assert channels.Should().NotBeEmpty(); } + + [Fact] + public async Task I_can_get_results_from_a_search_query_with_localization() + { + // Arrange + using var youtube = new YoutubeClient(); + + // Act + var results = await youtube.Search.GetResultsAsync("news", SearchFilter.None, "uk", "UA"); + + // Assert + results.Should().NotBeEmpty(); + } + + [Fact] + public async Task I_can_get_video_results_from_a_search_query_with_localization() + { + // Arrange + using var youtube = new YoutubeClient(); + + // Act + var videos = await youtube.Search.GetVideosAsync("music", "fr", "FR"); + + // Assert + videos.Should().NotBeEmpty(); + } + + [Fact] + public async Task I_can_get_results_from_a_search_query_with_advanced_filters() + { + // Arrange + using var youtube = new YoutubeClient(); + + var filter = new SearchFilter( + Type: SearchFilterType.Video, + UploadDate: SearchFilterUploadDate.ThisWeek, + Duration: SearchFilterDuration.Short, + SortBy: SearchFilterSortBy.UploadDate + ); + + // Act + var results = await youtube.Search.GetResultsAsync("news", filter); + + // Assert + results.Should().NotBeEmpty(); + } } diff --git a/YoutubeExplode/Search/SearchClient.cs b/YoutubeExplode/Search/SearchClient.cs index eb0ad827..3cff13a5 100644 --- a/YoutubeExplode/Search/SearchClient.cs +++ b/YoutubeExplode/Search/SearchClient.cs @@ -24,7 +24,9 @@ public class SearchClient(HttpClient http) /// public async IAsyncEnumerable> GetResultBatchesAsync( string searchQuery, - SearchFilter searchFilter, + SearchFilter searchFilter = default, + string hl = "en", + string gl = "US", [EnumeratorCancellation] CancellationToken cancellationToken = default ) { @@ -39,13 +41,15 @@ public async IAsyncEnumerable> GetResultBatchesAsync( searchQuery, searchFilter, continuationToken, + hl, + gl, cancellationToken ); // Video results foreach (var videoData in searchResults.Videos) { - if (searchFilter is not SearchFilter.None and not SearchFilter.Video) + if (searchFilter.Type is not null && searchFilter.Type != SearchFilterType.Video) { Debug.Fail("Did not expect videos in search results."); break; @@ -120,7 +124,7 @@ public async IAsyncEnumerable> GetResultBatchesAsync( // Playlist results foreach (var playlistData in searchResults.Playlists) { - if (searchFilter is not SearchFilter.None and not SearchFilter.Playlist) + if (searchFilter.Type is not null && searchFilter.Type != SearchFilterType.Playlist) { Debug.Fail("Did not expect playlists in search results."); break; @@ -185,7 +189,7 @@ public async IAsyncEnumerable> GetResultBatchesAsync( // Channel results foreach (var channelData in searchResults.Channels) { - if (searchFilter is not SearchFilter.None and not SearchFilter.Channel) + if (searchFilter.Type is not null && searchFilter.Type != SearchFilterType.Channel) { Debug.Fail("Did not expect channels in search results."); break; @@ -237,30 +241,27 @@ public async IAsyncEnumerable> GetResultBatchesAsync( } while (!string.IsNullOrWhiteSpace(continuationToken)); } - /// - /// Enumerates batches of search results returned by the specified query. - /// - public IAsyncEnumerable> GetResultBatchesAsync( - string searchQuery, - CancellationToken cancellationToken = default - ) => GetResultBatchesAsync(searchQuery, SearchFilter.None, cancellationToken); - /// /// Enumerates search results returned by the specified query. /// public IAsyncEnumerable GetResultsAsync( string searchQuery, + SearchFilter searchFilter = default, + string hl = "en", + string gl = "US", CancellationToken cancellationToken = default - ) => GetResultBatchesAsync(searchQuery, cancellationToken).FlattenAsync(); + ) => GetResultBatchesAsync(searchQuery, searchFilter, hl, gl, cancellationToken).FlattenAsync(); /// /// Enumerates video search results returned by the specified query. /// public IAsyncEnumerable GetVideosAsync( string searchQuery, + string hl = "en", + string gl = "US", CancellationToken cancellationToken = default ) => - GetResultBatchesAsync(searchQuery, SearchFilter.Video, cancellationToken) + GetResultBatchesAsync(searchQuery, SearchFilter.Video, hl, gl, cancellationToken) .FlattenAsync() .OfTypeAsync(); @@ -269,9 +270,11 @@ public IAsyncEnumerable GetVideosAsync( /// public IAsyncEnumerable GetPlaylistsAsync( string searchQuery, + string hl = "en", + string gl = "US", CancellationToken cancellationToken = default ) => - GetResultBatchesAsync(searchQuery, SearchFilter.Playlist, cancellationToken) + GetResultBatchesAsync(searchQuery, SearchFilter.Playlist, hl, gl, cancellationToken) .FlattenAsync() .OfTypeAsync(); @@ -280,9 +283,11 @@ public IAsyncEnumerable GetPlaylistsAsync( /// public IAsyncEnumerable GetChannelsAsync( string searchQuery, + string hl = "en", + string gl = "US", CancellationToken cancellationToken = default ) => - GetResultBatchesAsync(searchQuery, SearchFilter.Channel, cancellationToken) + GetResultBatchesAsync(searchQuery, SearchFilter.Channel, hl, gl, cancellationToken) .FlattenAsync() .OfTypeAsync(); } diff --git a/YoutubeExplode/Search/SearchController.cs b/YoutubeExplode/Search/SearchController.cs index 0e3c0635..c28e7059 100644 --- a/YoutubeExplode/Search/SearchController.cs +++ b/YoutubeExplode/Search/SearchController.cs @@ -12,6 +12,8 @@ public async ValueTask GetSearchResponseAsync( string searchQuery, SearchFilter searchFilter, string? continuationToken, + string hl, + string gl, CancellationToken cancellationToken = default ) { @@ -20,25 +22,22 @@ public async ValueTask GetSearchResponseAsync( "https://www.youtube.com/youtubei/v1/search" ); + var filter = searchFilter.ToString(); + request.Content = new StringContent( // lang=json $$""" { "query": {{Json.Encode(searchQuery)}}, - "params": {{Json.Encode(searchFilter switch - { - SearchFilter.Video => "EgIQAQ%3D%3D", - SearchFilter.Playlist => "EgIQAw%3D%3D", - SearchFilter.Channel => "EgIQAg%3D%3D", - _ => null - })}}, + "params": {{Json.Encode(!string.IsNullOrWhiteSpace(filter) ? filter : null)}}, "continuation": {{Json.Encode(continuationToken)}}, "context": { "client": { "clientName": "WEB", "clientVersion": "2.20210408.08.00", - "hl": "en", - "gl": "US", + "hl": {{Json.Encode(hl)}}, + "gl": {{Json.Encode(gl)}}, + "persist_hl": "1", "utcOffsetMinutes": 0 } } diff --git a/YoutubeExplode/Search/SearchFilter.cs b/YoutubeExplode/Search/SearchFilter.cs index b0afeb21..5d96d438 100644 --- a/YoutubeExplode/Search/SearchFilter.cs +++ b/YoutubeExplode/Search/SearchFilter.cs @@ -1,27 +1,279 @@ +using System; +using System.Collections.Generic; + namespace YoutubeExplode.Search; /// /// Filter applied to a YouTube search query. /// -public enum SearchFilter +public readonly record struct SearchFilter( + SearchFilterType? Type = null, + SearchFilterDuration? Duration = null, + SearchFilterUploadDate? UploadDate = null, + SearchFilterFeatures Features = SearchFilterFeatures.None, + SearchFilterSortBy SortBy = SearchFilterSortBy.Relevance +) { /// /// No filter applied. /// - None, + public static SearchFilter None { get; } = new(); /// /// Only search for videos. /// - Video, + public static SearchFilter Video { get; } = new(SearchFilterType.Video); /// /// Only search for playlists. /// - Playlist, + public static SearchFilter Playlist { get; } = new(SearchFilterType.Playlist); /// /// Only search for channels. /// - Channel, + public static SearchFilter Channel { get; } = new(SearchFilterType.Channel); + + private void WriteVarint(List buffer, ulong value) + { + while (value >= 0x80) + { + buffer.Add((byte)(value | 0x80)); + value >>= 7; + } + buffer.Add((byte)value); + } + + private void WriteTag(List buffer, int fieldNumber, int wireType) + { + WriteVarint(buffer, (ulong)((fieldNumber << 3) | wireType)); + } + + private void WriteVarintField(List buffer, int fieldNumber, ulong value) + { + WriteTag(buffer, fieldNumber, 0); + WriteVarint(buffer, value); + } + + /// + public override string ToString() + { + var topBuffer = new List(); + + // Field 1: Sort By + if (SortBy != SearchFilterSortBy.Relevance) + { + WriteVarintField(topBuffer, 1, (ulong)SortBy); + } + + // Field 2: Filters + var filterBuffer = new List(); + if (UploadDate is not null) + WriteVarintField(filterBuffer, 1, (ulong)UploadDate); + if (Type is not null) + WriteVarintField(filterBuffer, 2, (ulong)Type); + if (Duration is not null) + WriteVarintField(filterBuffer, 3, (ulong)Duration); + + if (Features.HasFlag(SearchFilterFeatures.HD)) + WriteVarintField(filterBuffer, 4, 1); + if (Features.HasFlag(SearchFilterFeatures.Subtitles)) + WriteVarintField(filterBuffer, 5, 1); + if (Features.HasFlag(SearchFilterFeatures.CreativeCommons)) + WriteVarintField(filterBuffer, 6, 1); + if (Features.HasFlag(SearchFilterFeatures.ThreeD)) + WriteVarintField(filterBuffer, 7, 1); + if (Features.HasFlag(SearchFilterFeatures.Live)) + WriteVarintField(filterBuffer, 8, 1); + if (Features.HasFlag(SearchFilterFeatures.VR180)) + WriteVarintField(filterBuffer, 9, 1); + if (Features.HasFlag(SearchFilterFeatures.Location)) + WriteVarintField(filterBuffer, 10, 1); + if (Features.HasFlag(SearchFilterFeatures.Purchased)) + WriteVarintField(filterBuffer, 11, 1); + if (Features.HasFlag(SearchFilterFeatures.FourK)) + WriteVarintField(filterBuffer, 14, 1); + if (Features.HasFlag(SearchFilterFeatures.HDR)) + WriteVarintField(filterBuffer, 15, 1); + if (Features.HasFlag(SearchFilterFeatures.ThreeSixty)) + WriteVarintField(filterBuffer, 16, 1); + + if (filterBuffer.Count > 0) + { + WriteTag(topBuffer, 2, 2); + WriteVarint(topBuffer, (ulong)filterBuffer.Count); + topBuffer.AddRange(filterBuffer); + } + + if (topBuffer.Count == 0) + return ""; + + return Convert.ToBase64String(topBuffer.ToArray()); + } +} + +/// +/// Search result type filter. +/// +public enum SearchFilterType +{ + /// + /// Only search for videos. + /// + Video = 1, + + /// + /// Only search for channels. + /// + Channel = 2, + + /// + /// Only search for playlists. + /// + Playlist = 3, + + /// + /// Only search for movies. + /// + Movie = 4, + + /// + /// Only search for shorts. + /// + Shorts = 5, +} + +/// +/// Search result duration filter. +/// +public enum SearchFilterDuration +{ + /// + /// Short duration (under 4 minutes). + /// + Short = 1, + + /// + /// Long duration (over 20 minutes). + /// + Long = 2, + + /// + /// Medium duration (4 to 20 minutes). + /// + Medium = 3, +} + +/// +/// Search result upload date filter. +/// +public enum SearchFilterUploadDate +{ + /// + /// Uploaded today. + /// + Today = 2, + + /// + /// Uploaded this week. + /// + ThisWeek = 3, + + /// + /// Uploaded this month. + /// + ThisMonth = 4, + + /// + /// Uploaded this year. + /// + ThisYear = 5, +} + +/// +/// Search result features filter. +/// +[Flags] +public enum SearchFilterFeatures +{ + /// + /// No features. + /// + None = 0, + + /// + /// Live videos. + /// + Live = 1 << 0, + + /// + /// 4K videos. + /// + FourK = 1 << 1, + + /// + /// HD videos. + /// + HD = 1 << 2, + + /// + /// Subtitles/CC. + /// + Subtitles = 1 << 3, + + /// + /// Creative Commons. + /// + CreativeCommons = 1 << 4, + + /// + /// 360° videos. + /// + ThreeSixty = 1 << 5, + + /// + /// VR180 videos. + /// + VR180 = 1 << 6, + + /// + /// 3D videos. + /// + ThreeD = 1 << 7, + + /// + /// HDR videos. + /// + HDR = 1 << 8, + + /// + /// Location. + /// + Location = 1 << 9, + + /// + /// Purchased. + /// + Purchased = 1 << 10, +} + +/// +/// Search result sort order. +/// +public enum SearchFilterSortBy +{ + /// + /// Relevance. + /// + Relevance = 0, + + /// + /// Upload date. + /// + UploadDate = 2, + + /// + /// View count (popularity). + /// + Popularity = 3, }