diff --git a/spec/invidious/helpers_spec.cr b/spec/invidious/helpers_spec.cr index b2436989..5ecebef3 100644 --- a/spec/invidious/helpers_spec.cr +++ b/spec/invidious/helpers_spec.cr @@ -29,20 +29,6 @@ Spectator.describe "Helper" do end end - describe "#produce_search_params" do - it "correctly produces token for searching with specified filters" do - expect(produce_search_params).to eq("CAASAhABSAA%3D") - - expect(produce_search_params(sort: "upload_date", content_type: "video")).to eq("CAISAhABSAA%3D") - - expect(produce_search_params(content_type: "playlist")).to eq("CAASAhADSAA%3D") - - expect(produce_search_params(sort: "date", content_type: "video", features: ["hd", "cc", "purchased", "hdr"])).to eq("CAISCxABIAEwAUgByAEBSAA%3D") - - expect(produce_search_params(content_type: "channel")).to eq("CAASAhACSAA%3D") - end - end - describe "#produce_comment_continuation" do it "correctly produces a continuation token for comments" do expect(produce_comment_continuation("_cE8xSu6swE", "ADSJ_i2qvJeFtL0htmS5_K5Ctj3eGFVBMWL9Wd42o3kmUL6_mAzdLp85-liQZL0mYr_16BhaggUqX652Sv9JqV6VXinShSP-ZT6rL4NolPBaPXVtJsO5_rA_qE3GubAuLFw9uzIIXU2-HnpXbdgPLWTFavfX206hqWmmpHwUOrmxQV_OX6tYkM3ux3rPAKCDrT8eWL7MU3bLiNcnbgkW8o0h8KYLL_8BPa8LcHbTv8pAoNkjerlX1x7K4pqxaXPoyz89qNlnh6rRx6AXgAzzoHH1dmcyQ8CIBeOHg-m4i8ZxdX4dP88XWrIFg-jJGhpGP8JUMDgZgavxVx225hUEYZMyrLGler5em4FgbG62YWC51moLDLeYEA")).to eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyjAMK9gJBRFNKX2kycXZKZUZ0TDBodG1TNV9LNUN0ajNlR0ZWQk1XTDlXZDQybzNrbVVMNl9tQXpkTHA4NS1saVFaTDBtWXJfMTZCaGFnZ1VxWDY1MlN2OUpxVjZWWGluU2hTUC1aVDZyTDROb2xQQmFQWFZ0SnNPNV9yQV9xRTNHdWJBdUxGdzl1eklJWFUyLUhucFhiZGdQTFdURmF2ZlgyMDZocVdtbXBId1VPcm14UVZfT1g2dFlrTTN1eDNyUEFLQ0RyVDhlV0w3TVUzYkxpTmNuYmdrVzhvMGg4S1lMTF84QlBhOExjSGJUdjhwQW9Oa2plcmxYMXg3SzRwcXhhWFBveXo4OXFObG5oNnJSeDZBWGdBenpvSEgxZG1jeVE4Q0lCZU9IZy1tNGk4WnhkWDRkUDg4WFdySUZnLWpKR2hwR1A4SlVNRGdaZ2F2eFZ4MjI1aFVFWVpNeXJMR2xlcjVlbTRGZ2JHNjJZV0M1MW1vTERMZVlFQSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D") diff --git a/spec/invidious/search/yt_filters_spec.cr b/spec/invidious/search/yt_filters_spec.cr new file mode 100644 index 00000000..27357058 --- /dev/null +++ b/spec/invidious/search/yt_filters_spec.cr @@ -0,0 +1,92 @@ +require "../../../src/invidious/search/filters" + +require "http/params" +require "spectator" + +Spectator.configure do |config| + config.fail_blank + config.randomize +end + +# Encoded filter values are extracted from the search +# page of Youtube with any browser devtools HTML inspector. + +DATE_FILTERS = { + Invidious::Search::Filters::Date::Hour => "EgIIAQ%3D%3D", + Invidious::Search::Filters::Date::Today => "EgIIAg%3D%3D", + Invidious::Search::Filters::Date::Week => "EgIIAw%3D%3D", + Invidious::Search::Filters::Date::Month => "EgIIBA%3D%3D", + Invidious::Search::Filters::Date::Year => "EgIIBQ%3D%3D", +} + +TYPE_FILTERS = { + Invidious::Search::Filters::Type::Video => "EgIQAQ%3D%3D", + Invidious::Search::Filters::Type::Channel => "EgIQAg%3D%3D", + Invidious::Search::Filters::Type::Playlist => "EgIQAw%3D%3D", + Invidious::Search::Filters::Type::Movie => "EgIQBA%3D%3D", +} + +DURATION_FILTERS = { + Invidious::Search::Filters::Duration::Short => "EgIYAQ%3D%3D", + Invidious::Search::Filters::Duration::Medium => "EgIYAw%3D%3D", + Invidious::Search::Filters::Duration::Long => "EgIYAg%3D%3D", +} + +FEATURE_FILTERS = { + Invidious::Search::Filters::Features::Live => "EgJAAQ%3D%3D", + Invidious::Search::Filters::Features::FourK => "EgJwAQ%3D%3D", + Invidious::Search::Filters::Features::HD => "EgIgAQ%3D%3D", + Invidious::Search::Filters::Features::Subtitles => "EgIoAQ%3D%3D", + Invidious::Search::Filters::Features::CCommons => "EgIwAQ%3D%3D", + Invidious::Search::Filters::Features::ThreeSixty => "EgJ4AQ%3D%3D", + Invidious::Search::Filters::Features::VR180 => "EgPQAQE%3D", + Invidious::Search::Filters::Features::ThreeD => "EgI4AQ%3D%3D", + Invidious::Search::Filters::Features::HDR => "EgPIAQE%3D", + Invidious::Search::Filters::Features::Location => "EgO4AQE%3D", + Invidious::Search::Filters::Features::Purchased => "EgJIAQ%3D%3D", +} + +SORT_FILTERS = { + Invidious::Search::Filters::Sort::Relevance => "", + Invidious::Search::Filters::Sort::Date => "CAI%3D", + Invidious::Search::Filters::Sort::Views => "CAM%3D", + Invidious::Search::Filters::Sort::Rating => "CAE%3D", +} + +Spectator.describe Invidious::Search::Filters do + # ------------------- + # Encode YT params + # ------------------- + + describe "#to_yt_params" do + sample DATE_FILTERS do |value, result| + it "Encodes upload date filter '#{value}'" do + expect(described_class.new(date: value).to_yt_params).to eq(result) + end + end + + sample TYPE_FILTERS do |value, result| + it "Encodes content type filter '#{value}'" do + expect(described_class.new(type: value).to_yt_params).to eq(result) + end + end + + sample DURATION_FILTERS do |value, result| + it "Encodes duration filter '#{value}'" do + expect(described_class.new(duration: value).to_yt_params).to eq(result) + end + end + + sample FEATURE_FILTERS do |value, result| + it "Encodes feature filter '#{value}'" do + expect(described_class.new(features: value).to_yt_params).to eq(result) + end + end + + sample SORT_FILTERS do |value, result| + it "Encodes sort filter '#{value}'" do + expect(described_class.new(sort: value).to_yt_params).to eq(result) + end + end + end +end diff --git a/src/invidious/search/filters.cr b/src/invidious/search/filters.cr index 75ac287a..f1fd2695 100644 --- a/src/invidious/search/filters.cr +++ b/src/invidious/search/filters.cr @@ -1,3 +1,6 @@ +require "protodec/utils" +require "http/params" + module Invidious::Search struct Filters # Values correspond to { "2:embedded": { "1:varint": }} @@ -74,6 +77,63 @@ module Invidious::Search @features : Features = Features::None, @sort : Sort = Sort::Relevance ) + # ------------------- + # Youtube params + # ------------------- + + # Produce the youtube search parameters for the + # innertube API (base64-encoded protobuf object). + def to_yt_params(page : Int = 1) : String + # Initialize the embedded protobuf object + embedded = {} of String => Int64 + + # Add these field only if associated parameter is selected + embedded["1:varint"] = @date.to_i64 if !@date.none? + embedded["2:varint"] = @type.to_i64 if !@type.all? + embedded["3:varint"] = @duration.to_i64 if !@duration.none? + + if !@features.none? + # All features have a value of "1" when enabled, and + # the field is omitted when the feature is no selected. + embedded["4:varint"] = 1_i64 if @features.includes?(Features::HD) + embedded["5:varint"] = 1_i64 if @features.includes?(Features::Subtitles) + embedded["6:varint"] = 1_i64 if @features.includes?(Features::CCommons) + embedded["7:varint"] = 1_i64 if @features.includes?(Features::ThreeD) + embedded["8:varint"] = 1_i64 if @features.includes?(Features::Live) + embedded["9:varint"] = 1_i64 if @features.includes?(Features::Purchased) + embedded["14:varint"] = 1_i64 if @features.includes?(Features::FourK) + embedded["15:varint"] = 1_i64 if @features.includes?(Features::ThreeSixty) + embedded["23:varint"] = 1_i64 if @features.includes?(Features::Location) + embedded["25:varint"] = 1_i64 if @features.includes?(Features::HDR) + embedded["26:varint"] = 1_i64 if @features.includes?(Features::VR180) + end + + # Initialize an empty protobuf object + object = {} of String => (Int64 | String | Hash(String, Int64)) + + # As usual, everything can be omitted if it has no value + object["2:embedded"] = embedded if !embedded.empty? + + # Default sort is "relevance", so when this option is selected, + # the associated field can be omitted. + if !@sort.relevance? + object["1:varint"] = @sort.to_i64 + end + + # Add page number (if provided) + if page > 1 + object["9:varint"] = ((page - 1) * 20).to_i64 + end + + # If the object is empty, return an empty string, + # otherwise encode to protobuf then to base64 + return "" if object.empty? + + return object + .try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } end end end