<% end %>
diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr
index 3bc29e55..9e11d562 100644
--- a/src/invidious/views/community.ecr
+++ b/src/invidious/views/community.ecr
@@ -1,71 +1,21 @@
-<% ucid = channel.ucid %>
-<% author = HTML.escape(channel.author) %>
+<%-
+ ucid = channel.ucid
+ author = HTML.escape(channel.author)
+ channel_profile_pic = URI.parse(channel.author_thumbnail).request_target
+
+ relative_url = "/channel/#{ucid}/community"
+ youtube_url = "https://www.youtube.com#{relative_url}"
+ redirect_url = Invidious::Frontend::Misc.redirect_url(env)
+
+ selected_tab = Invidious::Frontend::ChannelPage::TabsAvailable::Community
+-%>
<% content_for "header" do %>
+
- <% sub_count_text = number_to_short_text(channel.sub_count) %>
- <%= rendered "components/subscribe_widget" %>
-
diff --git a/src/invidious/views/components/channel_info.ecr b/src/invidious/views/components/channel_info.ecr
new file mode 100644
index 00000000..f216359f
--- /dev/null
+++ b/src/invidious/views/components/channel_info.ecr
@@ -0,0 +1,60 @@
+<% if channel.banner %>
+
+
">
+
+
+
+
+
+<% end %>
+
+
+
+
+
+
<%= author %><% if !channel.verified.nil? && channel.verified %>
<% end %>
+
+
+
+
+
+
+
+
<%= channel.description_html %>
+
+
+
+
+ <% sub_count_text = number_to_short_text(channel.sub_count) %>
+ <%= rendered "components/subscribe_widget" %>
+
+
+
+
+
+
+
+ <%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %>
+
+
+
+ <% sort_options.each do |sort| %>
+
+ <% end %>
+
+
+
diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr
deleted file mode 100644
index c8718e7b..00000000
--- a/src/invidious/views/playlists.ecr
+++ /dev/null
@@ -1,108 +0,0 @@
-<% ucid = channel.ucid %>
-<% author = HTML.escape(channel.author) %>
-
-<% content_for "header" do %>
-
<%= author %> - Invidious
-<% end %>
-
-<% if channel.banner %>
-
-
">
-
-
-
-
-
-<% end %>
-
-
-
-
-
-
<%= author %><% if !channel.verified.nil? && channel.verified %>
<% end %>
-
-
-
-
-
-
-
-
<%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content if !channel.description_html.empty? %>
-
-
-
-
- <% sub_count_text = number_to_short_text(channel.sub_count) %>
- <%= rendered "components/subscribe_widget" %>
-
-
-
-
-
-
-
-
-
-
- <% if !channel.auto_generated %>
- <%= translate(locale, "Playlists") %>
- <% end %>
-
-
-
-
-
-
- <% {"last", "oldest", "newest"}.each do |sort| %>
-
- <% end %>
-
-
-
-
-
-
-
-
-
-<% items.each do |item| %>
- <%= rendered "components/item" %>
-<% end %>
-
-
-
diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr
index edc722cf..65d107b2 100644
--- a/src/invidious/yt_backend/extractors.cr
+++ b/src/invidious/yt_backend/extractors.cr
@@ -7,7 +7,7 @@ require "../helpers/serialized_yt_data"
private ITEM_CONTAINER_EXTRACTOR = {
Extractors::YouTubeTabs,
Extractors::SearchResults,
- Extractors::Continuation,
+ Extractors::ContinuationContent,
}
private ITEM_PARSERS = {
@@ -18,8 +18,11 @@ private ITEM_PARSERS = {
Parsers::CategoryRendererParser,
Parsers::RichItemRendererParser,
Parsers::ReelItemRendererParser,
+ Parsers::ContinuationItemRendererParser,
}
+private alias InitialData = Hash(String, JSON::Any)
+
record AuthorFallback, name : String, id : String
# Namespace for logic relating to parsing InnerTube data into various datastructs.
@@ -345,14 +348,9 @@ private module Parsers
content_container = item_contents["contents"]
end
- raw_contents = content_container["items"]?.try &.as_a
- if !raw_contents.nil?
- raw_contents.each do |item|
- result = extract_item(item)
- if !result.nil?
- contents << result
- end
- end
+ content_container["items"]?.try &.as_a.each do |item|
+ result = parse_item(item, author_fallback.name, author_fallback.id)
+ contents << result if result.is_a?(SearchItem)
end
Category.new({
@@ -384,7 +382,9 @@ private module Parsers
end
private def self.parse(item_contents, author_fallback)
- return VideoRendererParser.process(item_contents, author_fallback)
+ child = VideoRendererParser.process(item_contents, author_fallback)
+ child ||= ReelItemRendererParser.process(item_contents, author_fallback)
+ return child
end
def self.parser_name
@@ -408,9 +408,19 @@ private module Parsers
private def self.parse(item_contents, author_fallback)
video_id = item_contents["videoId"].as_s
- video_details_container = item_contents.dig(
+ reel_player_overlay = item_contents.dig(
"navigationEndpoint", "reelWatchEndpoint",
- "overlay", "reelPlayerOverlayRenderer",
+ "overlay", "reelPlayerOverlayRenderer"
+ )
+
+ # Sometimes, the "reelPlayerOverlayRenderer" object is missing the
+ # important part of the response. We use this exception to tell
+ # the calling function to fetch the content again.
+ if !reel_player_overlay.as_h.has_key?("reelPlayerHeaderSupportedRenderers")
+ raise RetryOnceException.new
+ end
+
+ video_details_container = reel_player_overlay.dig(
"reelPlayerHeaderSupportedRenderers",
"reelPlayerHeaderRenderer"
)
@@ -436,9 +446,9 @@ private module Parsers
# View count
- view_count_text = video_details_container.dig?("viewCountText", "simpleText")
- view_count_text ||= video_details_container
- .dig?("viewCountText", "accessibility", "accessibilityData", "label")
+ # View count used to be in the reelWatchEndpoint, but that changed?
+ view_count_text = item_contents.dig?("viewCountText", "simpleText")
+ view_count_text ||= video_details_container.dig?("viewCountText", "simpleText")
view_count = view_count_text.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64
@@ -450,8 +460,8 @@ private module Parsers
regex_match = /- (?
\d+ minutes? )?(?\d+ seconds?)+ -/.match(a11y_data)
- minutes = regex_match.try &.["min"].to_i(strict: false) || 0
- seconds = regex_match.try &.["sec"].to_i(strict: false) || 0
+ minutes = regex_match.try &.["min"]?.try &.to_i(strict: false) || 0
+ seconds = regex_match.try &.["sec"]?.try &.to_i(strict: false) || 0
duration = (minutes*60 + seconds)
@@ -475,6 +485,35 @@ private module Parsers
return {{@type.name}}
end
end
+
+ # Parses an InnerTube continuationItemRenderer into a Continuation.
+ # Returns nil when the given object isn't a continuationItemRenderer.
+ #
+ # continuationItemRenderer contains various metadata ued to load more
+ # content (i.e when the user scrolls down). The interesting bit is the
+ # protobuf object known as the "continutation token". Previously, those
+ # were generated from sratch, but recent (as of 11/2022) Youtube changes
+ # are forcing us to extract them from replies.
+ #
+ module ContinuationItemRendererParser
+ def self.process(item : JSON::Any, author_fallback : AuthorFallback)
+ if item_contents = item["continuationItemRenderer"]?
+ return self.parse(item_contents)
+ end
+ end
+
+ private def self.parse(item_contents)
+ token = item_contents
+ .dig?("continuationEndpoint", "continuationCommand", "token")
+ .try &.as_s
+
+ return Continuation.new(token) if token
+ end
+
+ def self.parser_name
+ return {{@type.name}}
+ end
+ end
end
# The following are the extractors for extracting an array of items from
@@ -510,7 +549,7 @@ private module Extractors
# }]
#
module YouTubeTabs
- def self.process(initial_data : Hash(String, JSON::Any))
+ def self.process(initial_data : InitialData)
if target = initial_data["twoColumnBrowseResultsRenderer"]?
self.extract(target)
end
@@ -575,7 +614,7 @@ private module Extractors
# }
#
module SearchResults
- def self.process(initial_data : Hash(String, JSON::Any))
+ def self.process(initial_data : InitialData)
if target = initial_data["twoColumnSearchResultsRenderer"]?
self.extract(target)
end
@@ -608,8 +647,8 @@ private module Extractors
# The way they are structured is too varied to be accurately written down here.
# However, they all eventually lead to an array of parsable items after traversing
# through the JSON structure.
- module Continuation
- def self.process(initial_data : Hash(String, JSON::Any))
+ module ContinuationContent
+ def self.process(initial_data : InitialData)
if target = initial_data["continuationContents"]?
self.extract(target)
elsif target = initial_data["appendContinuationItemsAction"]?
@@ -691,8 +730,7 @@ end
# Parses an item from Youtube's JSON response into a more usable structure.
# The end result can either be a SearchVideo, SearchPlaylist or SearchChannel.
-def extract_item(item : JSON::Any, author_fallback : String? = "",
- author_id_fallback : String? = "")
+def parse_item(item : JSON::Any, author_fallback : String? = "", author_id_fallback : String? = "")
# We "allow" nil values but secretly use empty strings instead. This is to save us the
# hassle of modifying every author_fallback and author_id_fallback arg usage
# which is more often than not nil.
@@ -702,24 +740,23 @@ def extract_item(item : JSON::Any, author_fallback : String? = "",
# Each parser automatically validates the data given to see if the data is
# applicable to itself. If not nil is returned and the next parser is attempted.
ITEM_PARSERS.each do |parser|
- LOGGER.trace("extract_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)")
+ LOGGER.trace("parse_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)")
if result = parser.process(item, author_fallback)
- LOGGER.debug("extract_item: Successfully parsed via #{parser.parser_name}")
-
+ LOGGER.debug("parse_item: Successfully parsed via #{parser.parser_name}")
return result
else
- LOGGER.trace("extract_item: Parser \"#{parser.parser_name}\" does not apply. Cycling to the next one...")
+ LOGGER.trace("parse_item: Parser \"#{parser.parser_name}\" does not apply. Cycling to the next one...")
end
end
end
# Parses multiple items from YouTube's initial JSON response into a more usable structure.
# The end result is an array of SearchItem.
-def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil,
- author_id_fallback : String? = nil) : Array(SearchItem)
- items = [] of SearchItem
-
+#
+# This function yields the container so that items can be parsed separately.
+#
+def extract_items(initial_data : InitialData, &block)
if unpackaged_data = initial_data["contents"]?.try &.as_h
elsif unpackaged_data = initial_data["response"]?.try &.as_h
elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 0).try &.as_h
@@ -727,24 +764,37 @@ def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : Stri
unpackaged_data = initial_data
end
- # This is identical to the parser cycling of extract_item().
+ # This is identical to the parser cycling of parse_item().
ITEM_CONTAINER_EXTRACTOR.each do |extractor|
LOGGER.trace("extract_items: Attempting to extract item container using \"#{extractor.extractor_name}\" (cycling...)")
if container = extractor.process(unpackaged_data)
LOGGER.debug("extract_items: Successfully unpacked container with \"#{extractor.extractor_name}\"")
# Extract items in container
- container.each do |item|
- if parsed_result = extract_item(item, author_fallback, author_id_fallback)
- items << parsed_result
- end
- end
-
- break
+ container.each { |item| yield item }
else
LOGGER.trace("extract_items: Extractor \"#{extractor.extractor_name}\" does not apply. Cycling to the next one...")
end
end
-
- return items
+end
+
+# Wrapper using the block function above
+def extract_items(
+ initial_data : InitialData,
+ author_fallback : String? = nil,
+ author_id_fallback : String? = nil
+) : {Array(SearchItem), String?}
+ items = [] of SearchItem
+ continuation = nil
+
+ extract_items(initial_data) do |item|
+ parsed = parse_item(item, author_fallback, author_id_fallback)
+
+ case parsed
+ when .is_a?(Continuation) then continuation = parsed.token
+ when .is_a?(SearchItem) then items << parsed
+ end
+ end
+
+ return items, continuation
end
diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr
index f8245160..0cb3c079 100644
--- a/src/invidious/yt_backend/extractors_utils.cr
+++ b/src/invidious/yt_backend/extractors_utils.cr
@@ -68,10 +68,10 @@ rescue ex
return false
end
-def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil)
- extracted = extract_items(initial_data, author_fallback, author_id_fallback)
+def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) : Array(SearchVideo)
+ extracted, _ = extract_items(initial_data, author_fallback, author_id_fallback)
- target = [] of SearchItem
+ target = [] of (SearchItem | Continuation)
extracted.each do |i|
if i.is_a?(Category)
i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video }
@@ -79,28 +79,11 @@ def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : Str
target << i
end
end
- return target.select(SearchVideo).map(&.as(SearchVideo))
+
+ return target.select(SearchVideo)
end
def extract_selected_tab(tabs)
# Extract the selected tab from the array of tabs Youtube returns
return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"]
end
-
-def fetch_continuation_token(items : Array(JSON::Any))
- # Fetches the continuation token from an array of items
- return items.last["continuationItemRenderer"]?
- .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s
-end
-
-def fetch_continuation_token(initial_data : Hash(String, JSON::Any))
- # Fetches the continuation token from initial data
- if initial_data["onResponseReceivedActions"]?
- continuation_items = initial_data["onResponseReceivedActions"][0]["appendContinuationItemsAction"]["continuationItems"]
- else
- tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"])
- continuation_items = tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["gridRenderer"]["items"]
- end
-
- return fetch_continuation_token(continuation_items.as_a)
-end