From 8000d538dbbf1eb9c78e000b1449926ba3b24da9 Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 7 May 2021 05:13:53 -0700 Subject: [PATCH] Add parser for categories (shelfRenderer) This commit adds a new parser for YT's shelfRenderers which are typically used to denote different categories.The code for featured channels parsing has also been moved to use the new parser but some additional refactoring are needed there. The ContinuationExtractor has also been improved and is now capable of extraction continuation data that is packaged under "appendContinuationItemsAction" In additional this commit adds some useful helper functions to extract the current selected tab the continuation token. This is to mainly reduce code size and repetition. --- src/invidious/channels.cr | 63 ++++- src/invidious/featured_channels.cr | 170 ------------ src/invidious/helpers/extractors.cr | 141 ++++++++-- src/invidious/helpers/helpers.cr | 30 +- src/invidious/helpers/invidiousitems.cr | 258 ++++++++++++++++++ src/invidious/routes/channels.cr | 1 - src/invidious/search.cr | 232 ---------------- .../views/channel/featured_channels.ecr | 17 +- src/invidious/views/components/item.ecr | 1 + 9 files changed, 472 insertions(+), 441 deletions(-) delete mode 100644 src/invidious/featured_channels.cr create mode 100644 src/invidious/helpers/invidiousitems.cr diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr index 0018e5c9..f32d457d 100644 --- a/src/invidious/channels.cr +++ b/src/invidious/channels.cr @@ -380,24 +380,73 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by) return items, continuation end -def fetch_channel_featured_channels(ucid, tab_data, params = nil, continuation = nil, title = nil) +def fetch_channel_featured_channels(ucid, tab_data, params = nil, continuation = nil, query_title = nil) if continuation.is_a?(String) initial_data = request_youtube_api_browse(continuation) - channels_tab_content = initial_data["onResponseReceivedActions"][0]["appendContinuationItemsAction"]["continuationItems"] + items = extract_items(initial_data) + continuation_token = fetch_continuation_token(initial_data) - return process_featured_channels([channels_tab_content], nil, title, continuation_items = true) + return [Category.new({ + title: query_title.not_nil!, # If continuation contents is requested then the query_title has to be passed along. + contents: items, + browse_endpoint_data: nil, + continuation_token: continuation_token, + badges: nil, + })] else if params.is_a?(String) initial_data = request_youtube_api_browse(ucid, params) + continuation_token = fetch_continuation_token(initial_data) else initial_data = request_youtube_api_browse(ucid, tab_data[1]) + continuation_token = nil end - channels_tab = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"][tab_data[0]]["tabRenderer"] - channels_tab_content = channels_tab["content"]["sectionListRenderer"]["contents"].as_a - submenu_data = channels_tab["content"]["sectionListRenderer"]["subMenu"]?.try &.["channelSubMenuRenderer"]["contentTypeSubMenuItems"] || false + channels_tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]) + submenu = channels_tab["content"]["sectionListRenderer"]["subMenu"]? - return process_featured_channels(channels_tab_content, submenu_data) + # There's no submenu data if the channel doesn't feature any channels. + if !submenu + return [] of Category + end + + submenu_data = submenu["channelSubMenuRenderer"]["contentTypeSubMenuItems"] + + items = extract_items(initial_data) + fallback_title = submenu_data.as_a.select(&.["selected"].as_bool)[0]["title"].as_s + + # Although extract_items parsed everything into the right structs, we still have + # to fill in the title (if missing) attribute since Youtube doesn't return it when requesting + # a full category + + category_array = [] of Category + items.each do |category| + # Tell compiler that the result from extract_items has to be an array of Categories + if !category.is_a?(Category) + next + end + + category_array << Category.new({ + title: category.title.empty? ? fallback_title : category.title, + contents: category.contents, + browse_endpoint_data: category.browse_endpoint_data, + continuation_token: continuation_token, + badges: nil, + }) + end + + # If we don't have any categories we'll create one. + if category_array.empty? + return [Category.new({ + title: fallback_title, # If continuation contents is requested then the query_title has to be passed along. + contents: items, + browse_endpoint_data: nil, + continuation_token: continuation_token, + badges: nil, + })] + end + + return category_array end end diff --git a/src/invidious/featured_channels.cr b/src/invidious/featured_channels.cr deleted file mode 100644 index e1486403..00000000 --- a/src/invidious/featured_channels.cr +++ /dev/null @@ -1,170 +0,0 @@ -struct FeaturedChannel - include DB::Serializable - - property author : String - property ucid : String - property author_thumbnail : String - property subscriber_count : Int32 - property video_count : Int32 - property description_html : String? - - def to_json(locale, json : JSON::Builder) - json.object do - json.field "author", self.author - json.field "authorId", self.ucid - json.field "authorUrl", "/channel/#{self.ucid}" - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "description", html_to_content(self.description_html) - json.field "descriptionHtml", self.description_html - json.field "subCount", self.subscriber_count - json.field "videoCount", self.video_count - json.field "badges", self.badges - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end -end - -struct Category - include DB::Serializable - - property title : String - property contents : Array(FeaturedChannel) | FeaturedChannel - property browse_endpoint_param : String? - property continuation_token : String? - - def to_json(locale, json : JSON::Builder) - json.object do - json.field "title", self.title - json.field "contents", self.contents - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end -end - -def _extract_channel_data(channel) - ucid = channel["channelId"].as_s - author = channel["title"]["simpleText"].as_s - author_thumbnail = channel["thumbnail"]["thumbnails"].as_a[0]["url"].as_s - subscriber_count = channel["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s? - .try { |text| short_text_to_number(text.split(" ")[0]) } || 0 - - video_count = channel["videoCountText"]?.try &.["runs"][0]["text"].as_s.gsub(/\D/, "").to_i || 0 - - if channel["descriptionSnippet"]? - description = channel["descriptionSnippet"]["runs"][0]["text"].as_s - description_html = HTML.escape(description).gsub("\n", "") - else - description_html = nil - end - - FeaturedChannel.new({ - author: author, - ucid: ucid, - author_thumbnail: author_thumbnail, - subscriber_count: subscriber_count, - video_count: video_count, - description_html: description_html, - }) -end - -def process_featured_channels(data, submenu_data, title = nil, continuation_items = false) - all_categories = [] of Category - - if submenu_data.is_a?(Bool) - return all_categories - end - - # Extraction process differs when there's more than one category - if data.size > 1 - data.each do |raw_category| - raw_category = raw_category["itemSectionRenderer"]["contents"].as_a[0]["shelfRenderer"] - - category_title = raw_category["title"]["runs"][0]["text"].as_s - browse_endpoint_param = raw_category["endpoint"]["browseEndpoint"]["params"].as_s - - # Category has multiple channels - if raw_category["content"].as_h.has_key?("horizontalListRenderer") - contents = [] of FeaturedChannel - raw_category["content"]["horizontalListRenderer"]["items"].as_a.each do |channel| - contents << _extract_channel_data(channel["gridChannelRenderer"]) - end - # Single channel - else - channel = raw_category["content"]["expandedShelfContentsRenderer"]["items"][0]["channelRenderer"] - contents = _extract_channel_data(channel) - end - - all_categories << Category.new({ - title: category_title, - contents: contents, - browse_endpoint_param: browse_endpoint_param, - continuation_token: nil, - }) - end - else - if !continuation_items - raw_category_contents = data[0]["itemSectionRenderer"]["contents"].as_a[0]["gridRenderer"]["items"].as_a - else - raw_category_contents = data[0].as_a - end - - category_title = submenu_data.try &.[0]["title"].as_s || title || "" - - browse_endpoint_param = nil # Not needed - continuation_token = nil - - # If a continuation token is needed it'll always be after at least twelve channels - if raw_category_contents.size > 12 - continuation_token = raw_category_contents[-1]["continuationItemRenderer"]?.try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s || nil - - if !continuation_token.nil? - raw_category_contents = raw_category_contents[0..-2] - end - end - - contents = [] of FeaturedChannel - raw_category_contents.each do |channel| - contents << _extract_channel_data(channel["gridChannelRenderer"]) - end - - all_categories << Category.new({ - title: category_title, - contents: contents, - browse_endpoint_param: browse_endpoint_param, - continuation_token: continuation_token, - }) - end - - return all_categories -end diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index 6e16c879..a9523eb8 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -1,11 +1,11 @@ -# This file contains helper methods to parse the Youtube API json data into +# This file contains helper methods to parse the Youtube API json data into # neat little packages we can use # Tuple of Parsers/Extractors so we can easily cycle through them. private ITEM_CONTAINER_EXTRACTOR = { YoutubeTabsExtractor.new, SearchResultsExtractor.new, - ContinuationExtractor.new + ContinuationExtractor.new, } private ITEM_PARSERS = { @@ -13,6 +13,7 @@ private ITEM_PARSERS = { ChannelParser.new, GridPlaylistParser.new, PlaylistParser.new, + CategoryParser.new, } private struct AuthorFallback @@ -33,7 +34,7 @@ private class ItemParser private def parse(item_contents : JSON::Any, author_fallback : AuthorFallback) end -end +end private class VideoParser < ItemParser def process(item, author_fallback) @@ -98,7 +99,7 @@ end private class ChannelParser < ItemParser def process(item, author_fallback) - if item_contents = item["channelRenderer"]? + if item_contents = (item["channelRenderer"]? || item["gridChannelRenderer"]?) return self.parse(item_contents, author_fallback) end end @@ -197,7 +198,89 @@ private class PlaylistParser < ItemParser end end -# The following are the extractors for extracting an array of items from +private class CategoryParser < ItemParser + def process(item, author_fallback) + if item_contents = item["shelfRenderer"]? + return self.parse(item_contents, author_fallback) + end + end + + def parse(item_contents, author_fallback) + # Title extraction is a bit complicated. There are two possible routes for it + # as well as times when the title attribute just isn't sent by YT. + + title_container = item_contents["title"]? || "" + if !title_container.is_a? String + if title = title_container["simpleText"]? + title = title.as_s + else + title = title_container["runs"][0]["text"].as_s + end + else + title = "" + end + + browse_endpoint = item_contents["endpoint"]?.try &.["browseEndpoint"] || nil + browse_endpoint_data = "" + category_type = 0 # 0: Video, 1: Channels, 2: Playlist/feed, 3: trending + + # There's no endpoint data for video and trending category + if !item_contents["endpoint"]? + if !item_contents["videoId"]? + category_type = 3 + end + end + + if !browse_endpoint.nil? + # Playlist/feed categories doesn't need the params value (nor is it even included in yt response) + # instead it uses the browseId parameter. So if there isn't a params value we can assume the + # category is a playlist/feed + if browse_endpoint["params"]? + browse_endpoint_data = browse_endpoint["params"].as_s + category_type = 1 + else + browse_endpoint_data = browse_endpoint["browseId"].as_s + category_type = 2 + end + end + + # Sometimes a category can have badges. + badges = [] of Tuple(String, String) # (Badge style, label) + item_contents["badges"]?.try &.as_a.each do |badge| + badge = badge["metadataBadgeRenderer"] + badges << {badge["style"].as_s, badge["label"].as_s} + end + + # Content parsing + contents = [] of SearchItem + + # Content could be in three locations. + if content_container = item_contents["content"]["horizontalListRenderer"]? + elsif content_container = item_contents["content"]["expandedShelfContentsRenderer"] + elsif content_container = item_contents["content"]["verticalListRenderer"] + else + content_container = item_contents["contents"] + end + + raw_contents = content_container["items"].as_a + raw_contents.each do |item| + result = extract_item(item) + if !result.nil? + contents << result + end + end + + Category.new({ + title: title, + contents: contents, + browse_endpoint_data: browse_endpoint_data, + continuation_token: nil, + badges: badges, + }) + end +end + +# The following are the extractors for extracting an array of items from # the internal Youtube API's JSON response. The result is then packaged into # a structure we can more easily use via the parsers above. Their internals are # identical to the item parsers. @@ -220,25 +303,22 @@ private class YoutubeTabsExtractor < ItemsContainerExtractor private def extract(target) raw_items = [] of JSON::Any selected_tab = extract_selected_tab(target["tabs"]) - content = selected_tab["tabRenderer"]["content"] + content = selected_tab["content"] - content["sectionListRenderer"]["contents"].as_a.each do | renderer_container | + content["sectionListRenderer"]["contents"].as_a.each do |renderer_container| renderer_container = renderer_container["itemSectionRenderer"] renderer_container_contents = renderer_container["contents"].as_a[0] - # Shelf renderer usually refer to a category and would need special handling once - # An extractor for categories are added. But for now it is just used to - # extract items for the trending page + # Category extraction if items_container = renderer_container_contents["shelfRenderer"]? - if items_container["content"]["expandedShelfContentsRenderer"]? - items_container = items_container["content"]["expandedShelfContentsRenderer"] - end - elsif items_container = renderer_container_contents["gridRenderer"]? + raw_items << renderer_container_contents + next + elsif items_container = renderer_container_contents["gridRenderer"]? else items_container = renderer_container_contents end - items_container["items"].as_a.each do | item | + items_container["items"].as_a.each do |item| raw_items << item end end @@ -268,6 +348,8 @@ private class ContinuationExtractor < ItemsContainerExtractor def process(initial_data) if target = initial_data["continuationContents"]? self.extract(target) + elsif target = initial_data["appendContinuationItemsAction"]? + self.extract(target) end end @@ -275,20 +357,23 @@ private class ContinuationExtractor < ItemsContainerExtractor raw_items = [] of JSON::Any if content = target["gridContinuation"]? raw_items = content["items"].as_a + elsif content = target["continuationItems"]? + raw_items = content.as_a end return raw_items end end -def extract_item(item : JSON::Any, author_fallback : String? = nil, author_id_fallback : String? = nil) +def extract_item(item : JSON::Any, author_fallback : String? = nil, + author_id_fallback : String? = nil) # Parses an item from Youtube's JSON response into a more usable structure. # The end result can either be a SearchVideo, SearchPlaylist or SearchChannel. author_fallback = AuthorFallback.new(author_fallback, author_id_fallback) # Cycles through all of the item parsers and attempt to parse the raw YT JSON data. - # 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 attemped. + # 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 attemped. ITEM_PARSERS.each do |parser| result = parser.process(item, author_fallback) if !result.nil? @@ -298,23 +383,31 @@ def extract_item(item : JSON::Any, author_fallback : String? = nil, author_id_fa # TODO radioRenderer, showRenderer, shelfRenderer, horizontalCardListRenderer, searchPyvRenderer end -def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) +def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, + author_id_fallback : String? = nil) items = [] of SearchItem - initial_data = initial_data["contents"]?.try &.as_h || initial_data["response"]?.try &.as_h || initial_data + + if unpackaged_data = initial_data["contents"]?.try &.as_h + elsif unpackaged_data = initial_data["response"]?.try &.as_h + elsif unpackaged_data = initial_data["onResponseReceivedActions"]?.try &.as_a.[0].as_h + else + unpackaged_data = initial_data + end # This is identicial to the parser cyling of extract_item(). - ITEM_CONTAINER_EXTRACTOR.each do | extractor | - results = extractor.process(initial_data) + ITEM_CONTAINER_EXTRACTOR.each do |extractor| + results = extractor.process(unpackaged_data) if !results.nil? - results.each do | item | + results.each do |item| parsed_result = extract_item(item, author_fallback, author_id_fallback) if !parsed_result.nil? items << parsed_result end end + return items end end return items -end \ No newline at end of file +end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 7c234f3c..7d687567 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -248,12 +248,38 @@ def html_to_content(description_html : String) end def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) - extract_items(initial_data, author_fallback, author_id_fallback).select(&.is_a?(SearchVideo)).map(&.as(SearchVideo)) + extracted = extract_items(initial_data, author_fallback, author_id_fallback) + + if extracted.is_a?(Category) + target = extracted.contents + else + target = extracted + end + return target.select(&.is_a?(SearchVideo)).map(&.as(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"].as_bool)[0] + return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].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 def check_enum(db, enum_name, struct_type = nil) diff --git a/src/invidious/helpers/invidiousitems.cr b/src/invidious/helpers/invidiousitems.cr new file mode 100644 index 00000000..8694ae97 --- /dev/null +++ b/src/invidious/helpers/invidiousitems.cr @@ -0,0 +1,258 @@ +struct SearchVideo + include DB::Serializable + + property title : String + property id : String + property author : String + property ucid : String + property published : Time + property views : Int64 + property description_html : String + property length_seconds : Int32 + property live_now : Bool + property paid : Bool + property premium : Bool + property premiere_timestamp : Time? + + def to_xml(auto_generated, query_params, xml : XML::Builder) + query_params["v"] = self.id + + xml.element("entry") do + xml.element("id") { xml.text "yt:video:#{self.id}" } + xml.element("yt:videoId") { xml.text self.id } + xml.element("yt:channelId") { xml.text self.ucid } + xml.element("title") { xml.text self.title } + xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}") + + xml.element("author") do + if auto_generated + xml.element("name") { xml.text self.author } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } + else + xml.element("name") { xml.text author } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" } + end + end + + xml.element("content", type: "xhtml") do + xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do + xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do + xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") + end + + xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text html_to_content(self.description_html) } + end + end + + xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") } + + xml.element("media:group") do + xml.element("media:title") { xml.text self.title } + xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg", + width: "320", height: "180") + xml.element("media:description") { xml.text html_to_content(self.description_html) } + end + + xml.element("media:community") do + xml.element("media:statistics", views: self.views) + end + end + end + + def to_xml(auto_generated, query_params, xml : XML::Builder | Nil = nil) + if xml + to_xml(HOST_URL, auto_generated, query_params, xml) + else + XML.build do |json| + to_xml(HOST_URL, auto_generated, query_params, xml) + end + end + end + + def to_json(locale : Hash(String, JSON::Any), json : JSON::Builder) + json.object do + json.field "type", "video" + json.field "title", self.title + json.field "videoId", self.id + + json.field "author", self.author + json.field "authorId", self.ucid + json.field "authorUrl", "/channel/#{self.ucid}" + + json.field "videoThumbnails" do + generate_thumbnails(json, self.id) + end + + json.field "description", html_to_content(self.description_html) + json.field "descriptionHtml", self.description_html + + json.field "viewCount", self.views + json.field "published", self.published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) + json.field "lengthSeconds", self.length_seconds + json.field "liveNow", self.live_now + json.field "paid", self.paid + json.field "premium", self.premium + json.field "isUpcoming", self.is_upcoming + + if self.premiere_timestamp + json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix + end + end + end + + def to_json(locale, json : JSON::Builder | Nil = nil) + if json + to_json(locale, json) + else + JSON.build do |json| + to_json(locale, json) + end + end + end + + def is_upcoming + premiere_timestamp ? true : false + end +end + +struct SearchPlaylistVideo + include DB::Serializable + + property title : String + property id : String + property length_seconds : Int32 +end + +struct SearchPlaylist + include DB::Serializable + + property title : String + property id : String + property author : String + property ucid : String + property video_count : Int32 + property videos : Array(SearchPlaylistVideo) + property thumbnail : String? + + def to_json(locale, json : JSON::Builder) + json.object do + json.field "type", "playlist" + json.field "title", self.title + json.field "playlistId", self.id + json.field "playlistThumbnail", self.thumbnail + + json.field "author", self.author + json.field "authorId", self.ucid + json.field "authorUrl", "/channel/#{self.ucid}" + + json.field "videoCount", self.video_count + json.field "videos" do + json.array do + self.videos.each do |video| + json.object do + json.field "title", video.title + json.field "videoId", video.id + json.field "lengthSeconds", video.length_seconds + + json.field "videoThumbnails" do + generate_thumbnails(json, video.id) + end + end + end + end + end + end + end + + def to_json(locale, json : JSON::Builder | Nil = nil) + if json + to_json(locale, json) + else + JSON.build do |json| + to_json(locale, json) + end + end + end +end + +struct SearchChannel + include DB::Serializable + + property author : String + property ucid : String + property author_thumbnail : String + property subscriber_count : Int32 + property video_count : Int32 + property description_html : String + property auto_generated : Bool + + def to_json(locale, json : JSON::Builder) + json.object do + json.field "type", "channel" + json.field "author", self.author + json.field "authorId", self.ucid + json.field "authorUrl", "/channel/#{self.ucid}" + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + + json.field "autoGenerated", self.auto_generated + json.field "subCount", self.subscriber_count + json.field "videoCount", self.video_count + + json.field "description", html_to_content(self.description_html) + json.field "descriptionHtml", self.description_html + end + end + + def to_json(locale, json : JSON::Builder | Nil = nil) + if json + to_json(locale, json) + else + JSON.build do |json| + to_json(locale, json) + end + end + end +end + +class Category + include DB::Serializable + + property title : String + property contents : Array(SearchItem) | SearchItem + property browse_endpoint_data : String? + property continuation_token : String? + property badges : Array(Tuple(String, String))? + + def to_json(locale, json : JSON::Builder) + json.object do + json.field "title", self.title + json.field "contents", self.contents + end + end + + def to_json(locale, json : JSON::Builder | Nil = nil) + if json + to_json(locale, json) + else + JSON.build do |json| + to_json(locale, json) + end + end + end +end + +alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | Category diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index d96f7c46..3d64d796 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -102,7 +102,6 @@ class Invidious::Routes::Channels < Invidious::Routes::BaseRoute return env.redirect "/channel/#{channel.ucid}" end - # When a channel only has a single category it lacks the category param option so we'll handle it here. if continuation offset = env.params.query["offset"]? if offset diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 6d4afc03..60d95bcd 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -1,235 +1,3 @@ -struct SearchVideo - include DB::Serializable - - property title : String - property id : String - property author : String - property ucid : String - property published : Time - property views : Int64 - property description_html : String - property length_seconds : Int32 - property live_now : Bool - property paid : Bool - property premium : Bool - property premiere_timestamp : Time? - - def to_xml(auto_generated, query_params, xml : XML::Builder) - query_params["v"] = self.id - - xml.element("entry") do - xml.element("id") { xml.text "yt:video:#{self.id}" } - xml.element("yt:videoId") { xml.text self.id } - xml.element("yt:channelId") { xml.text self.ucid } - xml.element("title") { xml.text self.title } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}") - - xml.element("author") do - if auto_generated - xml.element("name") { xml.text self.author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } - else - xml.element("name") { xml.text author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" } - end - end - - xml.element("content", type: "xhtml") do - xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do - xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do - xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") - end - - xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text html_to_content(self.description_html) } - end - end - - xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") } - - xml.element("media:group") do - xml.element("media:title") { xml.text self.title } - xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg", - width: "320", height: "180") - xml.element("media:description") { xml.text html_to_content(self.description_html) } - end - - xml.element("media:community") do - xml.element("media:statistics", views: self.views) - end - end - end - - def to_xml(auto_generated, query_params, xml : XML::Builder | Nil = nil) - if xml - to_xml(HOST_URL, auto_generated, query_params, xml) - else - XML.build do |json| - to_xml(HOST_URL, auto_generated, query_params, xml) - end - end - end - - def to_json(locale, json : JSON::Builder) - json.object do - json.field "type", "video" - json.field "title", self.title - json.field "videoId", self.id - - json.field "author", self.author - json.field "authorId", self.ucid - json.field "authorUrl", "/channel/#{self.ucid}" - - json.field "videoThumbnails" do - generate_thumbnails(json, self.id) - end - - json.field "description", html_to_content(self.description_html) - json.field "descriptionHtml", self.description_html - - json.field "viewCount", self.views - json.field "published", self.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) - json.field "lengthSeconds", self.length_seconds - json.field "liveNow", self.live_now - json.field "paid", self.paid - json.field "premium", self.premium - json.field "isUpcoming", self.is_upcoming - - if self.premiere_timestamp - json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix - end - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end - - def is_upcoming - premiere_timestamp ? true : false - end -end - -struct SearchPlaylistVideo - include DB::Serializable - - property title : String - property id : String - property length_seconds : Int32 -end - -struct SearchPlaylist - include DB::Serializable - - property title : String - property id : String - property author : String - property ucid : String - property video_count : Int32 - property videos : Array(SearchPlaylistVideo) - property thumbnail : String? - - def to_json(locale, json : JSON::Builder) - json.object do - json.field "type", "playlist" - json.field "title", self.title - json.field "playlistId", self.id - json.field "playlistThumbnail", self.thumbnail - - json.field "author", self.author - json.field "authorId", self.ucid - json.field "authorUrl", "/channel/#{self.ucid}" - - json.field "videoCount", self.video_count - json.field "videos" do - json.array do - self.videos.each do |video| - json.object do - json.field "title", video.title - json.field "videoId", video.id - json.field "lengthSeconds", video.length_seconds - - json.field "videoThumbnails" do - generate_thumbnails(json, video.id) - end - end - end - end - end - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end -end - -struct SearchChannel - include DB::Serializable - - property author : String - property ucid : String - property author_thumbnail : String - property subscriber_count : Int32 - property video_count : Int32 - property description_html : String - property auto_generated : Bool - - def to_json(locale, json : JSON::Builder) - json.object do - json.field "type", "channel" - json.field "author", self.author - json.field "authorId", self.ucid - json.field "authorUrl", "/channel/#{self.ucid}" - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "autoGenerated", self.auto_generated - json.field "subCount", self.subscriber_count - json.field "videoCount", self.video_count - - json.field "description", html_to_content(self.description_html) - json.field "descriptionHtml", self.description_html - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end -end - -alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist - def channel_search(query, page, channel) response = YT_POOL.client &.get("/channel/#{channel}") diff --git a/src/invidious/views/channel/featured_channels.ecr b/src/invidious/views/channel/featured_channels.ecr index 118fb48d..e2e49d24 100644 --- a/src/invidious/views/channel/featured_channels.ecr +++ b/src/invidious/views/channel/featured_channels.ecr @@ -14,7 +14,7 @@

- <% if (category_request_param = category.browse_endpoint_param).is_a?(String) %> + <% if (category_request_param = category.browse_endpoint_data).is_a?(String) %> <%= category.title %> @@ -25,8 +25,12 @@

<% contents = category.contents%>
- <% if contents.is_a?(Array(FeaturedChannel)) %> + <% if contents.is_a?(Array) %> <% contents.each do |item|%> + <% if !item.is_a?(SearchChannel)%> + <% next %> + <% end %> +
<%end%> - <% elsif contents.is_a?(FeaturedChannel) %> + <% elsif contents.is_a?(SearchItem) %> + <% if !contents.is_a?(SearchChannel)%> + <% next %> + <% end %> + <%item = contents %> - <%end%> + <% end %>
- <% end %> <% else %>

diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 6f027bee..ff486044 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -97,6 +97,7 @@ <%= item.responds_to?(:views) && item.views ? translate(locale, "`x` views", number_to_short_text(item.views || 0)) : "" %>

+ <% when Category %> <% else %> <% if !env.get("preferences").as(Preferences).thin_mode %>