Add support channel home pages + gen. improvements

This commit adds support for channel home pages and all of the
categories within it. However, the frontend code is a mess and thus
needs to be refactor soon. Though that would likely require a rework of
items.ecr

This commit also comes with some general cleanups and improvements.

Before this commit channel brand URls would only be supported on the
videos page (now home page). It has been improved to be able to handle
all channel URLs.

The category_type and auxiliary_data property has also been removed from
the Category struct. The former was never used and the latter allows for
random data to be added to the Struct presenting documentation issues.

Since the auxiliary_data variable was mainly used to store values from
the browse_endpoint in order to create URLs, its much simpler to instead
just get the URL from the webCommandMetadata.

As a result of this change the browse_endpoint_data attribute of
Category has also been removed.
This commit is contained in:
syeopite 2021-05-08 21:09:30 -07:00
parent aa8f15f795
commit 1b569bbc99
No known key found for this signature in database
GPG key ID: 6FA616E5A5294A82
14 changed files with 197 additions and 82 deletions

View file

@ -66,7 +66,6 @@
} }
.category-heading { .category-heading {
font-size: 1.2em;
user-select: none; user-select: none;
display: inline; display: inline;
} }
@ -117,3 +116,23 @@ only show up when the screen is wide enough */
margin-top: 1em; margin-top: 1em;
} }
} }
.trailer-metadata {
margin-left: 15px;
font-size: 12px;
color: rgb(232, 230, 227);
}
.trailer-metadata .read-more {
line-height: 20px;
text-transform: uppercase;
color: gray;
font-size: 13px;
}
.trailer-description {
overflow: hidden;
max-height: 150px;
line-height: 20px;
margin-top: 20px;
}

View file

@ -602,7 +602,8 @@ hr {
} }
.category { .category {
margin: 3em 0px 4em 0px; margin-bottom: 2em;
margin-top: 1em;
} }
.category .heading > p { .category .heading > p {
@ -616,4 +617,9 @@ hr {
border-radius: 5px; border-radius: 5px;
font-size: 14px; font-size: 14px;
margin-left: 10px; margin-left: 10px;
}
/* Temp */
.category-description {
color: #A8A095;
} }

View file

@ -315,6 +315,10 @@ Invidious::Routing.get "/channel/:ucid/community", Invidious::Routes::Channels,
Invidious::Routing.get "/channel/:ucid/channels", Invidious::Routes::Channels, :channels Invidious::Routing.get "/channel/:ucid/channels", Invidious::Routes::Channels, :channels
Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about
["", "/home", "/videos", "/playlists", "/community", "/channels", "/about"].each do |path|
Invidious::Routing.get "/c/:user#{path}", Invidious::Routes::Channels, :brand_redirect
end
Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle
Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect
Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect
@ -1624,22 +1628,6 @@ end
end end
end end
# YouTube appears to let users set a "brand" URL that
# is different from their username, so we convert that here
get "/c/:user" do |env|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
user = env.params.url["user"]
response = YT_POOL.client &.get("/c/#{user}")
html = XML.parse_html(response.body)
ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
next env.redirect "/" if !ucid
env.redirect "/channel/#{ucid}"
end
# Legacy endpoint for /user/:username # Legacy endpoint for /user/:username
get "/profile" do |env| get "/profile" do |env|
user = env.params.query["user"]? user = env.params.query["user"]?

View file

@ -341,6 +341,30 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
return channel return channel
end end
def fetch_channel_home(ucid, channel)
initial_data = request_youtube_api_browse(ucid, channel.tabs["home"][1])
items = extract_items(initial_data, channel.author, channel.ucid)
# Channel trailer needs some slight special handling
home_tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"])
trailer = home_tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["channelVideoPlayerRenderer"]? || nil
home_sections = [] of (Category | Video)
if trailer
trailer = get_video(trailer["videoId"].as_s, PG_DB)
home_sections << trailer
end
items.each do |category|
if category.is_a? Category
home_sections << category
end
end
return home_sections
end
def fetch_channel_playlists(ucid, author, continuation, sort_by) def fetch_channel_playlists(ucid, author, continuation, sort_by)
if continuation if continuation
response_json = request_youtube_api_browse(continuation) response_json = request_youtube_api_browse(continuation)
@ -381,8 +405,6 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by)
end end
def fetch_channel_featured_channels(ucid, tab_data, view = nil, shelf_id = nil, continuation = nil, query_title = nil) : {Array(Category), (String | Nil)} def fetch_channel_featured_channels(ucid, tab_data, view = nil, shelf_id = nil, continuation = nil, query_title = nil) : {Array(Category), (String | Nil)}
auxiliary_data = {} of String => String
if continuation.is_a?(String) if continuation.is_a?(String)
initial_data = request_youtube_api_browse(continuation) initial_data = request_youtube_api_browse(continuation)
items = extract_items(initial_data) items = extract_items(initial_data)
@ -392,14 +414,13 @@ def fetch_channel_featured_channels(ucid, tab_data, view = nil, shelf_id = nil,
title: query_title.not_nil!, # If continuation contents is requested then the query_title has to be passed along. title: query_title.not_nil!, # If continuation contents is requested then the query_title has to be passed along.
contents: items, contents: items,
description_html: "", description_html: "",
browse_endpoint_data: nil, url: nil,
badges: nil, badges: nil,
auxiliary_data: auxiliary_data,
})], continuation_token })], continuation_token
else else
url = nil
if view && shelf_id if view && shelf_id
auxiliary_data["view"] = view url = "/channel/#{ucid}/channels?view=#{view}&shelf_id=#{shelf_id}"
auxiliary_data["shelf_id"] = shelf_id
params = produce_featured_channel_browse_param(view.to_i64, shelf_id.to_i64) params = produce_featured_channel_browse_param(view.to_i64, shelf_id.to_i64)
initial_data = request_youtube_api_browse(ucid, params) initial_data = request_youtube_api_browse(ucid, params)
@ -437,21 +458,20 @@ def fetch_channel_featured_channels(ucid, tab_data, view = nil, shelf_id = nil,
title: category.title.empty? ? fallback_title : category.title, title: category.title.empty? ? fallback_title : category.title,
contents: category.contents, contents: category.contents,
description_html: category.description_html, description_html: category.description_html,
browse_endpoint_data: nil, url: category.url,
badges: nil, badges: nil,
auxiliary_data: category.auxiliary_data,
}) })
end end
# If we don't have any categories we'll create one. # If no categories has been parsed then it means that we're currently requesting a single one and not in
# the initial preview anymore. The frontend still needs a Category however, so we'll create one.
if category_array.empty? if category_array.empty?
category_array << Category.new({ category_array << Category.new({
title: fallback_title, title: fallback_title,
contents: items, contents: items,
description_html: "", description_html: "",
browse_endpoint_data: nil, url: url,
badges: nil, badges: nil,
auxiliary_data: auxiliary_data,
}) })
end end

View file

@ -219,37 +219,7 @@ private class CategoryParser < ItemParser
title = "" title = ""
end end
auxiliary_data = {} of String => String url = item_contents["endpoint"]?.try &.["commandMetadata"]["webCommandMetadata"]["url"].as_s
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"]?
# However, even though the channel category type returns the browse endpoint param
# we're not going to be using it in order to preserve compatablity with Youtube.
# and for an URL that looks cleaner
url = item_contents["endpoint"]["commandMetadata"]["webCommandMetadata"]["url"]
url = URI.parse(url.as_s)
auxiliary_data["view"] = url.query_params["view"]
auxiliary_data["shelf_id"] = url.query_params["shelf_id"]
category_type = 1
else
browse_endpoint_data = browse_endpoint["browseId"].as_s
category_type = 2
end
end
# Sometimes a category can have badges. # Sometimes a category can have badges.
badges = [] of Tuple(String, String) # (Badge style, label) badges = [] of Tuple(String, String) # (Badge style, label)
@ -284,9 +254,8 @@ private class CategoryParser < ItemParser
title: title, title: title,
contents: contents, contents: contents,
description_html: description_html, description_html: description_html,
browse_endpoint_data: browse_endpoint_data, url: url,
badges: badges, badges: badges,
auxiliary_data: auxiliary_data,
}) })
end end
end end
@ -325,6 +294,9 @@ private class YoutubeTabsExtractor < ItemsContainerExtractor
raw_items << renderer_container_contents raw_items << renderer_container_contents
next next
elsif items_container = renderer_container_contents["gridRenderer"]? elsif items_container = renderer_container_contents["gridRenderer"]?
elsif items_container = renderer_container_contents["channelVideoPlayerRenderer"]?
# Parsing for channel trailer is already taken elsewhere
next
else else
items_container = renderer_container_contents items_container = renderer_container_contents
end end

View file

@ -232,14 +232,11 @@ class Category
include DB::Serializable include DB::Serializable
property title : String property title : String
property contents : Array(SearchItem) property contents : Array(SearchItem) | Array(Video)
property browse_endpoint_data : String? property url : String?
property description_html : String property description_html : String
property badges : Array(Tuple(String, String))? property badges : Array(Tuple(String, String))?
# Data unique to only specific types of categories.
property auxiliary_data : Hash(String, String)
def to_json(locale, json : JSON::Builder) def to_json(locale, json : JSON::Builder)
json.object do json.object do
json.field "title", self.title json.field "title", self.title

View file

@ -1,6 +1,18 @@
class Invidious::Routes::Channels < Invidious::Routes::BaseRoute class Invidious::Routes::Channels < Invidious::Routes::BaseRoute
def home(env) def home(env)
self.videos(env) data = self.fetch_basic_information(env)
if !data.is_a?(Tuple)
return data
end
locale, user, subscriptions, continuation, ucid, channel = data
items = fetch_channel_home(ucid, channel)
has_trailer = false
if items[0].is_a? Video
has_trailer = true
end
templated "channel/home"
end end
def videos(env) def videos(env)
@ -149,6 +161,34 @@ class Invidious::Routes::Channels < Invidious::Routes::BaseRoute
templated "channel/about", buffer_footer: true templated "channel/about", buffer_footer: true
end end
def brand_redirect(env)
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
user = env.params.url["user"]
response = YT_POOL.client &.get("/c/#{user}")
html = XML.parse_html(response.body)
ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
if !ucid
env.response.status_code = 404
return
end
url = "/channel/#{ucid}"
location = env.request.path.lchop?("/c/#{user}/")
if location
url += "/#{location}"
end
if env.params.query.size > 0
url += "?#{env.params.query}"
end
env.redirect url
end
private def fetch_basic_information(env) private def fetch_basic_information(env)
locale = LOCALES[env.get("preferences").as(Preferences).locale]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?

View file

@ -275,7 +275,7 @@ struct Video
end end
end end
def to_json(locale, json : JSON::Builder) def to_json(locale : Hash(String, JSON::Any), json : JSON::Builder)
json.object do json.object do
json.field "type", "video" json.field "type", "video"

View file

@ -4,7 +4,7 @@
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= channel.ucid %>" > <link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= channel.ucid %>" >
<% end %> <% end %>
<% content_type = 0 %> <% content_type = 1 %>
<%= rendered "components/channel-information" %> <%= rendered "components/channel-information" %>
<div class="pure-g"> <div class="pure-g">

View file

@ -3,7 +3,7 @@
<link rel="stylesheet" href="/css/channel.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/channel.css?v=<%= ASSET_COMMIT %>">
<% end %> <% end %>
<% content_type = 2 %> <% content_type = 3 %>
<% sort_options = Tuple.new %> <% sort_options = Tuple.new %>
<%= rendered "components/channel-information" %> <%= rendered "components/channel-information" %>

View file

@ -13,10 +13,9 @@
<div class="channel-section pure-u-1"> <div class="channel-section pure-u-1">
<details open=""> <details open="">
<summary style="display: revert;"> <summary style="display: revert;">
<h3 class="category-heading"> <span style="font-weight: bold;">
<% if category.auxiliary_data.has_key?("view") %> <% if category.url %>
<% category_url_param = "?view=#{category.auxiliary_data["view"]}&shelf_id=#{category.auxiliary_data["shelf_id"]}" %> <a href="<%=category.url%>">
<a href="/channel/<%=channel.ucid%>/channels<%=HTML.escape(category_url_param)%>">
<%= category.title %> <%= category.title %>
</a> </a>
<%else%> <%else%>

View file

@ -0,0 +1,60 @@
<% content_for "header" do %>
<title><%= channel.author %> - Invidious</title>
<link rel="stylesheet" href="/css/channel.css?v=<%= ASSET_COMMIT %>">
<% end %>
<% content_type = 0 %>
<% sort_options = Tuple.new %>
<%= rendered "components/channel-information" %>
<div class="pure-g">
<% items.each do | section | %>
<% # Channel trailer %>
<% if section.is_a? Video %>
<div class="pure-u-1 h-box trailer-container category">
<% # Placeholder solution. A mini player should be placed here
%>
<div class="player-container pure-u-1 pure-u-md-1-3">
<a style="width:100%" href="/watch?v=<%= section.id %>">
<div class="thumbnail">
<img class="thumbnail" src="/vi/<%=section.id%>/maxres.jpg"/>
<% if section.length_seconds != 0 %>
<p class="length"><%= recode_length_seconds(section.length_seconds) %></p>
<% end %>
</div>
</a>
</div>
<div class="trailer-metadata pure-u-1 pure-u-md-1-3">
<a style="color:rgb(209, 209, 209)"><%= HTML.escape(section.title) %></a>
<p style="color: gray;">
<%= translate(locale, "`x` views", number_to_short_text(section.views || 0)) %>
<%= translate(locale, "Shared `x` ago", recode_date(section.published, locale)) %>
</p>
<div class="trailer-description">
<%= section.description_html %>
</div>
<a class="read-more" href="/watch?v=<%= section.id %>">READ MORE</a>
</div>
</div>
<% else %>
<div class="category pure-u-1">
<details open = "">
<summary style="display: revert;">
<a class="category-heading" href="<%=section.url%>"> <%= section.title %> </a>
</summary>
<div class="category-description h-box">
<p> <%= section.description_html %></p>
</div>
<div class="pure-g">
<% section.contents.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
</details>
</div>
<% end %>
<% end %>
</div>

View file

@ -3,7 +3,7 @@
<link rel="stylesheet" href="/css/channel.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/channel.css?v=<%= ASSET_COMMIT %>">
<% end %> <% end %>
<% content_type = 1 %> <% content_type = 2 %>
<%= rendered "components/channel-information" %> <%= rendered "components/channel-information" %>
<div class="pure-g"> <div class="pure-g">

View file

@ -60,23 +60,37 @@
<!-- TODO Refactor this! --> <!-- TODO Refactor this! -->
<div class="pure-menu pure-menu-horizontal"> <div class="pure-menu pure-menu-horizontal">
<ui class="pure-menu-list"> <ui class="pure-menu-list">
<% if content_type == 0 %>
<li class="pure-menu-item pure-menu-selected">
<a class="pure-menu-link" href="/channel/<%= channel.ucid %>">
<b> <%= translate(locale, "Home") %> </b>
</a>
</li>
<% else %>
<li class="pure-menu-item">
<a class="pure-menu-link" href="/channel/<%= channel.ucid %>">
<%= translate(locale, "Home") %>
</a>
</li>
<% end %>
<% if !channel.auto_generated %> <% if !channel.auto_generated %>
<% if content_type == 0 %> <% if content_type == 1 %>
<li class="pure-menu-item pure-menu-selected"> <li class="pure-menu-item pure-menu-selected">
<a href="/channel/<%= channel.ucid %>" class="pure-menu-link"> <a href="/channel/<%= channel.ucid %>/videos" class="pure-menu-link">
<b><%= translate(locale, "Videos") %></b> <b><%= translate(locale, "Videos") %></b>
</a> </a>
</li> </li>
<% else %> <% else %>
<li class="pure-menu-item pure-menu"> <li class="pure-menu-item pure-menu">
<a href="/channel/<%= channel.ucid %>" class="pure-menu-link"> <a href="/channel/<%= channel.ucid %>/videos" class="pure-menu-link">
<%= translate(locale, "Videos") %> <%= translate(locale, "Videos") %>
</a> </a>
</li> </li>
<% end %> <% end %>
<% end %> <% end %>
<% if content_type == 1 || channel.auto_generated %> <% if content_type == 2 %>
<li class="pure-menu-item pure-menu-selected"> <li class="pure-menu-item pure-menu-selected">
<a class="pure-menu-link" href="/channel/<%= channel.ucid %>/playlists"> <a class="pure-menu-link" href="/channel/<%= channel.ucid %>/playlists">
<b> <%= translate(locale, "Playlists") %> </b> <b> <%= translate(locale, "Playlists") %> </b>
@ -91,7 +105,7 @@
<% end %> <% end %>
<% if channel.tabs.has_key?("community") %> <% if channel.tabs.has_key?("community") %>
<% if content_type == 2 %> <% if content_type == 3 %>
<li class="pure-menu-item pure-menu-selected"> <li class="pure-menu-item pure-menu-selected">
<a class="pure-menu-link" href="/channel/<%= channel.ucid %>/community"> <a class="pure-menu-link" href="/channel/<%= channel.ucid %>/community">
<b> <%= translate(locale, "Community") %> </b> <b> <%= translate(locale, "Community") %> </b>
@ -152,7 +166,7 @@
</div> </div>
<div class="pure-u-1-3"></div> <div class="pure-u-1-3"></div>
<% if content_type == 0 || content_type == 1 %> <% if content_type == 1 || content_type == 2 %>
<% route = content_type == 1 ? "/playlists" : "" %> <% route = content_type == 1 ? "/playlists" : "" %>
<% url = "/channel/#{channel.ucid + route}" %> <% url = "/channel/#{channel.ucid + route}" %>