Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Search: Add hashtag result #3989

Merged
merged 4 commits into from
Aug 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions assets/hashtag.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions locales/en-US.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"generic_channels_count": "{{count}} channel",
"generic_channels_count_plural": "{{count}} channels",
"generic_views_count": "{{count}} view",
"generic_views_count_plural": "{{count}} views",
"generic_videos_count": "{{count}} video",
Expand Down
2 changes: 2 additions & 0 deletions locales/fr.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"generic_channels_count": "{{count}} chaîne",
"generic_channels_count_plural": "{{count}} chaînes",
"generic_views_count": "{{count}} vue",
"generic_views_count_plural": "{{count}} vues",
"generic_videos_count": "{{count}} vidéo",
Expand Down
21 changes: 20 additions & 1 deletion src/invidious/helpers/serialized_yt_data.cr
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,25 @@ struct SearchChannel
end
end

struct SearchHashtag
include DB::Serializable

property title : String
property url : String
property video_count : Int64
property channel_count : Int64

def to_json(locale : String?, json : JSON::Builder)
json.object do
json.field "type", "hashtag"
json.field "title", self.title
json.field "url", self.url
json.field "videoCount", self.video_count
json.field "channelCount", self.channel_count
end
end
end

class Category
include DB::Serializable

Expand Down Expand Up @@ -274,4 +293,4 @@ struct Continuation
end
end

alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | Category
alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | SearchHashtag | Category
26 changes: 25 additions & 1 deletion src/invidious/views/components/item.ecr
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<%-
thin_mode = env.get("preferences").as(Preferences).thin_mode
item_watched = !item.is_a?(SearchChannel | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil
item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil
author_verified = item.responds_to?(:author_verified) && item.author_verified
-%>

Expand Down Expand Up @@ -29,6 +29,30 @@
<p><%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %></p>
<% if !item.auto_generated %><p><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p><% end %>
<h5><%= item.description_html %></h5>
<% when SearchHashtag %>
<% if !thin_mode %>
<a tabindex="-1" href="<%= item.url %>">
<center><img style="width:56.25%" src="/hashtag.svg" alt="" /></center>
</a>
<%- else -%>
<div class="thumbnail-placeholder" style="width:56.25%"></div>
<% end %>

<div class="video-card-row">
<div class="flex-left"><a href="<%= item.url %>"><%= HTML.escape(item.title) %></a></div>
</div>

<div class="video-card-row">
<%- if item.video_count != 0 -%>
<p><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
<%- end -%>
</div>

<div class="video-card-row">
<%- if item.channel_count != 0 -%>
<p><%= translate_count(locale, "generic_channels_count", item.channel_count, NumberFormatting::Separator) %></p>
<%- end -%>
</div>
<% when SearchPlaylist, InvidiousPlaylist %>
<%-
if item.id.starts_with? "RD"
Expand Down
53 changes: 52 additions & 1 deletion src/invidious/yt_backend/extractors.cr
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,16 @@ private ITEM_CONTAINER_EXTRACTOR = {
}

private ITEM_PARSERS = {
Parsers::RichItemRendererParser,
Parsers::VideoRendererParser,
Parsers::ChannelRendererParser,
Parsers::GridPlaylistRendererParser,
Parsers::PlaylistRendererParser,
Parsers::CategoryRendererParser,
Parsers::RichItemRendererParser,
Parsers::ReelItemRendererParser,
Parsers::ItemSectionRendererParser,
Parsers::ContinuationItemRendererParser,
Parsers::HashtagRendererParser,
}

private alias InitialData = Hash(String, JSON::Any)
Expand Down Expand Up @@ -210,6 +211,56 @@ private module Parsers
end
end

# Parses an Innertube `hashtagTileRenderer` into a `SearchHashtag`.
# Returns `nil` when the given object is not a `hashtagTileRenderer`.
#
# A `hashtagTileRenderer` is a kind of search result.
# It can be found when searching for any hashtag (e.g "#hi" or "#shorts")
module HashtagRendererParser
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["hashtagTileRenderer"]?
return self.parse(item_contents)
end
end

private def self.parse(item_contents)
title = extract_text(item_contents["hashtag"]).not_nil! # E.g "#hi"

# E.g "/hashtag/hi"
url = item_contents.dig?("onTapCommand", "commandMetadata", "webCommandMetadata", "url").try &.as_s
url ||= URI.encode_path("/hashtag/#{title.lchop('#')}")

video_count_txt = extract_text(item_contents["hashtagVideoCount"]?) # E.g "203K videos"
channel_count_txt = extract_text(item_contents["hashtagChannelCount"]?) # E.g "81K channels"

# Fallback for video/channel counts
if channel_count_txt.nil? || video_count_txt.nil?
# E.g: "203K videos • 81K channels"
info_text = extract_text(item_contents["hashtagInfoText"]?).try &.split(" • ")

if info_text && info_text.size == 2
video_count_txt ||= info_text[0]
channel_count_txt ||= info_text[1]
end
end

return SearchHashtag.new({
title: title,
url: url,
video_count: short_text_to_number(video_count_txt || ""),
channel_count: short_text_to_number(channel_count_txt || ""),
})
rescue ex
LOGGER.debug("HashtagRendererParser: Failed to extract renderer.")
LOGGER.debug("HashtagRendererParser: Got exception: #{ex.message}")
return nil
end

def self.parser_name
return {{@type.name}}
end
end

# Parses a InnerTube gridPlaylistRenderer into a SearchPlaylist. Returns nil when the given object isn't a gridPlaylistRenderer
#
# A gridPlaylistRenderer renders a playlist, that is located in a grid, to click on within the YouTube and Invidious UI.
Expand Down