Add community page

This commit is contained in:
Omar Roth 2019-07-09 09:31:04 -05:00
parent 2cc25b1e6e
commit bcd239ac2b
No known key found for this signature in database
GPG Key ID: B8254FB7EC3D37F2
22 changed files with 422 additions and 31 deletions

101
assets/js/community.js Normal file
View File

@ -0,0 +1,101 @@
String.prototype.supplant = function (o) {
return this.replace(/{([^{}]*)}/g, function (a, b) {
var r = o[b];
return typeof r === 'string' || typeof r === 'number' ? r : a;
});
}
function hide_youtube_replies(event) {
var target = event.target;
sub_text = target.getAttribute('data-inner-text');
inner_text = target.getAttribute('data-sub-text');
body = target.parentNode.parentNode.children[1];
body.style.display = 'none';
target.innerHTML = sub_text;
target.onclick = show_youtube_replies;
target.setAttribute('data-inner-text', inner_text);
target.setAttribute('data-sub-text', sub_text);
}
function show_youtube_replies(event) {
var target = event.target;
sub_text = target.getAttribute('data-inner-text');
inner_text = target.getAttribute('data-sub-text');
body = target.parentNode.parentNode.children[1];
body.style.display = '';
target.innerHTML = sub_text;
target.onclick = hide_youtube_replies;
target.setAttribute('data-inner-text', inner_text);
target.setAttribute('data-sub-text', sub_text);
}
function number_with_separator(val) {
while (/(\d+)(\d{3})/.test(val.toString())) {
val = val.toString().replace(/(\d+)(\d{3})/, '$1' + ',' + '$2');
}
return val;
}
function get_youtube_replies(target, load_more) {
var continuation = target.getAttribute('data-continuation');
var body = target.parentNode.parentNode;
var fallback = body.innerHTML;
body.innerHTML =
'<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>';
var url = '/api/v1/channels/comments/' + community_data.ucid +
'?format=html' +
'&hl=' + community_data.preferences.locale +
'&thin_mode=' + community_data.preferences.thin_mode +
'&continuation=' + continuation;
var xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.timeout = 10000;
xhr.open('GET', url, true);
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
if (xhr.status == 200) {
if (load_more) {
body = body.parentNode.parentNode;
body.removeChild(body.lastElementChild);
body.innerHTML += xhr.response.contentHtml;
} else {
body.removeChild(body.lastElementChild);
var p = document.createElement('p');
var a = document.createElement('a');
p.appendChild(a);
a.href = 'javascript:void(0)';
a.onclick = hide_youtube_replies;
a.setAttribute('data-sub-text', community_data.hide_replies_text);
a.setAttribute('data-inner-text', community_data.show_replies_text);
a.innerText = community_data.hide_replies_text;
var div = document.createElement('div');
div.innerHTML = xhr.response.contentHtml;
body.appendChild(p);
body.appendChild(div);
}
} else {
body.innerHTML = fallback;
}
}
}
xhr.ontimeout = function () {
console.log('Pulling comments failed.');
body.innerHTML = fallback;
}
xhr.send();
}

View File

@ -316,5 +316,6 @@
"Video mode": "وضع الفيديو",
"Videos": "الفيديوهات",
"Playlists": "قوائم التشغيل",
"Community": "",
"Current version: ": "الإصدار الحالى"
}

View File

@ -316,5 +316,6 @@
"Video mode": "Videomodus",
"Videos": "Videos",
"Playlists": "Wiedergabelisten",
"Community": "",
"Current version: ": "Aktuelle Version: "
}

View File

@ -361,5 +361,6 @@
"Video mode": "Λειτουργία βίντεο",
"Videos": "Βίντεο",
"Playlists": "Λίστες Αναπαραγωγής",
"Community": "",
"Current version: ": "Τρέχουσα έκδοση: "
}

View File

@ -361,5 +361,6 @@
"Video mode": "Video mode",
"Videos": "Videos",
"Playlists": "Playlists",
"Community": "Community",
"Current version: ": "Current version: "
}

View File

@ -316,5 +316,6 @@
"Video mode": "Videa reĝimo",
"Videos": "Videoj",
"Playlists": "Ludlistoj",
"Community": "",
"Current version: ": "Nuna versio: "
}

View File

@ -316,5 +316,6 @@
"Video mode": "Modo de vídeo",
"Videos": "Vídeos",
"Playlists": "Listas de reproducción",
"Community": "",
"Current version: ": "Versión actual: "
}

View File

@ -316,5 +316,6 @@
"Video mode": "Mode Vidéo",
"Videos": "Vidéos",
"Playlists": "Liste de lecture",
"Community": "",
"Current version: ": "Version actuelle : "
}

View File

@ -315,5 +315,6 @@
"Video mode": "Modalità video",
"Videos": "",
"Playlists": "",
"Community": "",
"Current version: ": ""
}

View File

@ -316,5 +316,6 @@
"Video mode": "Video-modus",
"Videos": "Videoer",
"Playlists": "Spillelister",
"Community": "",
"Current version: ": "Nåværende versjon: "
}

View File

@ -316,5 +316,6 @@
"Video mode": "Videomodus",
"Videos": "Video's",
"Playlists": "Afspeellijsten",
"Community": "",
"Current version: ": "Huidige versie: "
}

View File

@ -316,5 +316,6 @@
"Video mode": "Tryb wideo",
"Videos": "Filmy",
"Playlists": "Playlisty",
"Community": "",
"Current version: ": "Aktualna wersja: "
}

View File

@ -316,5 +316,6 @@
"Video mode": "Видео режим",
"Videos": "Видео",
"Playlists": "Плейлисты",
"Community": "",
"Current version: ": "Текущая версия: "
}

View File

@ -316,5 +316,6 @@
"Video mode": "Відеорежим",
"Videos": "Відео",
"Playlists": "Плейлисти",
"Community": "",
"Current version: ": "Поточна версія: "
}

View File

@ -316,5 +316,6 @@
"Video mode": "视频模式",
"Videos": "视频",
"Playlists": "播放列表",
"Community": "",
"Current version: ": "当前版本:"
}

View File

@ -2861,6 +2861,16 @@ get "/user/:user/videos" do |env|
env.redirect "/channel/#{user}/videos"
end
get "/user/:user/about" do |env|
user = env.params.url["user"]
env.redirect "/channel/#{user}"
end
get "/channel:ucid/about" do |env|
ucid = env.params.url["ucid"]
env.redirect "/channel/#{ucid}"
end
get "/channel/:ucid" do |env|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
@ -2968,6 +2978,46 @@ get "/channel/:ucid/playlists" do |env|
templated "playlists"
end
get "/channel/:ucid/community" do |env|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
user = env.get? "user"
if user
user = user.as(User)
subscriptions = user.subscriptions
end
subscriptions ||= [] of String
ucid = env.params.url["ucid"]
thin_mode = env.params.query["thin_mode"]? || env.get("preferences").as(Preferences).thin_mode
thin_mode = thin_mode == "true"
continuation = env.params.query["continuation"]?
# sort_by = env.params.query["sort_by"]?.try &.downcase
begin
channel = get_about_info(ucid, locale)
rescue ex
error_message = ex.message
env.response.status_code = 500
next templated "error"
end
if !channel.tabs.includes? "community"
next env.redirect "/channel/#{channel.ucid}"
end
begin
items = JSON.parse(fetch_channel_community(ucid, continuation, locale, config, Kemal.config, "json", thin_mode))
rescue ex
env.response.status_code = 500
error_message = ex.message
end
templated "community"
end
# API Endpoints
get "/api/v1/stats" do |env|
@ -3757,12 +3807,17 @@ end
ucid = env.params.url["ucid"]
continuation = env.params.query["continuation"]?
thin_mode = env.params.query["thin_mode"]?
thin_mode = thin_mode == "true"
format = env.params.query["format"]?
format ||= "json"
continuation = env.params.query["continuation"]?
# sort_by = env.params.query["sort_by"]?.try &.downcase
begin
fetch_channel_community(ucid, continuation, locale, config, Kemal.config)
fetch_channel_community(ucid, continuation, locale, config, Kemal.config, format, thin_mode)
rescue ex
env.response.status_code = 400
error_message = {"error" => ex.message}.to_json

View File

@ -123,6 +123,7 @@ struct AboutChannel
is_family_friendly: Bool,
allowed_regions: Array(String),
related_channels: Array(AboutRelatedChannel),
tabs: Array(String),
})
end
@ -617,7 +618,7 @@ def extract_channel_playlists_cursor(url, auto_generated)
end
# TODO: Add "sort_by"
def fetch_channel_community(ucid, continuation, locale, config, kemal_config)
def fetch_channel_community(ucid, continuation, locale, config, kemal_config, format, thin_mode)
client = make_client(YT_URL)
headers = HTTP::Headers.new
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36"
@ -632,11 +633,10 @@ def fetch_channel_community(ucid, continuation, locale, config, kemal_config)
raise error_message
end
ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"]
if !continuation || continuation.empty?
response = JSON.parse(response.body.match(/window\["ytInitialData"\] = (?<info>.*?);\n/).try &.["info"] || "{}")
ucid = response["responseContext"]["serviceTrackingParams"]
.as_a.select { |service| service["service"] == "GFEEDBACK" }[0]?.try &.["params"]
.as_a.select { |param| param["key"] == "browse_id" }[0]?.try &.["value"].as_s
body = response["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]?
if !body
@ -645,6 +645,8 @@ def fetch_channel_community(ucid, continuation, locale, config, kemal_config)
body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]
else
continuation = produce_channel_community_continuation(ucid, continuation)
headers["cookie"] = response.cookies.add_request_headers(headers)["cookie"]
headers["content-type"] = "application/x-www-form-urlencoded"
@ -663,10 +665,6 @@ def fetch_channel_community(ucid, continuation, locale, config, kemal_config)
response = client.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, form: post_req)
body = JSON.parse(response.body)
ucid = body["response"]["responseContext"]["serviceTrackingParams"]
.as_a.select { |service| service["service"] == "GFEEDBACK" }[0]?.try &.["params"]
.as_a.select { |param| param["key"] == "browse_id" }[0]?.try &.["value"].as_s
body = body["response"]["continuationContents"]["itemSectionContinuation"]? ||
body["response"]["continuationContents"]["backstageCommentsContinuation"]?
@ -685,7 +683,7 @@ def fetch_channel_community(ucid, continuation, locale, config, kemal_config)
raise error_message
end
JSON.build do |json|
response = JSON.build do |json|
json.object do
json.field "authorId", ucid
json.field "comments" do
@ -755,6 +753,7 @@ def fetch_channel_community(ucid, continuation, locale, config, kemal_config)
json.field "likeCount", like_count
json.field "commentId", post["postId"]? || post["commentId"]? || ""
json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid
if attachment = post["backstageAttachment"]?
json.field "attachment" do
@ -837,7 +836,7 @@ def fetch_channel_community(ucid, continuation, locale, config, kemal_config)
json.field "replies" do
json.object do
json.field "replyCount", reply_count
json.field "continuation", continuation
json.field "continuation", extract_channel_community_cursor(continuation)
end
end
end
@ -847,11 +846,71 @@ def fetch_channel_community(ucid, continuation, locale, config, kemal_config)
end
if body["continuations"]?
continuation = body["continuations"][0]["nextContinuationData"]["continuation"]
json.field "continuation", continuation
continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s
json.field "continuation", extract_channel_community_cursor(continuation)
end
end
end
if format == "html"
response = JSON.parse(response)
content_html = template_youtube_comments(response, locale, thin_mode)
response = JSON.build do |json|
json.object do
json.field "contentHtml", content_html
end
end
end
return response
end
def produce_channel_community_continuation(ucid, cursor)
cursor = URI.escape(cursor)
continuation = IO::Memory.new
continuation.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02])
continuation.write(write_var_int(3 + ucid.size + write_var_int(cursor.size).size + cursor.size))
continuation.write(Bytes[0x12, ucid.size])
continuation.print(ucid)
continuation.write(Bytes[0x1a])
continuation.write(write_var_int(cursor.size))
continuation.print(cursor)
continuation.rewind
continuation = Base64.urlsafe_encode(continuation.to_slice)
continuation = URI.escape(continuation)
return continuation
end
def extract_channel_community_cursor(continuation)
continuation = URI.unescape(continuation)
continuation = Base64.decode(continuation)
# 0xe2 0xa9 0x85 0xb2 0x02
continuation += 5
total_size = read_var_int(continuation[0, 4])
continuation += write_var_int(total_size).size
# 0x12
continuation += 1
ucid_size = continuation[0]
continuation += 1
ucid = continuation[0, ucid_size]
continuation += ucid_size
# 0x1a
continuation += 1
until continuation[0] == 'E'.ord
continuation += 1
end
return String.new(continuation)
end
def get_about_info(ucid, locale)
@ -947,6 +1006,8 @@ def get_about_info(ucid, locale)
auto_generated = true
end
tabs = about.xpath_nodes(%q(//ul[@id="channel-navigation-menu"]/li/a/span)).map { |node| node.content.downcase }
return AboutChannel.new(
ucid: ucid,
author: author,
@ -961,7 +1022,8 @@ def get_about_info(ucid, locale)
joined: joined,
is_family_friendly: is_family_friendly,
allowed_regions: allowed_regions,
related_channels: related_channels
related_channels: related_channels,
tabs: tabs
)
end

View File

@ -112,7 +112,7 @@ def fetch_youtube_comments(id, db, continuation, format, locale, thin_mode, regi
end
end
comments = JSON.build do |json|
response = JSON.build do |json|
json.object do
if body["header"]?
count_text = body["header"]["commentsHeaderRenderer"]["countText"]
@ -223,15 +223,15 @@ def fetch_youtube_comments(id, db, continuation, format, locale, thin_mode, regi
end
if format == "html"
comments = JSON.parse(comments)
content_html = template_youtube_comments(comments, locale, thin_mode)
response = JSON.parse(response)
content_html = template_youtube_comments(response, locale, thin_mode)
comments = JSON.build do |json|
response = JSON.build do |json|
json.object do
json.field "contentHtml", content_html
if comments["commentCount"]?
json.field "commentCount", comments["commentCount"]
if response["commentCount"]?
json.field "commentCount", response["commentCount"]
else
json.field "commentCount", 0
end
@ -239,7 +239,7 @@ def fetch_youtube_comments(id, db, continuation, format, locale, thin_mode, regi
end
end
return comments
return response
end
def fetch_reddit_comments(id, sort_by = "confidence")
@ -286,7 +286,7 @@ def template_youtube_comments(comments, locale, thin_mode)
<div class="pure-u-23-24">
<p>
<a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}"
onclick="get_youtube_replies(this)">#{translate(locale, "View `x` replies", child["replies"]["replyCount"].to_s)}</a>
onclick="get_youtube_replies(this)">#{translate(locale, "View `x` replies", number_with_separator(child["replies"]["replyCount"]))}</a>
</p>
</div>
</div>
@ -300,9 +300,9 @@ def template_youtube_comments(comments, locale, thin_mode)
end
html << <<-END_HTML
<div class="pure-g">
<div class="pure-g" style="width:100%">
<div class="channel-profile pure-u-4-24 pure-u-md-2-24">
<img style="padding-right:1em;padding-top:1em" src="#{author_thumbnail}">
<img style="padding-right:1em;padding-top:1em;width:90%" src="#{author_thumbnail}">
</div>
<div class="pure-u-20-24 pure-u-md-22-24">
<p>
@ -310,11 +310,66 @@ def template_youtube_comments(comments, locale, thin_mode)
<a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{child["author"]}</a>
</b>
<p style="white-space:pre-wrap">#{child["contentHtml"]}</p>
<span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""}</span>
|
<a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
<i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])}
END_HTML
if child["attachment"]?
attachment = child["attachment"]
case attachment["type"]
when "image"
attachment = attachment["imageThumbnails"][1]
html << <<-END_HTML
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-2">
<img style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).full_path}">
</div>
</div>
END_HTML
when "video"
html << <<-END_HTML
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-2">
<div style="position:relative;width:100%;height:0;padding-bottom:56.25%;margin-bottom:5px">
END_HTML
if attachment["error"]?
html << <<-END_HTML
<p>#{attachment["error"]}</p>
END_HTML
else
html << <<-END_HTML
<iframe id='ivplayer' type='text/html' style='position:absolute;width:100%;height:100%;left:0;top:0' src='/embed/#{attachment["videoId"]?}' frameborder='0'></iframe>
END_HTML
end
html << <<-END_HTML
</div>
</div>
</div>
END_HTML
end
end
html << <<-END_HTML
<span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""}</span>
|
END_HTML
if comments["videoId"]?
html << <<-END_HTML
<a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
END_HTML
elsif comments["authorId"]?
html << <<-END_HTML
<a href="https://www.youtube.com/channel/#{comments["authorId"]}/community?lb=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
END_HTML
end
html << <<-END_HTML
<i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])}
END_HTML
if child["creatorHeart"]?

View File

@ -49,6 +49,11 @@
<a href="/channel/<%= channel.ucid %>/playlists"><%= translate(locale, "Playlists") %></a>
<% end %>
</div>
<div class="pure-u-1 pure-md-1-3">
<% if channel.tabs.includes? "community" %>
<a href="/channel/<%= channel.ucid %>/community"><%= translate(locale, "Community") %></a>
<% end %>
</div>
</div>
<div class="pure-u-1-3"></div>
<div class="pure-u-1-3">

View File

@ -0,0 +1,80 @@
<% content_for "header" do %>
<title><%= channel.author %> - Invidious</title>
<% end %>
<% if channel.banner %>
<div class="h-box">
<img style="width:100%" src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).full_path %>">
</div>
<div class="h-box">
<hr>
</div>
<% end %>
<div class="pure-g h-box">
<div class="pure-u-2-3">
<div class="channel-profile">
<img src="/ggpht<%= URI.parse(channel.author_thumbnail).full_path %>">
<span><%= channel.author %></span>
</div>
</div>
<div class="pure-u-1-3" style="text-align:right">
<h3>
<a href="/feed/channel/<%= channel.ucid %>"><i class="icon ion-logo-rss"></i></a>
</h3>
</div>
</div>
<div class="h-box">
<% ucid = channel.ucid %>
<% author = channel.author %>
<% sub_count_text = number_to_short_text(channel.sub_count) %>
<%= rendered "components/subscribe_widget" %>
</div>
<div class="pure-g h-box">
<div class="pure-u-1-3">
<a href="https://www.youtube.com/channel/<%= channel.ucid %>/community"><%= translate(locale, "View channel on YouTube") %></a>
<% if !channel.auto_generated %>
<div class="pure-u-1 pure-md-1-3">
<a href="/channel/<%= channel.ucid %>"><%= translate(locale, "Videos") %></a>
</div>
<% end %>
<div class="pure-u-1 pure-md-1-3">
<a href="/channel/<%= channel.ucid %>/playlists"><%= translate(locale, "Playlists") %></a>
</div>
<div class="pure-u-1 pure-md-1-3">
<% if channel.tabs.includes? "community" %>
<b><%= translate(locale, "Community") %></b>
<% end %>
</div>
</div>
<div class="pure-u-2-3"></div>
</div>
<div class="h-box">
<hr>
</div>
<% if error_message %>
<div class="h-box">
<p><%= error_message %></p>
</div>
<% else %>
<div class="h-box pure-g" id="comments">
<%= template_youtube_comments(items.not_nil!, locale, thin_mode) %>
</div>
<% end %>
<script>
var community_data = {
ucid: '<%= channel.ucid %>',
youtube_comments_text: '<%= HTML.escape(translate(locale, "View YouTube comments")) %>',
comments_text: '<%= HTML.escape(translate(locale, "View `x` comments", "{commentCount}")) %>',
hide_replies_text: '<%= HTML.escape(translate(locale, "Hide replies")) %>',
show_replies_text: '<%= HTML.escape(translate(locale, "Show replies")) %>',
preferences: <%= env.get("preferences").as(Preferences).to_json %>,
}
</script>
<script src="/js/community.js?v=<%= ASSET_COMMIT %>"></script>

View File

@ -9,6 +9,20 @@
<body>
<h1><%= translate(locale, "JavaScript license information") %></h1>
<table id="jslicense-labels1">
<tr>
<td>
<a href="/js/community.js?v=<%= ASSET_COMMIT %>">community.js</a>
</td>
<td>
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
</td>
<td>
<a href="/js/community.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
</td>
</tr>
<tr>
<td>
<a href="/js/embed.js?v=<%= ASSET_COMMIT %>">embed.js</a>

View File

@ -36,7 +36,7 @@
<div class="pure-g h-box">
<div class="pure-g pure-u-1-3">
<div class="pure-u-1 pure-md-1-3">
<a href="https://www.youtube.com/channel/<%= channel.ucid %>"><%= translate(locale, "View channel on YouTube") %></a>
<a href="https://www.youtube.com/channel/<%= channel.ucid %>/playlists"><%= translate(locale, "View channel on YouTube") %></a>
</div>
<div class="pure-u-1 pure-md-1-3">
<a href="/channel/<%= channel.ucid %>"><%= translate(locale, "Videos") %></a>
@ -46,6 +46,11 @@
<b><%= translate(locale, "Playlists") %></b>
<% end %>
</div>
<div class="pure-u-1 pure-md-1-3">
<% if channel.tabs.includes? "community" %>
<a href="/channel/<%= channel.ucid %>/community"><%= translate(locale, "Community") %></a>
<% end %>
</div>
</div>
<div class="pure-u-1-3"></div>
<div class="pure-u-1-3">