Compare commits

...

47 Commits
0.1.0 ... 0.3.0

Author SHA1 Message Date
45fa148380 Don't add playlist id for channel videos 2018-08-27 18:53:34 -05:00
2ba0063dc0 Add search filters 2018-08-27 15:23:25 -05:00
b57176d7ef Fix notification count in subscription feed 2018-08-27 13:46:50 -05:00
0dbef6ab9f Fix typo in preferred_captions 2018-08-26 15:00:19 -05:00
8fc4dcfdea Use username for /data_control 2018-08-25 21:49:18 -05:00
6c98513153 Add referer to /data_control 2018-08-25 21:48:20 -05:00
c3d8ca68b3 Add code to calculate video rating 2018-08-25 21:34:11 -05:00
a37692cce4 Fix 'to_json' for comment array 2018-08-25 21:33:53 -05:00
a1ad561b98 Fix /clear_watch_history 2018-08-25 21:33:33 -05:00
7fd0f93d02 Add support for preferences as query params 2018-08-25 20:05:51 -05:00
23aaf7f1b7 Add comments fallback 2018-08-25 18:33:15 -05:00
41a04e7c67 Clean up /videoplayback 2018-08-25 17:24:07 -05:00
77b12b6249 Only show next page when there are more results 2018-08-25 17:18:43 -05:00
78fcf579a7 Add Liberapay 2018-08-25 15:43:39 -05:00
9ae3bf216e Update signature extraction 2018-08-24 07:17:16 -05:00
0e7c56687b Add error message for comment timeouts 2018-08-23 16:55:26 -05:00
01a80995d3 Add fix for channel endpoint where channel has no subscribers 2018-08-22 11:06:31 -05:00
76d3abb5f9 Make view extractor more robust 2018-08-20 19:25:12 -05:00
deb4b06ea0 Fix playlist view extractor 2018-08-20 10:25:05 -05:00
4725f7222b Fix description in video API endpoint 2018-08-20 10:07:50 -05:00
16c7d99dd8 Fix channel feeds 2018-08-18 18:38:33 -05:00
55f8fd0b58 Add note for livestreams that haven't started 2018-08-18 11:47:16 -05:00
1611ee83a6 Remove preload directive 2018-08-18 10:05:04 -05:00
567b9f31f3 Add fix for livestreams in search results 2018-08-17 16:08:07 -05:00
6bb747b579 Fix comment replies 2018-08-17 13:03:25 -05:00
9a15438c71 Minor formatting changes 2018-08-17 11:04:38 -05:00
4760b3c6e7 Merge pull request #116 from omarroth/add-playlists
Add playlist page and endpoint
2018-08-17 11:01:36 -05:00
9e68df965b Add 'view channel on Youtube' link 2018-08-17 10:57:08 -05:00
3ba2a7d921 Fix referers 2018-08-17 10:19:20 -05:00
71aa4d0347 Replace duplicate link to YouTube 2018-08-17 09:25:47 -05:00
bb0b60e575 Add playlist page and endpoint 2018-08-16 14:26:16 -05:00
fa2ba807a3 Remove 'engage types' 2018-08-16 13:11:38 -05:00
bce01cba32 Add fix for videos without metadata 2018-08-16 09:05:48 -05:00
ec399f5f7b Properly filter movies, playlists, channels from search results 2018-08-16 08:47:51 -05:00
7c63c759f4 Add donation links to footer 2018-08-15 20:36:21 -05:00
b72f3c2274 Rename 'layout' to 'template' 2018-08-15 20:31:47 -05:00
74cf3d18d0 Remove ID constrant for users 2018-08-15 19:30:13 -05:00
8adb4650a0 Add support for multiple sessions 2018-08-15 12:40:42 -05:00
45ce301bd2 Fix reply count extraction 2018-08-15 11:47:37 -05:00
d9ea8e413e Bump size of comment author thumbnail 2018-08-14 19:48:53 -05:00
2cedac8c58 Don't show 'next page' or 'previous page' where not applicable 2018-08-14 19:15:33 -05:00
c5bd5e6c6d Fix produce_playlist_url 2018-08-14 17:00:35 -05:00
7dfb301858 Sort engage types 2018-08-14 14:04:43 -05:00
f26e9313ff Add ability to decrypt port numbers for proxy list 2018-08-14 13:22:39 -05:00
1409160ee6 Fix typo in shard.yml 2018-08-14 12:29:27 -05:00
6e434409a0 Update to Crystal 0.26.0 2018-08-14 09:41:03 -05:00
3833366756 Change options to provide more proxies 2018-08-14 09:40:52 -05:00
29 changed files with 967 additions and 442 deletions

View File

@ -2,6 +2,7 @@
## Invidious is what YouTube should be
Liberapay: https://liberapay.com/omarroth
Patreon: https://patreon.com/omarroth
BTC: 356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY
BCH: qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk

View File

@ -2,9 +2,9 @@
-- DROP TABLE public.users;
CREATE TABLE public.users
CREATE TABLE public.users
(
id text COLLATE pg_catalog."default" NOT NULL,
id text[] COLLATE pg_catalog."default" NOT NULL,
updated timestamp with time zone,
notifications text[] COLLATE pg_catalog."default",
subscriptions text[] COLLATE pg_catalog."default",
@ -13,8 +13,7 @@ CREATE TABLE public.users
password text COLLATE pg_catalog."default",
token text COLLATE pg_catalog."default",
watched text[] COLLATE pg_catalog."default",
CONSTRAINT users_email_key UNIQUE (email),
CONSTRAINT users_id_key UNIQUE (id)
CONSTRAINT users_email_key UNIQUE (email)
)
WITH (
OIDS = FALSE

View File

@ -11,14 +11,11 @@ targets:
dependencies:
kemal:
github: kemalcr/kemal
branch: rework-param-parser
pg:
github: will/crystal-pg
branch: master
detect_language:
github: detectlanguage/detectlanguage-crystal
branch: master
crystal: 0.25.1
crystal: 0.26.0
license: AGPLv3

View File

@ -114,10 +114,11 @@ before_all do |env|
# Invidious users only have SID
if !env.request.cookies.has_key? "SSID"
user = PG_DB.query_one?("SELECT * FROM users WHERE id = $1", sid, as: User)
user = PG_DB.query_one?("SELECT * FROM users WHERE $1 = ANY(id)", sid, as: User)
if user
env.set "user", user
env.set "sid", sid
end
else
begin
@ -125,10 +126,24 @@ before_all do |env|
user = get_user(sid, client, headers, PG_DB, false)
env.set "user", user
env.set "sid", sid
rescue ex
end
end
end
current_page = env.request.path
if env.request.query
query = HTTP::Params.parse(env.request.query.not_nil!)
if query["referer"]?
query["referer"] = get_referer(env, "/")
end
current_page += "?#{query}"
end
env.set "current_page", URI.escape(current_page)
end
get "/" do |env|
@ -200,8 +215,9 @@ get "/watch" do |env|
end
subscriptions ||= [] of String
autoplay, video_loop, video_start, video_end, listen, raw, quality, controls = process_video_params(env.params.query, preferences)
if listen
params = process_video_params(env.params.query, preferences)
if params[:listen]
env.params.query.delete_all("listen")
end
@ -219,13 +235,17 @@ get "/watch" do |env|
audio_streams = video.audio_streams(adaptive_fmts)
captions = video.captions
if preferences
preferred_captions = captions.select { |caption| preferences.captions.includes? caption.name.simpleText }
preferred_captions.sort_by! { |caption| preferences.captions.index(caption.name.simpleText).not_nil! }
captions = captions - preferred_captions
end
preferred_captions ||= [] of Caption
preferred_captions = captions.select { |caption|
params[:preferred_captions].includes?(caption.name.simpleText) ||
params[:preferred_captions].includes?(caption.languageCode.split("-")[0])
}
preferred_captions.sort_by! { |caption|
(params[:preferred_captions].index(caption.name.simpleText) ||
params[:preferred_captions].index(caption.languageCode.split("-")[0])).not_nil!
}
captions = captions - preferred_captions
aspect_ratio = "16:9"
video.description = fill_links(video.description, "https", "www.youtube.com")
@ -244,11 +264,11 @@ get "/watch" do |env|
# TODO: Find highest resolution thumbnail automatically
thumbnail = "https://i.ytimg.com/vi/#{video.id}/mqdefault.jpg"
if raw
if params[:raw]
url = fmt_stream[0]["url"]
fmt_stream.each do |fmt|
if fmt["label"].split(" - ")[0] == quality
if fmt["label"].split(" - ")[0] == params[:quality]
url = fmt["url"]
end
end
@ -261,13 +281,16 @@ get "/watch" do |env|
rvs << HTTP::Params.parse(rv).to_h
end
# rating = (video.likes.to_f/(video.likes.to_f + video.dislikes.to_f) * 4 + 1)
rating = video.info["avg_rating"].to_f64
engagement = ((video.dislikes.to_f + video.likes.to_f)/video.views * 100)
if video.info["enabled_engage_types"]?
engage_types = video.info["enabled_engage_types"].split(",")
engage_types = engage_types.join(", ")
playability_status = video.player_response["playabilityStatus"]?
if playability_status && playability_status["status"] == "LIVE_STREAM_OFFLINE"
reason = playability_status["reason"]?.try &.as_s
end
reason ||= ""
templated "watch"
end
@ -297,21 +320,7 @@ get "/embed/:id" do |env|
next env.redirect url
end
autoplay, video_loop, video_start, video_end, listen, raw, quality, controls = process_video_params(env.params.query, nil)
preferred_captions = [] of Caption
preferences = Preferences.from_json({
"video_loop" => video_loop,
"autoplay" => autoplay,
"speed" => 1.0,
"quality" => quality,
"volume" => 100,
"max_results" => 0,
"sort" => "",
"latest_only" => false,
"unseen_only" => false,
"dark_mode" => false,
}.to_json)
aspect_ratio = nil
params = process_video_params(env.params.query, nil)
begin
video = get_video(id, PG_DB)
@ -327,6 +336,18 @@ get "/embed/:id" do |env|
captions = video.captions
preferred_captions = captions.select { |caption|
params[:preferred_captions].includes?(caption.name.simpleText) ||
params[:preferred_captions].includes?(caption.languageCode.split("-")[0])
}
preferred_captions.sort_by! { |caption|
(params[:preferred_captions].index(caption.name.simpleText) ||
params[:preferred_captions].index(caption.languageCode.split("-")[0])).not_nil!
}
captions = captions - preferred_captions
aspect_ratio = nil
video.description = fill_links(video.description, "https", "www.youtube.com")
video.description = add_alt_links(video.description)
description = video.short_description
@ -343,11 +364,11 @@ get "/embed/:id" do |env|
# TODO: Find highest resolution thumbnail automatically
thumbnail = "https://i.ytimg.com/vi/#{video.id}/mqdefault.jpg"
if raw
if params[:raw]
url = fmt_stream[0]["url"]
fmt_stream.each do |fmt|
if fmt["label"].split(" - ")[0] == quality
if fmt["label"].split(" - ")[0] == params[:quality]
url = fmt["url"]
end
end
@ -358,6 +379,31 @@ get "/embed/:id" do |env|
rendered "embed"
end
# Playlists
get "/playlist" do |env|
plid = env.params.query["list"]?
if !plid
next env.redirect "/"
end
page = env.params.query["page"]?.try &.to_i?
page ||= 1
if plid
begin
videos = extract_playlist(plid, page)
rescue ex
error_message = ex.message
next templated "error"
end
playlist = fetch_playlist(plid)
else
next env.redirect "/"
end
templated "playlist"
end
# Search
get "/results" do |env|
@ -383,8 +429,32 @@ get "/search" do |env|
page = env.params.query["page"]?.try &.to_i?
page ||= 1
search_params = build_search_params(sort_by: "relevance", content_type: "video")
videos = search(query, page, search_params)
sort = "relevance"
date = ""
duration = ""
features = [] of String
operators = query.split(" ").select { |a| a.match(/\w+:[\w,]+/) }
operators.each do |operator|
key, value = operator.split(":")
case key
when "sort"
sort = value
when "date"
date = value
when "duration"
duration = value
when "features"
features = value.split(",")
end
end
query = (query.split(" ") - operators).join(" ")
search_params = build_search_params(sort: sort, date: date, content_type: "video",
duration: duration, features: features)
count, videos = search(query, page, search_params).as(Tuple)
templated "search"
end
@ -414,8 +484,7 @@ end
# See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L79
post "/login" do |env|
referer = env.params.query["referer"]?
referer ||= get_referer(env, "/feed/subscriptions")
referer = get_referer(env, "/feed/subscriptions")
email = env.params.body["email"]?
password = env.params.body["password"]?
@ -509,7 +578,7 @@ post "/login" do |env|
end
if !tfa_code
next env.redirect "/login?tfa=true&type=google"
next env.redirect "/login?tfa=true&type=google&referer=#{URI.escape(referer)}"
end
tl = challenge_results[1][2]
@ -621,8 +690,8 @@ post "/login" do |env|
end
if Crypto::Bcrypt::Password.new(user.password.not_nil!) == password
sid = Base64.encode(Random::Secure.random_bytes(50))
PG_DB.exec("UPDATE users SET id = $1 WHERE email = $2", sid, email)
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
PG_DB.exec("UPDATE users SET id = id || $1 WHERE email = $2", [sid], email)
if Kemal.config.ssl || CONFIG.https_only
secure = true
@ -643,7 +712,7 @@ post "/login" do |env|
next templated "error"
end
sid = Base64.encode(Random::Secure.random_bytes(50))
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
user = create_user(sid, email, password)
user_array = user.to_a
@ -673,8 +742,14 @@ get "/signout" do |env|
cookie.expires = Time.new(1990, 1, 1)
end
if env.get? "user"
user = env.get("user").as(User)
sid = env.get("sid").as(String)
PG_DB.exec("UPDATE users SET id = array_remove(id, $1) WHERE email = $2", sid, user.email)
end
env.request.cookies.add_response_headers(env.response.headers)
env.redirect referer
env.redirect URI.unescape(referer)
end
get "/preferences" do |env|
@ -715,8 +790,9 @@ post "/preferences" do |env|
volume = env.params.body["volume"]?.try &.as(String).to_i?
volume ||= 100
comments = env.params.body["comments"]?
comments ||= "youtube"
comments_0 = env.params.body["comments_0"]?.try &.as(String) || "youtube"
comments_1 = env.params.body["comments_1"]?.try &.as(String) || ""
comments = [comments_0, comments_1]
captions_0 = env.params.body["captions_0"]?.try &.as(String) || ""
captions_1 = env.params.body["captions_1"]?.try &.as(String) || ""
@ -865,7 +941,7 @@ get "/subscription_manager" do |env|
headers["Cookie"] = env.request.headers["Cookie"]
client = make_client(YT_URL)
user = get_user(user.id, client, headers, PG_DB)
user = get_user(user.id[0], client, headers, PG_DB)
end
action_takeout = env.params.query["action_takeout"]?.try &.to_i?
@ -981,18 +1057,18 @@ post "/data_control" do |env|
body["watch_history"].as_a.each do |id|
id = id.as_s
if !user.watched.includes? id
PG_DB.exec("UPDATE users SET watched = array_append(watched,$1) WHERE id = $2", id, user.id)
PG_DB.exec("UPDATE users SET watched = array_append(watched,$1) WHERE email = $2", id, user.email)
end
end
PG_DB.exec("UPDATE users SET preferences = $1 WHERE id = $2", body["preferences"].to_json, user.id)
PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", body["preferences"].to_json, user.email)
when "import_youtube"
subscriptions = XML.parse(body)
subscriptions.xpath_nodes(%q(//outline[@type="rss"])).each do |channel|
ucid = channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0]
if !user.subscriptions.includes? ucid
PG_DB.exec("UPDATE users SET subscriptions = array_append(subscriptions,$1) WHERE id = $2", ucid, user.id)
PG_DB.exec("UPDATE users SET subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email)
begin
client = make_client(YT_URL)
@ -1007,7 +1083,7 @@ post "/data_control" do |env|
ucid = md["channel_id"]
if !user.subscriptions.includes? ucid
PG_DB.exec("UPDATE users SET subscriptions = array_append(subscriptions,$1) WHERE id = $2", ucid, user.id)
PG_DB.exec("UPDATE users SET subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email)
begin
client = make_client(YT_URL)
@ -1023,7 +1099,7 @@ post "/data_control" do |env|
ucid = channel["url"].as_s.match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0]
if !user.subscriptions.includes? ucid
PG_DB.exec("UPDATE users SET subscriptions = array_append(subscriptions,$1) WHERE id = $2", ucid, user.id)
PG_DB.exec("UPDATE users SET subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email)
begin
client = make_client(YT_URL)
@ -1044,14 +1120,14 @@ post "/data_control" do |env|
db = entry.io.gets_to_end
db.scan(/youtube\.com\/watch\?v\=(?<id>[a-zA-Z0-9_-]{11})/) do |md|
if !user.watched.includes? md["id"]
PG_DB.exec("UPDATE users SET watched = array_append(watched,$1) WHERE id = $2", md["id"], user.id)
PG_DB.exec("UPDATE users SET watched = array_append(watched,$1) WHERE email = $2", md["id"], user.email)
end
end
db.scan(/youtube\.com\/channel\/(?<ucid>[a-zA-Z0-9_-]{22})/) do |md|
ucid = md["ucid"]
if !user.subscriptions.includes? ucid
PG_DB.exec("UPDATE users SET subscriptions = array_append(subscriptions,$1) WHERE id = $2", ucid, user.id)
PG_DB.exec("UPDATE users SET subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email)
begin
client = make_client(YT_URL)
@ -1151,7 +1227,7 @@ get "/clear_watch_history" do |env|
if user
user = user.as(User)
PG_DB.exec("UPDATE users SET watched = '{}' WHERE id = $1", user.id)
PG_DB.exec("UPDATE users SET watched = '{}' WHERE email = $1", user.email)
end
env.redirect referer
@ -1173,7 +1249,7 @@ get "/feed/subscriptions" do |env|
if !user.password
client = make_client(YT_URL)
user = get_user(user.id, client, headers, PG_DB)
user = get_user(user.id[0], client, headers, PG_DB)
end
max_results = preferences.max_results
@ -1196,21 +1272,21 @@ get "/feed/subscriptions" do |env|
if preferences.notifications_only && !notifications.empty?
args = arg_array(notifications)
videos = PG_DB.query_all("SELECT * FROM channel_videos WHERE id IN (#{args})
notifications = PG_DB.query_all("SELECT * FROM channel_videos WHERE id IN (#{args})
ORDER BY published DESC", notifications, as: ChannelVideo)
notifications = [] of ChannelVideo
videos = [] of ChannelVideo
videos.sort_by! { |video| video.published }.reverse!
notifications.sort_by! { |video| video.published }.reverse!
case preferences.sort
when "alphabetically"
videos.sort_by! { |video| video.title }
notifications.sort_by! { |video| video.title }
when "alphabetically - reverse"
videos.sort_by! { |video| video.title }.reverse!
notifications.sort_by! { |video| video.title }.reverse!
when "channel name"
videos.sort_by! { |video| video.author }
notifications.sort_by! { |video| video.author }
when "channel name - reverse"
videos.sort_by! { |video| video.author }.reverse!
notifications.sort_by! { |video| video.author }.reverse!
end
else
if preferences.latest_only
@ -1340,7 +1416,7 @@ get "/feed/channel/:ucid" do |env|
end
nodeset = document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")]))
extract_videos(nodeset).each do |video|
extract_videos(nodeset, ucid).each do |video|
xml.element("entry") do
xml.element("id") { xml.text "yt:video:#{video.id}" }
xml.element("yt:videoId") { xml.text video.id }
@ -1519,31 +1595,14 @@ get "/channel/:ucid" do |env|
rss = XML.parse_html(rss.body)
author = rss.xpath_node("//feed/author/name").not_nil!.content
url = produce_playlist_url(ucid, (page - 1) * 100)
response = client.get(url)
response = JSON.parse(response.body)
if !response["content_html"]?
error_message = "This channel does not exist."
begin
videos = extract_playlist(ucid, page)
videos.each { |a| a.playlists.clear }
rescue ex
error_message = ex.message
next templated "error"
end
document = XML.parse_html(response["content_html"].as_s)
anchor = document.xpath_node(%q(//div[@class="pl-video-owner"]/a))
if !anchor
videos = [] of ChannelVideo
next templated "channel"
end
videos = [] of ChannelVideo
document.xpath_nodes(%q(//a[contains(@class,"pl-video-title-link")])).each do |node|
href = URI.parse(node["href"])
id = HTTP::Params.parse(href.query.not_nil!)["v"]
title = node.content
videos << ChannelVideo.new(id, title, Time.now, Time.now, "", "")
end
templated "channel"
end
@ -1682,7 +1741,7 @@ get "/api/v1/comments/:id" do |env|
if format == "json"
next {"comments" => [] of String}.to_json
else
next {"content_html" => ""}.to_json
next {"contentHtml" => ""}.to_json
end
end
ctoken = ctoken["ctoken"]
@ -1720,7 +1779,7 @@ get "/api/v1/comments/:id" do |env|
if format == "json"
next {"comments" => [] of String}.to_json
else
next {"content_html" => ""}.to_json
next {"contentHtml" => ""}.to_json
end
end
@ -1785,9 +1844,13 @@ get "/api/v1/comments/:id" do |env|
json.field "commentId", node_comment["commentId"]
if node_replies && !response["commentRepliesContinuation"]?
reply_count = node_replies["moreText"]["simpleText"].as_s.match(/View all (?<count>\d+) replies/)
.try &.["count"].to_i?
reply_count ||= 1
reply_count = node_replies["moreText"]["simpleText"].as_s.delete("View all reply replies,")
if reply_count.empty?
reply_count = 1
else
reply_count = reply_count.try &.to_i?
reply_count ||= 1
end
continuation = node_replies["continuations"].as_a[0]["nextContinuationData"]["continuation"].as_s
@ -1816,7 +1879,17 @@ get "/api/v1/comments/:id" do |env|
comments = JSON.parse(comments)
content_html = template_youtube_comments(comments)
next {"content_html" => content_html}.to_json
response = JSON.build do |json|
json.object do
json.field "contentHtml", content_html
if comments["commentCount"]?
json.field "commentCount", comments["commentCount"]
end
end
end
next response
end
elsif source == "reddit"
client = make_client(REDDIT_URL)
@ -1837,9 +1910,10 @@ get "/api/v1/comments/:id" do |env|
end
env.response.content_type = "application/json"
next {"title" => reddit_thread.title,
"permalink" => reddit_thread.permalink,
"content_html" => content_html}.to_json
next {"title" => reddit_thread.title,
"permalink" => reddit_thread.permalink,
"contentHtml" => content_html,
}.to_json
end
end
@ -1868,7 +1942,7 @@ get "/api/v1/videos/:id" do |env|
generate_thumbnails(json, video.id)
end
description = html_to_description(video.description)
description, video.description = html_to_description(video.description)
json.field "description", description
json.field "descriptionHtml", video.description
@ -2127,10 +2201,16 @@ get "/api/v1/channels/:ucid" do |env|
is_family_friendly = channel_html.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).not_nil!["content"] == "True"
allowed_regions = channel_html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).not_nil!["content"].split(",")
sub_count, total_views, joined = channel_html.xpath_nodes(%q(//span[@class="about-stat"]))
sub_count = sub_count.content.rchop(" subscribers").delete(",").to_i64
total_views = total_views.content.rchop(" views").lchop("").delete(",").to_i64
joined = Time.parse(joined.content.lchop("Joined "), "%b %-d, %Y", Time::Location.local)
anchor = channel_html.xpath_nodes(%q(//span[@class="about-stat"]))
if anchor[0].content.includes? "views"
sub_count = 0
total_views = anchor[0].content.delete("views •,").to_i64
joined = Time.parse(anchor[1].content.lchop("Joined "), "%b %-d, %Y", Time::Location.local)
else
sub_count = anchor[0].content.delete("subscribers").delete(",").to_i64
total_views = anchor[1].content.delete("views •,").to_i64
joined = Time.parse(anchor[2].content.lchop("Joined "), "%b %-d, %Y", Time::Location.local)
end
latest_videos = PG_DB.query_all("SELECT * FROM channel_videos WHERE ucid = $1 ORDER BY published DESC LIMIT 15",
channel.id, as: ChannelVideo)
@ -2316,7 +2396,7 @@ get "/api/v1/search" do |env|
response = JSON.build do |json|
json.array do
search_results = search(query, page, search_params)
count, search_results = search(query, page, search_params).as(Tuple)
search_results.each do |video|
json.object do
json.field "title", video.title
@ -2343,6 +2423,65 @@ get "/api/v1/search" do |env|
response
end
get "/api/v1/playlists/:plid" do |env|
plid = env.params.url["plid"]
page = env.params.query["page"]?.try &.to_i?
page ||= 1
begin
videos = extract_playlist(plid, page)
rescue ex
env.response.content_type = "application/json"
response = {"error" => "Playlist is empty"}.to_json
halt env, status_code: 404, response: response
end
playlist = fetch_playlist(plid)
response = JSON.build do |json|
json.object do
json.field "title", playlist.title
json.field "id", playlist.id
json.field "author", playlist.author
json.field "authorId", playlist.ucid
json.field "authorUrl", "/channel/#{playlist.ucid}"
json.field "description", playlist.description
json.field "videoCount", playlist.video_count
json.field "viewCount", playlist.views
json.field "updated", playlist.updated.epoch
json.field "videos" do
json.array do
videos.each do |video|
json.object do
json.field "title", video.title
json.field "id", video.id
json.field "author", video.author
json.field "authorId", video.ucid
json.field "authorUrl", "/channel/#{video.ucid}"
json.field "videoThumbnails" do
generate_thumbnails(json, video.id)
end
json.field "index", video.index
json.field "lengthSeconds", video.length_seconds
end
end
end
end
end
end
env.response.content_type = "application/json"
response
end
get "/api/manifest/dash/id/videoplayback" do |env|
env.response.headers["Access-Control-Allow-Origin"] = "*"
env.redirect "/videoplayback?#{env.params.query}"
@ -2551,6 +2690,12 @@ get "/videoplayback" do |env|
client = make_client(URI.parse(host))
response = client.head(url)
if response.headers["Location"]?
url = URI.parse(response.headers["Location"])
env.response.headers["Access-Control-Allow-Origin"] = "*"
next env.redirect url.full_path
end
headers = env.request.headers
headers.delete("Host")
headers.delete("Cookie")
@ -2558,30 +2703,24 @@ get "/videoplayback" do |env|
headers.delete("Referer")
client.get(url, headers) do |response|
if response.headers["Location"]?
url = URI.parse(response.headers["Location"])
env.response.headers["Access-Control-Allow-Origin"] = "*"
env.redirect url.full_path
else
env.response.status_code = response.status_code
env.response.status_code = response.status_code
response.headers.each do |key, value|
env.response.headers[key] = value
end
env.response.headers["Access-Control-Allow-Origin"] = "*"
begin
chunk_size = 4096
size = 1
while size > 0
size = IO.copy(response.body_io, env.response.output, chunk_size)
env.response.flush
Fiber.yield
end
rescue ex
break
response.headers.each do |key, value|
env.response.headers[key] = value
end
env.response.headers["Access-Control-Allow-Origin"] = "*"
begin
chunk_size = 4096
size = 1
while size > 0
size = IO.copy(response.body_io, env.response.output, chunk_size)
env.response.flush
Fiber.yield
end
rescue ex
break
end
end
end
@ -2611,10 +2750,6 @@ if Kemal.config.ssl
server.bind_tcp "0.0.0.0", 80
server.listen
end
before_all do |env|
env.response.headers.add("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
end
end
static_headers do |response, filepath, filestat|

View File

@ -93,7 +93,7 @@ def template_youtube_comments(comments)
<div class="pure-u-23-24">
<p>
<a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}"
onclick="load_comments(this)">View #{child["replies"]["replyCount"]} replies</a>
onclick="get_youtube_replies(this)">View #{child["replies"]["replyCount"]} replies</a>
</p>
</div>
</div>
@ -103,7 +103,7 @@ def template_youtube_comments(comments)
html += <<-END_HTML
<div class="pure-g">
<div class="pure-u-2-24">
<img style="width:90%; padding-right:1em; padding-top:1em;" src="#{child["authorThumbnails"][0]["url"]}">
<img style="width:90%; padding-right:1em; padding-top:1em;" src="#{child["authorThumbnails"][-1]["url"]}">
</div>
<div class="pure-u-22-24">
<p>
@ -127,7 +127,7 @@ def template_youtube_comments(comments)
<div class="pure-u-1">
<p>
<a href="javascript:void(0)" data-continuation="#{comments["continuation"]}"
onclick="load_comments(this)">Load more</a>
onclick="get_youtube_replies(this)">Load more</a>
</p>
</div>
</div>
@ -262,7 +262,7 @@ def fill_links(html, scheme, host)
end
if host == "www.youtube.com"
html = html.xpath_node(%q(//p[@id="eow-description"])).not_nil!.to_xml
html = html.xpath_node(%q(//body)).not_nil!.to_xml
else
html = html.to_xml(options: XML::SaveOptions::NO_DECL)
end

View File

@ -116,39 +116,6 @@ def login_req(login_form, f_req)
return HTTP::Params.encode(data)
end
def produce_playlist_url(ucid, index)
ucid = ucid.lchop("UC")
ucid = "VLUU" + ucid
continuation = write_var_int(index)
continuation.unshift(0x08_u8)
slice = continuation.to_unsafe.to_slice(continuation.size)
continuation = Base64.urlsafe_encode(slice, false)
continuation = "PT:" + continuation
continuation = continuation.bytes
continuation.unshift(0x7a_u8, continuation.size.to_u8)
slice = continuation.to_unsafe.to_slice(continuation.size)
continuation = Base64.urlsafe_encode(slice)
continuation = URI.escape(continuation)
continuation = continuation.bytes
continuation.unshift(continuation.size.to_u8)
continuation.unshift(ucid.size.to_u8)
continuation = ucid.bytes + continuation
continuation.unshift(0x12.to_u8, ucid.size.to_u8)
continuation.unshift(0xe2_u8, 0xa9_u8, 0x85_u8, 0xb2_u8, 2_u8, continuation.size.to_u8)
slice = continuation.to_unsafe.to_slice(continuation.size)
continuation = Base64.urlsafe_encode(slice)
continuation = URI.escape(continuation)
url = "/browse_ajax?action_continuation=1&continuation=#{continuation}"
return url
end
def produce_videos_url(ucid, page = 1)
page = "#{page}"
@ -268,7 +235,7 @@ def generate_captcha(key)
answer = "#{hour}:#{minute.to_s.rjust(2, '0')}"
token = OpenSSL::HMAC.digest(:sha256, key, answer)
token = Base64.encode(token)
token = Base64.urlsafe_encode(token)
return {challenge: challenge, token: token}
end
@ -301,6 +268,15 @@ def extract_videos(nodeset, ucid = nil)
next
end
case node.xpath_node(%q(.//div)).not_nil!["class"]
when .includes? "yt-lockup-movie-vertical-poster"
next
when .includes? "yt-lockup-playlist"
next
when .includes? "yt-lockup-channel"
next
end
title = anchor.content.strip
id = anchor["href"].lchop("/watch?v=")
@ -317,38 +293,31 @@ def extract_videos(nodeset, ucid = nil)
author_id = anchor["href"].split("/")[-1]
end
# Skip playlists
if node.xpath_node(%q(.//div[contains(@class, "yt-playlist-renderer")]))
next
end
# Skip movies
if node.xpath_node(%q(.//div[contains(@class, "yt-lockup-movie-top-content")]))
next
end
metadata = node.xpath_nodes(%q(.//div[contains(@class,"yt-lockup-meta")]/ul/li))
if metadata.size == 0
if metadata.empty?
next
elsif metadata.size == 1
if metadata[0].content.starts_with? "Starts"
view_count = 0_i64
published = Time.epoch(metadata[0].xpath_node(%q(.//span)).not_nil!["data-timestamp"].to_i64)
else
view_count = metadata[0].content.lchop("Streamed ").split(" ")[0].delete(",").to_i64
published = Time.now
end
else
published = decode_date(metadata[0].content)
view_count = metadata[1].content.split(" ")[0]
if view_count == "No"
view_count = 0_i64
else
view_count = view_count.delete(",").to_i64
end
end
begin
published = decode_date(metadata[0].content.lchop("Streamed ").lchop("Starts "))
rescue ex
end
begin
published ||= Time.epoch(metadata[0].xpath_node(%q(.//span)).not_nil!["data-timestamp"].to_i64)
rescue ex
end
published ||= Time.now
begin
view_count = metadata[0].content.rchop(" watching").delete(",").try &.to_i64?
rescue ex
end
begin
view_count ||= metadata.try &.[1].content.delete("No views,").try &.to_i64?
rescue ex
end
view_count ||= 0_i64
description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")]))
description, description_html = html_to_description(description_html)

View File

@ -9,7 +9,7 @@ macro add_mapping(mapping)
DB.mapping({{mapping}})
end
macro templated(filename, template = "layout")
macro templated(filename, template = "template")
render "src/invidious/views/#{{{filename}}}.ecr", "src/invidious/views/#{{{template}}}.ecr"
end

View File

@ -31,11 +31,11 @@ class HTTPProxy
if resp[:code]? == 200
{% if !flag?(:without_openssl) %}
if tls
tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: host)
socket = tls_socket
end
{% end %}
if tls
tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: host)
socket = tls_socket
end
{% end %}
return socket
else
@ -98,26 +98,44 @@ def get_proxies(country_code = "US")
headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8"
headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9"
headers["Host"] = "spys.one"
headers["Origin"] = "http://spys.one"
headers["Referer"] = "http://spys.one/free-proxy-list/#{country_code}/"
headers["Content-Type"] = "application/x-www-form-urlencoded"
body = {
"xpp" => "2",
"xpp" => "5",
"xf1" => "0",
"xf2" => "2",
"xf4" => "1",
"xf2" => "0",
"xf4" => "0",
"xf5" => "1",
}
response = client.post("/free-proxy-list/#{country_code}/", headers, form: body)
response = XML.parse_html(response.body)
mapping = response.xpath_node(%q(.//body/script)).not_nil!.content
mapping = mapping.match(/\}\('(?<p>[^']+)',\d+,\d+,'(?<x>[^']+)'/).not_nil!
p = mapping["p"].not_nil!
x = mapping["x"].not_nil!
mapping = decrypt_port(p, x)
proxies = [] of {ip: String, port: Int32, score: Float64}
response = response.xpath_nodes(%q(//table))[1]
response = response.xpath_node(%q(//tr/td/table)).not_nil!
response.xpath_nodes(%q(.//tr)).each do |node|
if !node["onmouseover"]?
next
end
ip = node.xpath_node(%q(.//td[1]/font[2])).to_s.match(/<font class="spy14">(?<address>[^<]+)</).not_nil!["address"]
port = 3128
encrypted_port = node.xpath_node(%q(.//td[1]/font[2]/script)).not_nil!.content
encrypted_port = encrypted_port.match(/<\\\/font>"\+(?<encrypted_port>[\d\D]+)\)$/).not_nil!["encrypted_port"]
port = ""
encrypted_port.split("+").each do |number|
number = number.delete("()")
left_side, right_side = number.split("^")
result = mapping[left_side] ^ mapping[right_side]
port = "#{port}#{result}"
end
port = port.to_i
latency = node.xpath_node(%q(.//td[6])).not_nil!.content.to_f
speed = node.xpath_node(%q(.//td[7]/font/table)).not_nil!["width"].to_f
@ -141,3 +159,52 @@ def get_proxies(country_code = "US")
return proxies
end
def decrypt_port(p, x)
x = x.split("^")
s = {} of String => String
60.times do |i|
if x[i]?.try &.empty?
s[y_func(i)] = y_func(i)
else
s[y_func(i)] = x[i]
end
end
x = s
p = p.gsub(/\b\w+\b/, x)
p = p.split(";")
p = p.map { |item| item.split("=") }
mapping = {} of String => Int32
p.each do |item|
if item == [""]
next
end
key = item[0]
value = item[1]
value = value.split("^")
if value.size == 1
value = value[0].to_i
else
left_side = value[0].to_i?
left_side ||= mapping[value[0]]
right_side = value[1].to_i?
right_side ||= mapping[value[1]]
value = left_side ^ right_side
end
mapping[key] = value
end
return mapping
end
def y_func(c)
return (c < 60 ? "" : y_func((c/60).to_i)) + ((c = c % 60) > 35 ? ((c.to_u8 + 29).unsafe_chr) : c.to_s(36))
end

View File

@ -64,10 +64,23 @@ end
def decode_date(string : String)
# String matches 'YYYY'
if string.match(/\d{4}/)
if string.match(/^\d{4}/)
return Time.new(string.to_i, 1, 1)
end
# Try to parse as format Jul 10, 2000
begin
return Time.parse(string, "%b %-d, %Y", Time::Location.local)
rescue ex
end
case string
when "today"
return Time.now
when "yesterday"
return Time.now - 1.day
end
# String matches format "20 hours ago", "4 months ago"...
date = string.split(" ")[-3, 3]
delta = date[0].to_i
@ -150,10 +163,27 @@ def make_host_url(ssl, host)
end
def get_referer(env, fallback = "/")
referer = env.request.headers["referer"]?
referer = env.params.query["referer"]?
referer ||= env.request.headers["referer"]?
referer ||= fallback
referer = URI.parse(referer).full_path
referer = URI.parse(referer)
# "Unroll" nested referers
loop do
if referer.query
params = HTTP::Params.parse(referer.query.not_nil!)
if params["referer"]?
referer = URI.parse(URI.unescape(params["referer"]))
else
break
end
else
break
end
end
referer = referer.full_path
if referer == env.request.path
referer = fallback

View File

@ -2,13 +2,13 @@ def crawl_videos(db)
ids = Deque(String).new
random = Random.new
search(random.base64(3)).each do |video|
search(random.base64(3)).as(Tuple)[1].each do |video|
ids << video.id
end
loop do
if ids.empty?
search(random.base64(3)).each do |video|
search(random.base64(3)).as(Tuple)[1].each do |video|
ids << video.id
end
end

160
src/invidious/playlists.cr Normal file
View File

@ -0,0 +1,160 @@
class Playlist
add_mapping({
title: String,
id: String,
author: String,
ucid: String,
description: String,
video_count: Int32,
views: Int64,
updated: Time,
})
end
class PlaylistVideo
add_mapping({
title: String,
id: String,
author: String,
ucid: String,
length_seconds: Int32,
published: Time,
playlists: Array(String),
index: Int32,
})
end
def extract_playlist(plid, page)
index = (page - 1) * 100
url = produce_playlist_url(plid, index)
client = make_client(YT_URL)
response = client.get(url)
response = JSON.parse(response.body)
if !response["content_html"]? || response["content_html"].as_s.empty?
raise "Playlist does not exist"
end
videos = [] of PlaylistVideo
document = XML.parse_html(response["content_html"].as_s)
anchor = document.xpath_node(%q(//div[@class="pl-video-owner"]/a))
if anchor
document.xpath_nodes(%q(.//tr[contains(@class, "pl-video")])).each_with_index do |video, offset|
anchor = video.xpath_node(%q(.//td[@class="pl-video-title"]))
if !anchor
next
end
title = anchor.xpath_node(%q(.//a)).not_nil!.content.strip(" \n")
id = anchor.xpath_node(%q(.//a)).not_nil!["href"].lchop("/watch?v=")[0, 11]
anchor = anchor.xpath_node(%q(.//div[@class="pl-video-owner"]/a))
if anchor
author = anchor.content
ucid = anchor["href"].split("/")[2]
else
author = ""
ucid = ""
end
anchor = video.xpath_node(%q(.//td[@class="pl-video-time"]/div/div[1]))
if anchor && !anchor.content.empty?
length_seconds = decode_length_seconds(anchor.content)
else
length_seconds = 0
end
videos << PlaylistVideo.new(
title,
id,
author,
ucid,
length_seconds,
Time.now,
[plid],
index + offset,
)
end
end
return videos
end
def produce_playlist_url(id, index)
if id.starts_with? "UC"
id = "UU" + id.lchop("UC")
end
ucid = "VL" + id
continuation = [0x08_u8] + write_var_int(index)
slice = continuation.to_unsafe.to_slice(continuation.size)
slice = Base64.urlsafe_encode(slice, false)
# Inner Base64
continuation = "PT:" + slice
continuation = [0x7a_u8, continuation.bytes.size.to_u8] + continuation.bytes
slice = continuation.to_unsafe.to_slice(continuation.size)
slice = Base64.urlsafe_encode(slice)
slice = URI.escape(slice)
# Outer Base64
continuation = [0x1a.to_u8, slice.bytes.size.to_u8] + slice.bytes
continuation = ucid.bytes + continuation
continuation = [0x12_u8, ucid.size.to_u8] + continuation
continuation = [0xe2_u8, 0xa9_u8, 0x85_u8, 0xb2_u8, 2_u8, continuation.size.to_u8] + continuation
# Wrap bytes
slice = continuation.to_unsafe.to_slice(continuation.size)
slice = Base64.urlsafe_encode(slice)
slice = URI.escape(slice)
continuation = slice
url = "/browse_ajax?action_continuation=1&continuation=#{continuation}"
return url
end
def fetch_playlist(plid)
client = make_client(YT_URL)
response = client.get("/playlist?list=#{plid}&disable_polymer=1")
document = XML.parse_html(response.body)
title = document.xpath_node(%q(//h1[@class="pl-header-title"])).not_nil!.content
title = title.strip(" \n")
description = document.xpath_node(%q(//span[@class="pl-header-description-text"]/div/div[1]))
description ||= document.xpath_node(%q(//span[@class="pl-header-description-text"]))
if description
description = description.to_xml.strip(" \n")
description = description.split("<button ")[0]
description = fill_links(description, "https", "www.youtube.com")
description = add_alt_links(description)
else
description = ""
end
anchor = document.xpath_node(%q(//ul[@class="pl-header-details"])).not_nil!
author = anchor.xpath_node(%q(.//li[1]/a)).not_nil!.content
ucid = anchor.xpath_node(%q(.//li[1]/a)).not_nil!["href"].split("/")[2]
video_count = anchor.xpath_node(%q(.//li[2])).not_nil!.content.delete("videos, ").to_i
views = anchor.xpath_node(%q(.//li[3])).not_nil!.content.delete("views, ").to_i64
updated = anchor.xpath_node(%q(.//li[4])).not_nil!.content.lchop("Last updated on ").lchop("Updated ")
updated = decode_date(updated)
playlist = Playlist.new(
title,
plid,
author,
ucid,
description,
video_count,
views,
updated
)
return playlist
end

View File

@ -14,21 +14,26 @@ end
def search(query, page = 1, search_params = build_search_params(content_type: "video"))
client = make_client(YT_URL)
if query.empty?
return {0, [] of SearchVideo}
end
html = client.get("/results?q=#{URI.escape(query)}&page=#{page}&sp=#{search_params}&disable_polymer=1").body
if html.empty?
return [] of SearchVideo
return {0, [] of SearchVideo}
end
html = XML.parse_html(html)
nodeset = html.xpath_nodes(%q(//ol[@class="item-section"]/li))
videos = extract_videos(nodeset)
return videos
return {nodeset.size, videos}
end
def build_search_params(sort_by = "relevance", date : String = "", content_type : String = "", duration : String = "", features : Array(String) = [] of String)
def build_search_params(sort : String = "relevance", date : String = "", content_type : String = "",
duration : String = "", features : Array(String) = [] of String)
head = "\x08"
head += case sort_by
head += case sort
when "relevance"
"\x00"
when "rating"
@ -38,7 +43,7 @@ def build_search_params(sort_by = "relevance", date : String = "", content_type
when "view_count"
"\x03"
else
raise "No sort #{sort_by}"
raise "No sort #{sort}"
end
body = ""

View File

@ -3,7 +3,7 @@ def fetch_decrypt_function(client, id = "CvFH_6DNRCY")
url = document.match(/src="(?<url>\/yts\/jsbin\/player-.{9}\/en_US\/base.js)"/).not_nil!["url"]
player = client.get(url).body
function_name = player.match(/\(b\|\|\(b="signature"\),d.set\(b,(?<name>[a-zA-Z0-9]{2})\(c\)\)\)/).not_nil!["name"]
function_name = player.match(/"signature",(?<name>[a-zA-Z0-9]{2})\(/).not_nil!["name"]
function_body = player.match(/#{function_name}=function\(a\){(?<body>[^}]+)}/).not_nil!["body"]
function_body = function_body.split(";")[1..-2]

View File

@ -10,7 +10,7 @@ class User
end
add_mapping({
id: String,
id: Array(String),
updated: Time,
notifications: Array(String),
subscriptions: Array(String),
@ -32,7 +32,7 @@ DEFAULT_USER_PREFERENCES = Preferences.from_json({
"speed" => 1.0,
"quality" => "hd720",
"volume" => 100,
"comments" => "youtube",
"comments" => ["youtube", ""],
"captions" => ["", "", ""],
"dark_mode" => false,
"thin_mode " => false,
@ -43,6 +43,29 @@ DEFAULT_USER_PREFERENCES = Preferences.from_json({
}.to_json)
class Preferences
module StringToArray
def self.to_json(value : Array(String), json : JSON::Builder)
json.array do
value.each do |element|
json.string element
end
end
end
def self.from_json(value : JSON::PullParser) : Array(String)
begin
result = [] of String
value.read_array do
result << value.read_string
end
rescue ex
result = [value.read_string, ""]
end
result
end
end
JSON.mapping({
video_loop: Bool,
autoplay: Bool,
@ -50,8 +73,9 @@ class Preferences
quality: String,
volume: Int32,
comments: {
type: String,
default: "youtube",
type: Array(String),
default: ["youtube", ""],
converter: StringToArray,
},
captions: {
type: Array(String),
@ -78,8 +102,8 @@ class Preferences
end
def get_user(sid, client, headers, db, refresh = true)
if db.query_one?("SELECT EXISTS (SELECT true FROM users WHERE id = $1)", sid, as: Bool)
user = db.query_one("SELECT * FROM users WHERE id = $1", sid, as: User)
if db.query_one?("SELECT EXISTS (SELECT true FROM users WHERE $1 = ANY(id))", sid, as: Bool)
user = db.query_one("SELECT * FROM users WHERE $1 = ANY(id)", sid, as: User)
if refresh && Time.now - user.updated > 1.minute
user = fetch_user(sid, client, headers, db)
@ -89,7 +113,7 @@ def get_user(sid, client, headers, db, refresh = true)
args = arg_array(user_array)
db.exec("INSERT INTO users VALUES (#{args}) \
ON CONFLICT (email) DO UPDATE SET id = $1, updated = $2, subscriptions = $4", user_array)
ON CONFLICT (email) DO UPDATE SET id = users.id || $1, updated = $2, subscriptions = $4", user_array)
end
else
user = fetch_user(sid, client, headers, db)
@ -99,7 +123,7 @@ def get_user(sid, client, headers, db, refresh = true)
args = arg_array(user.to_a)
db.exec("INSERT INTO users VALUES (#{args}) \
ON CONFLICT (email) DO UPDATE SET id = $1, updated = $2, subscriptions = $4", user_array)
ON CONFLICT (email) DO UPDATE SET id = users.id || $1, updated = $2, subscriptions = $4", user_array)
end
return user
@ -132,7 +156,7 @@ def fetch_user(sid, client, headers, db)
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
user = User.new(sid, Time.now, [] of String, channels, email, DEFAULT_USER_PREFERENCES, nil, token, [] of String)
user = User.new([sid], Time.now, [] of String, channels, email, DEFAULT_USER_PREFERENCES, nil, token, [] of String)
return user
end
@ -140,7 +164,7 @@ def create_user(sid, email, password)
password = Crypto::Bcrypt::Password.create(password, cost: 10)
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
user = User.new(sid, Time.now, [] of String, [] of String, email, DEFAULT_USER_PREFERENCES, password.to_s, token, [] of String)
user = User.new([sid], Time.now, [] of String, [] of String, email, DEFAULT_USER_PREFERENCES, password.to_s, token, [] of String)
return user
end

View File

@ -228,6 +228,8 @@ VIDEO_FORMATS = {
}
class Video
property player_json : JSON::Any?
module HTTPParamConverter
def self.from_rs(rs)
HTTP::Params.parse(rs.read(String))
@ -287,9 +289,15 @@ class Video
return audio_streams
end
def captions
player_response = JSON.parse(self.info["player_response"])
def player_response
if !@player_json
@player_json = JSON.parse(@info["player_response"])
end
return @player_json.not_nil!
end
def captions
captions = [] of Caption
if player_response["captions"]?
caption_list = player_response["captions"]["playerCaptionsTracklistRenderer"]["captionTracks"]?.try &.as_a
@ -496,16 +504,29 @@ end
def process_video_params(query, preferences)
autoplay = query["autoplay"]?.try &.to_i?
preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase }
quality = query["quality"]?
speed = query["speed"]?.try &.to_f?
video_loop = query["loop"]?.try &.to_i?
volume = query["volume"]?.try &.to_i?
if preferences
autoplay ||= preferences.autoplay.to_unsafe
preferred_captions ||= preferences.captions
quality ||= preferences.quality
speed ||= preferences.speed
video_loop ||= preferences.video_loop.to_unsafe
volume ||= preferences.volume
end
autoplay ||= 0
autoplay = autoplay == 1
autoplay ||= 0
preferred_captions ||= [] of String
quality ||= "hd720"
speed ||= 1
video_loop ||= 0
volume ||= 100
autoplay = autoplay == 1
video_loop = video_loop == 1
if query["t"]?
@ -534,14 +555,25 @@ def process_video_params(query, preferences)
raw ||= 0
raw = raw == 1
quality = query["quality"]?
quality ||= "hd720"
controls = query["controls"]?.try &.to_i?
controls ||= 1
controls = controls == 1
return autoplay, video_loop, video_start, video_end, listen, raw, quality, controls
params = {
autoplay: autoplay,
controls: controls,
listen: listen,
preferred_captions: preferred_captions,
quality: quality,
raw: raw,
speed: speed,
video_end: video_end,
video_loop: video_loop,
video_start: video_start,
volume: volume,
}
return params
end
def generate_thumbnails(json, id)

View File

@ -16,21 +16,25 @@
<p class="h-box">
<% if user %>
<% if subscriptions.includes? ucid %>
<a href="/subscription_ajax?action_remove_subscriptions=1&c=<%= ucid %>">
<a href="/subscription_ajax?action_remove_subscriptions=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>">
<b>Unsubscribe from <%= author %></b>
</a>
<% else %>
<a href="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= ucid %>">
<a href="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>">
<b>Subscribe to <%= author %></b>
</a>
<% end %>
<% else %>
<a href="/login">
<a href="/login?referer=<%= env.get("current_page") %>">
<b>Login to subscribe to <%= author %></b>
</a>
<% end %>
</p>
<p class="h-box">
<a href="https://www.youtube.com/channel/<%= ucid %>">View channel on YouTube</a>
</p>
<% videos.each_slice(4) do |slice| %>
<div class="pure-g">
<% slice.each do |video| %>
@ -41,14 +45,14 @@
<div class="pure-g h-box">
<div class="pure-u-1 pure-u-md-1-5">
<% if page > 2 %>
<% if page >= 2 %>
<a href="/channel/<%= ucid %>?page=<%= page - 1 %>">Previous page</a>
<% else %>
<a href="/channel/<%= ucid %>">Previous page</a>
<% end %>
</div>
<div class="pure-u-1 pure-u-md-3-5"></div>
<div style="text-align:right;" class="pure-u-1 pure-u-md-1-5">
<% if videos.size == 100 %>
<a href="/channel/<%= ucid %>?page=<%= page + 1 %>">Next page</a>
<% end %>
</div>
</div>
</div>

View File

@ -1,19 +1,19 @@
<video style="width:100%" playsinline poster="<%= thumbnail %>" title="<%= HTML.escape(video.title) %>"
id="player" class="video-js"
<% if autoplay %>autoplay<% end %>
<% if video_loop %>loop<% end %>
<% if controls %>controls<% end %>>
<% if params[:autoplay] %>autoplay<% end %>
<% if params[:video_loop] %>loop<% end %>
<% if params[:controls] %>controls<% end %>>
<% if hlsvp %>
<source src="<%= hlsvp %>" type="application/x-mpegURL">
<% else %>
<% if listen %>
<% if params[:listen] %>
<% audio_streams.each_with_index do |fmt, i| %>
<source src="<%= fmt["url"] %>" type='<%= fmt["type"] %>' label="<%= fmt["bitrate"] %>k" selected="<%= i == 0 ? true : false %>">
<% end %>
<% else %>
<% fmt_stream.each_with_index do |fmt, i| %>
<% if preferences %>
<source src="<%= fmt["url"] %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= preferences.quality == fmt["label"].split(" - ")[0] %>">
<% if params[:quality] %>
<source src="<%= fmt["url"] %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= params[:quality] == fmt["label"].split(" - ")[0] %>">
<% else %>
<source src="<%= fmt["url"] %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= i == 0 ? true : false %>">
<% end %>
@ -110,7 +110,7 @@ var player = videojs("player", options, function() {
player.share(shareOptions);
<% if video_start > 0 || video_end > 0 %>
<% if params[:video_start] > 0 || params[:video_end] > 0 %>
player.markers({
onMarkerReached: function(marker) {
if (marker.text === "End") {
@ -122,19 +122,19 @@ player.markers({
}
},
markers: [
{ time: <%= video_start %>, text: "Start" },
<% if video_end < 0 %>
{ time: <%= params[:video_start] %>, text: "Start" },
<% if params[:video_end] < 0 %>
{ time: <%= video.info["length_seconds"].to_f - 0.5 %>, text: "End" }
<% else %>
{ time: <%= video_end %>, text: "End" }
{ time: <%= params[:video_end] %>, text: "End" }
<% end %>
]
});
player.currentTime(<%= video_start %>);
player.currentTime(<%= params[:video_start] %>);
<% end %>
<% if !listen %>
<% if !params[:listen] %>
var currentSources = player.currentSources();
for (var i = 0; i < currentSources.length; i++) {
if (player.canPlayType(currentSources[i]["type"].split(";")[0]) === "") {
@ -146,8 +146,6 @@ for (var i = 0; i < currentSources.length; i++) {
player.src(currentSources);
<% end %>
<% if preferences %>
player.volume(<%= preferences.volume.to_f / 100 %>);
player.playbackRate(<%= preferences.speed %>);
<% end %>
player.volume(<%= params[:volume].to_f / 100 %>);
player.playbackRate(<%= params[:speed] %>);
</script>

View File

@ -1,6 +1,11 @@
<div class="pure-u-1 pure-u-md-1-4">
<div class="h-box">
<a style="width:100%;" href="/watch?v=<%= video.id %>">
<% if video.responds_to?(:playlists) && !video.playlists.empty? %>
<% params = "&list=#{video.playlists[0]}" %>
<% else %>
<% params = nil %>
<% end %>
<a style="width:100%;" href="/watch?v=<%= video.id %><%= params %>">
<% if env.get?("user") && env.get("user").as(User).preferences.thin_mode %>
<% else %>
<img style="width:100%;" src="https://i.ytimg.com/vi/<%= video.id %>/mqdefault.jpg"/>
@ -15,4 +20,4 @@
<h5>Shared <%= recode_date(video.published) %> ago</h5>
<% end %>
</div>
</div>
</div>

View File

@ -3,7 +3,7 @@
<% end %>
<div class="h-box">
<form class="pure-form pure-form-aligned" enctype="multipart/form-data" action="/data_control" method="post">
<form class="pure-form pure-form-aligned" enctype="multipart/form-data" action="/data_control?referer=<%= referer %>" method="post">
<fieldset>
<legend>Import</legend>
@ -52,4 +52,4 @@
</div>
</fieldset>
</form>
</div>
</div>

View File

@ -26,4 +26,4 @@
<body>
<%= rendered "components/player" %>
</body>
</html>
</html>

View File

@ -4,4 +4,4 @@
<div class="h-box">
<%= error_message %>
</div>
</div>

View File

@ -16,7 +16,7 @@
</div>
<hr>
<% if account_type == "invidious" %>
<form class="pure-form pure-form-stacked" action="/login?referer=<%= referer %>&type=invidious" method="post">
<form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.escape(referer) %>&type=invidious" method="post">
<fieldset>
<label for="email">User ID:</label>
<input required class="pure-input-1" name="email" type="text" placeholder="User ID">
@ -34,7 +34,7 @@
</fieldset>
</form>
<% elsif account_type == "google" %>
<form class="pure-form pure-form-stacked" action="/login?referer=<%= referer %>" method="post">
<form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.escape(referer) %>" method="post">
<fieldset>
<label for="email">Email:</label>
<input required class="pure-input-1" name="email" type="email" placeholder="Email">

View File

@ -0,0 +1,42 @@
<% content_for "header" do %>
<title><%= playlist.title %> - Invidious</title>
<% end %>
<div class="pure-g h-box">
<div class="pure-u-2-3">
<h3><%= playlist.title %></h3>
</div>
</div>
<div class="pure-g h-box">
<div class="pure-u-1 pure-u-md-1-4">
<a href="/channel/<%= playlist.ucid %>">
<b><%= playlist.author %></b>
</a>
</div>
</div>
<div class="h-box">
<p><%= playlist.description %></p>
</div>
<% videos.each_slice(4) do |slice| %>
<div class="pure-g">
<% slice.each do |video| %>
<%= rendered "components/video" %>
<% end %>
</div>
<% end %>
<div class="pure-g h-box">
<div class="pure-u-1 pure-u-md-1-5">
<% if page >= 2 %>
<a href="/playlist?list=<%= playlist.id %>&page=<%= page - 1 %>">Next page</a>
<% end %>
</div>
<div class="pure-u-1 pure-u-md-3-5"></div>
<div style="text-align:right;" class="pure-u-1 pure-u-md-1-5">
<% if videos.size == 100 %>
<a href="/playlist?list=<%= playlist.id %>&page=<%= page + 1 %>">Next page</a>
<% end %>
</div>
</div>

View File

@ -48,10 +48,19 @@ function update_value(element) {
</div>
<div class="pure-control-group">
<label for="comments">Pull comments from: </label>
<select name="comments" id="comments">
<% {"youtube", "reddit"}.each do |option| %>
<option <% if user.preferences.comments == option %> selected <% end %>><%= option %></option>
<label for="comments_0">Default comments: </label>
<select name="comments_0" id="comments_0">
<% {"", "youtube", "reddit"}.each do |option| %>
<option <% if user.preferences.comments[0] == option %> selected <% end %>><%= option %></option>
<% end %>
</select>
</div>
<div class="pure-control-group">
<label for="comments_1">Fallback comments: </label>
<select name="comments_1" id="comments_1">
<% {"", "youtube", "reddit"}.each do |option| %>
<option <% if user.preferences.comments[1] == option %> selected <% end %>><%= option %></option>
<% end %>
</select>
</div>
@ -128,11 +137,11 @@ function update_value(element) {
<legend>Data preferences</legend>
<div class="pure-control-group">
<a href="/clear_watch_history">Clear watch history</a>
<a href="/clear_watch_history?referer=<%= referer %>">Clear watch history</a>
</div>
<div class="pure-control-group">
<a href="/data_control">Import/Export data</a>
<a href="/data_control?referer=<%= referer %>">Import/Export data</a>
</div>
<div class="pure-control-group">
@ -144,4 +153,4 @@ function update_value(element) {
</div>
</fieldset>
</form>
</div>
</div>

View File

@ -12,14 +12,14 @@
<div class="pure-g h-box">
<div class="pure-u-1 pure-u-md-1-5">
<% if page > 2 %>
<% if page >= 2 %>
<a href="/search?q=<%= query %>&page=<%= page - 1 %>">Previous page</a>
<% else %>
<a href="/search?q=<%= query %>">Previous page</a>
<% end %>
</div>
<div class="pure-u-1 pure-u-md-3-5"></div>
<div style="text-align:right;" class="pure-u-1 pure-u-md-1-5">
<% if count == 20 %>
<a href="/search?q=<%= query %>&page=<%= page + 1 %>">Next page</a>
<% end %>
</div>
</div>
</div>

View File

@ -8,7 +8,7 @@
</div>
<div class="pure-u-1-3" style="text-align:right;">
<h3>
<a href="/data_control">Import/Export</a>
<a href="/data_control?referer=<%= referer %>">Import/Export</a>
</h3>
</div>
</div>
@ -33,4 +33,4 @@
<hr>
<% end %>
</div>
<% end %>
<% end %>

View File

@ -38,14 +38,14 @@
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-5">
<% if page > 2 %>
<% if page >= 2 %>
<a href="/feed/subscriptions?max_results=<%= max_results %>&page=<%= page - 1 %>">Previous page</a>
<% else %>
<a href="/feed/subscriptions?max_results=<%= max_results %>">Previous page</a>
<% end %>
</div>
<div class="pure-u-1 pure-u-md-3-5"></div>
<div style="text-align:right;" class="pure-u-1 pure-u-md-1-5">
<a href="/feed/subscriptions?max_results=<%= max_results %>&page=<%= page + 1 %>">Next page</a>
<% if (videos.size + notifications.size) == max_results %>
<a href="/feed/subscriptions?max_results=<%= max_results %>&page=<%= page + 1 %>">Next page</a>
<% end %>
</div>
</div>
</div>

View File

@ -34,7 +34,7 @@
<div class="pure-u-1 pure-u-md-8-24 user-field">
<% if env.get? "user" %>
<div class="pure-u-1-4">
<a href="/toggle_theme" class="pure-menu-heading">
<a href="/toggle_theme?referer=<%= env.get("current_page") %>" class="pure-menu-heading">
<% preferences = env.get("user").as(User).preferences %>
<% if preferences.dark_mode %>
<i class="icon ion-ios-sunny"></i>
@ -54,15 +54,15 @@
</a>
</div>
<div class="pure-u-1-4">
<a href="/preferences" class="pure-menu-heading">
<a href="/preferences?referer=<%= env.get("current_page") %>" class="pure-menu-heading">
<i class="icon ion-ios-cog"></i>
</a>
</div>
<div class="pure-u-1-4">
<a href="/signout" class="pure-menu-heading">Sign out</a>
<a href="/signout?referer=<%= env.get("current_page") %>" class="pure-menu-heading">Sign out</a>
</div>
<% else %>
<a href="/login" class="pure-menu-heading">Login</a>
<a href="/login?referer=<%= env.get("current_page") %>" class="pure-menu-heading">Login</a>
<% end %>
</div>
</div>
@ -72,6 +72,18 @@
Roth</a>.
Source available <a
href="https://github.com/omarroth/invidious">here</a>.
<p>Liberapay:
<a href="https://liberapay.com/omarroth">
https://liberapay.com/omarroth
</a>
</p>
<p>Patreon:
<a href="https://patreon.com/omarroth">
https://patreon.com/omarroth
</a>
</p>
<p>BTC: 356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY</p>
<p>BCH: qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk</p>
</div>
</div>
<div class="pure-u-1 pure-u-md-4-24"></div>

View File

@ -30,155 +30,10 @@
<%= rendered "components/player" %>
</div>
<script>
function toggle(target) {
body = target.parentNode.parentNode.children[1];
if (body.style.display === null || body.style.display === "") {
target.innerHTML = "[ + ]";
body.style.display = "none";
} else {
target.innerHTML = "[ - ]";
body.style.display = "";
}
}
function toggle_comments(target) {
body = target.parentNode.parentNode.parentNode.children[1];
if (body.style.display === null || body.style.display === "") {
target.innerHTML = "[ + ]";
body.style.display = "none";
} else {
target.innerHTML = "[ - ]";
body.style.display = "";
}
}
function load_comments(target) {
var continuation = target.getAttribute("data-continuation");
var body = target.parentNode.parentNode;
var fallback = body.innerHTML;
body.innerHTML =
'<h3><center class="loading"><i class="icon ion-ios-refresh"></i></center></h3>';
var url =
"/api/v1/comments/<%= video.id %>?format=html&continuation=" + continuation;
var xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.timeout = 20000;
xhr.open("GET", url, true);
xhr.send();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
if (xhr.status == 200) {
body.innerHTML = xhr.response.content_html;
} else {
body.innerHTML = fallback;
}
}
};
xhr.ontimeout = function() {
body.innerHTML = fallback;
};
}
function get_reddit_comments() {
var url = "/api/v1/comments/<%= video.id %>?source=reddit";
var xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.timeout = 20000;
xhr.open("GET", url, true);
xhr.send();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4)
if (xhr.status == 200) {
comments = document.getElementById("comments");
comments.innerHTML = `
<div>
<h3>
<a href="javascript:void(0)" onclick="toggle_comments(this)">[ - ]</a>
{title}
</h3>
<b>
<a target="_blank" href="https://reddit.com{permalink}">View more comments on Reddit</a>
</b>
</div>
<div>{content_html}</div>
<hr>`.supplant({
title: xhr.response.title,
permalink: xhr.response.permalink,
content_html: xhr.response.content_html
});
} else {
get_youtube_comments();
}
};
xhr.ontimeout = function() {
get_reddit_comments();
};
}
function get_youtube_comments() {
var url = "/api/v1/comments/<%= video.id %>?format=html";
var xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.timeout = 20000;
xhr.open("GET", url, true);
xhr.send();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4)
if (xhr.status == 200) {
comments = document.getElementById("comments");
comments.innerHTML = `
<div>
<h3>
<a href="javascript:void(0)" onclick="toggle_comments(this)">[ - ]</a>
<a target="_blank" href="https://www.youtube.com/watch?v=<%= video.id %>">View more comments on YouTube</a>
</h3>
</div>
<div>{content_html}</div>
<hr>`.supplant({
content_html: xhr.response.content_html
});
} else {
comments = document.getElementById("comments");
comments.innerHTML = "";
}
};
xhr.ontimeout = function() {
comments = document.getElementById("comments");
comments.innerHTML =
'<h3><center class="loading"><i class="icon ion-ios-refresh"></i></center></h3>';
get_youtube_comments();
};
}
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;
});
};
<% if preferences && preferences.comments == "reddit" %>
get_reddit_comments();
<% else %>
get_youtube_comments();
<% end %>
</script>
<div class="h-box">
<h1>
<%= HTML.escape(video.title) %>
<% if listen %>
<% if params[:listen] %>
<a href="/watch?<%= env.params.query %>">
<i class="icon ion-ios-videocam"></i>
</a>
@ -188,6 +43,9 @@ get_youtube_comments();
</a>
<% end %>
</h1>
<% if !reason.empty? %>
<h3><%= reason %></h3>
<% end %>
</div>
<div class="pure-g">
@ -211,9 +69,6 @@ get_youtube_comments();
<% end %>
</p>
<% end %>
<% if engage_types %>
<p id="Engage">Engage Types: <%= engage_types %></p>
<% end %>
</div>
</div>
@ -227,20 +82,20 @@ get_youtube_comments();
<% if user %>
<% if subscriptions.includes? video.ucid %>
<p>
<a href="/subscription_ajax?action_remove_subscriptions=1&c=<%= video.ucid %>">
<a href="/subscription_ajax?action_remove_subscriptions=1&c=<%= video.ucid %>&referer=<%= env.get("current_page") %>">
<b>Unsubscribe from <%= video.author %></b>
</a>
</p>
<% else %>
<p>
<a href="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= video.ucid %>">
<a href="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= video.ucid %>&referer=<%= env.get("current_page") %>">
<b>Subscribe to <%= video.author %></b>
</a>
</p>
<% end %>
<% else %>
<p>
<a href="/login">
<a href="/login?referer=<%= env.get("current_page") %>">
<b>Login to subscribe to <%= video.author %></b>
</a>
</p>
@ -276,3 +131,184 @@ get_youtube_comments();
</div>
</div>
</div>
<script>
function toggle(target) {
body = target.parentNode.parentNode.children[1];
if (body.style.display === null || body.style.display === "") {
target.innerHTML = "[ + ]";
body.style.display = "none";
} else {
target.innerHTML = "[ - ]";
body.style.display = "";
}
}
function toggle_comments(target) {
body = target.parentNode.parentNode.parentNode.children[1];
if (body.style.display === null || body.style.display === "") {
target.innerHTML = "[ + ]";
body.style.display = "none";
} else {
target.innerHTML = "[ - ]";
body.style.display = "";
}
}
function get_youtube_replies(target) {
var continuation = target.getAttribute("data-continuation");
var body = target.parentNode.parentNode;
var fallback = body.innerHTML;
body.innerHTML =
'<h3><center class="loading"><i class="icon ion-ios-refresh"></i></center></h3>';
var url =
"/api/v1/comments/<%= video.id %>?format=html&continuation=" + continuation;
var xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.timeout = 20000;
xhr.open("GET", url, true);
xhr.send();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
if (xhr.status == 200) {
body.innerHTML = xhr.response.contentHtml;
} else {
body.innerHTML = fallback;
}
}
};
xhr.ontimeout = function() {
console.log("Pulling comments timed out.");
body.innerHTML = fallback;
};
}
function get_reddit_comments() {
var url = "/api/v1/comments/<%= video.id %>?source=reddit";
var xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.timeout = 20000;
xhr.open("GET", url, true);
xhr.send();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4)
if (xhr.status == 200) {
comments = document.getElementById("comments");
comments.innerHTML = `
<div>
<h3>
<a href="javascript:void(0)" onclick="toggle_comments(this)">[ - ]</a>
{title}
</h3>
<b>
<a target="_blank" href="https://reddit.com{permalink}">View more comments on Reddit</a>
</b>
</div>
<div>{contentHtml}</div>
<hr>`.supplant({
title: xhr.response.title,
permalink: xhr.response.permalink,
contentHtml: xhr.response.contentHtml
});
} else {
<% if preferences && preferences.comments[1] == "youtube" %>
get_youtube_comments();
<% else %>
comments = document.getElementById("comments");
comments.innerHTML = "";
<% end %>
}
};
xhr.ontimeout = function() {
console.log("Pulling comments timed out.");
get_reddit_comments();
};
}
function get_youtube_comments() {
var url = "/api/v1/comments/<%= video.id %>?format=html";
var xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.timeout = 20000;
xhr.open("GET", url, true);
xhr.send();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4)
if (xhr.status == 200) {
comments = document.getElementById("comments");
comments.innerHTML = `
<div>
<h3>
<a href="javascript:void(0)" onclick="toggle_comments(this)">[ - ]</a>
View {commentCount} comments
</h3>
</div>
<div>{contentHtml}</div>
<hr>`.supplant({
contentHtml: xhr.response.contentHtml,
commentCount: commaSeparateNumber(xhr.response.commentCount)
});
} else {
<% if preferences && preferences.comments[1] == "youtube" %>
get_youtube_comments();
<% else %>
comments = document.getElementById("comments");
comments.innerHTML = "";
<% end %>
}
};
xhr.ontimeout = function() {
console.log("Pulling comments timed out.");
comments = document.getElementById("comments");
comments.innerHTML =
'<h3><center class="loading"><i class="icon ion-ios-refresh"></i></center></h3>';
get_youtube_comments();
};
}
function commaSeparateNumber(val){
while (/(\d+)(\d{3})/.test(val.toString())){
val = val.toString().replace(/(\d+)(\d{3})/, '$1'+','+'$2');
}
return val;
}
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;
});
};
<% if preferences %>
<% if preferences.comments[0] == "youtube" %>
get_youtube_comments();
<% elsif preferences.comments[0] == "reddit" %>
get_reddit_comments();
<% else %>
<% if preferences.comments[1] == "youtube" %>
get_youtube_comments();
<% elsif preferences.comments[1] == "reddit" %>
get_reddit_comments();
<% else %>
comments = document.getElementById("comments");
comments.innerHTML = "";
<% end %>
<% end %>
<% else %>
get_youtube_comments();
<% end %>
</script>