summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorSamantaz Fox <coding@samantaz.fr>2022-05-26 18:31:02 +0200
committerSamantaz Fox <coding@samantaz.fr>2022-06-08 23:56:40 +0200
commit33da64a6696e757aa98b2c771e3e8c03f5e58b2b (patch)
tree261bb7182971de3ec213377200aaf76acce54945 /src
parent7ad111e2f65c2688c7accb31ff75171c29f2cc26 (diff)
downloadinvidious-33da64a6696e757aa98b2c771e3e8c03f5e58b2b.tar.gz
invidious-33da64a6696e757aa98b2c771e3e8c03f5e58b2b.tar.bz2
invidious-33da64a6696e757aa98b2c771e3e8c03f5e58b2b.zip
Add support for hashtags
Diffstat (limited to 'src')
-rw-r--r--src/invidious.cr1
-rw-r--r--src/invidious/hashtag.cr44
-rw-r--r--src/invidious/routes/search.cr31
-rw-r--r--src/invidious/views/hashtag.ecr39
-rw-r--r--src/invidious/yt_backend/extractors.cr26
5 files changed, 141 insertions, 0 deletions
diff --git a/src/invidious.cr b/src/invidious.cr
index dd240852..4952b365 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -385,6 +385,7 @@ end
Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch
Invidious::Routing.get "/results", Invidious::Routes::Search, :results
Invidious::Routing.get "/search", Invidious::Routes::Search, :search
+ Invidious::Routing.get "/hashtag/:hashtag", Invidious::Routes::Search, :hashtag
# User routes
define_user_routes()
diff --git a/src/invidious/hashtag.cr b/src/invidious/hashtag.cr
new file mode 100644
index 00000000..afe31a36
--- /dev/null
+++ b/src/invidious/hashtag.cr
@@ -0,0 +1,44 @@
+module Invidious::Hashtag
+ extend self
+
+ def fetch(hashtag : String, page : Int, region : String? = nil) : Array(SearchItem)
+ cursor = (page - 1) * 60
+ ctoken = generate_continuation(hashtag, cursor)
+
+ client_config = YoutubeAPI::ClientConfig.new(region: region)
+ response = YoutubeAPI.browse(continuation: ctoken, client_config: client_config)
+
+ return extract_items(response)
+ end
+
+ def generate_continuation(hashtag : String, cursor : Int)
+ object = {
+ "80226972:embedded" => {
+ "2:string" => "FEhashtag",
+ "3:base64" => {
+ "1:varint" => cursor.to_i64,
+ },
+ "7:base64" => {
+ "325477796:embedded" => {
+ "1:embedded" => {
+ "2:0:embedded" => {
+ "2:string" => '#' + hashtag,
+ "4:varint" => 0_i64,
+ "11:string" => "",
+ },
+ "4:string" => "browse-feedFEhashtag",
+ },
+ "2:string" => hashtag,
+ },
+ },
+ },
+ }
+
+ continuation = 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) }
+
+ return continuation
+ end
+end
diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr
index e60d0081..6f8bffea 100644
--- a/src/invidious/routes/search.cr
+++ b/src/invidious/routes/search.cr
@@ -63,4 +63,35 @@ module Invidious::Routes::Search
templated "search"
end
end
+
+ def self.hashtag(env : HTTP::Server::Context)
+ locale = env.get("preferences").as(Preferences).locale
+
+ hashtag = env.params.url["hashtag"]?
+ if hashtag.nil? || hashtag.empty?
+ return error_template(400, "Invalid request")
+ end
+
+ page = env.params.query["page"]?
+ if page.nil?
+ page = 1
+ else
+ page = Math.max(1, page.to_i)
+ env.params.query.delete_all("page")
+ end
+
+ begin
+ videos = Invidious::Hashtag.fetch(hashtag, page)
+ rescue ex
+ return error_template(500, ex)
+ end
+
+ params = env.params.query.empty? ? "" : "&#{env.params.query}"
+
+ hashtag_encoded = URI.encode_www_form(hashtag, space_to_plus: false)
+ url_prev_page = "/hashtag/#{hashtag_encoded}?page=#{page - 1}#{params}"
+ url_next_page = "/hashtag/#{hashtag_encoded}?page=#{page + 1}#{params}"
+
+ templated "hashtag"
+ end
end
diff --git a/src/invidious/views/hashtag.ecr b/src/invidious/views/hashtag.ecr
new file mode 100644
index 00000000..0ecfe832
--- /dev/null
+++ b/src/invidious/views/hashtag.ecr
@@ -0,0 +1,39 @@
+<% content_for "header" do %>
+<title><%= HTML.escape(hashtag) %> - Invidious</title>
+<% end %>
+
+<hr/>
+
+<div class="pure-g h-box v-box">
+ <div class="pure-u-1 pure-u-lg-1-5">
+ <%- if page > 1 -%>
+ <a href="<%= url_prev_page %>"><%= translate(locale, "Previous page") %></a>
+ <%- end -%>
+ </div>
+ <div class="pure-u-1 pure-u-lg-3-5"></div>
+ <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
+ <%- if videos.size >= 60 -%>
+ <a href="<%= url_next_page %>"><%= translate(locale, "Next page") %></a>
+ <%- end -%>
+ </div>
+</div>
+
+<div class="pure-g">
+ <%- videos.each do |item| -%>
+ <%= rendered "components/item" %>
+ <%- end -%>
+</div>
+
+<div class="pure-g h-box">
+ <div class="pure-u-1 pure-u-lg-1-5">
+ <%- if page > 1 -%>
+ <a href="<%= url_prev_page %>"><%= translate(locale, "Previous page") %></a>
+ <%- end -%>
+ </div>
+ <div class="pure-u-1 pure-u-lg-3-5"></div>
+ <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
+ <%- if videos.size >= 60 -%>
+ <a href="<%= url_next_page %>"><%= translate(locale, "Next page") %></a>
+ <%- end -%>
+ </div>
+</div>
diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr
index a2ec7d59..7e7cf85b 100644
--- a/src/invidious/yt_backend/extractors.cr
+++ b/src/invidious/yt_backend/extractors.cr
@@ -1,3 +1,5 @@
+require "../helpers/serialized_yt_data"
+
# This file contains helper methods to parse the Youtube API json data into
# neat little packages we can use
@@ -14,6 +16,7 @@ private ITEM_PARSERS = {
Parsers::GridPlaylistRendererParser,
Parsers::PlaylistRendererParser,
Parsers::CategoryRendererParser,
+ Parsers::RichItemRendererParser,
}
record AuthorFallback, name : String, id : String
@@ -374,6 +377,29 @@ private module Parsers
return {{@type.name}}
end
end
+
+ # Parses an InnerTube richItemRenderer into a SearchVideo.
+ # Returns nil when the given object isn't a shelfRenderer
+ #
+ # A richItemRenderer seems to be a simple wrapper for a videoRenderer, used
+ # by the result page for hashtags. It is located inside a continuationItems
+ # container.
+ #
+ module RichItemRendererParser
+ def self.process(item : JSON::Any, author_fallback : AuthorFallback)
+ if item_contents = item.dig?("richItemRenderer", "content")
+ return self.parse(item_contents, author_fallback)
+ end
+ end
+
+ private def self.parse(item_contents, author_fallback)
+ return VideoRendererParser.process(item_contents, author_fallback)
+ end
+
+ def self.parser_name
+ return {{@type.name}}
+ end
+ end
end
# The following are the extractors for extracting an array of items from