summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/invidious.cr4
-rw-r--r--src/invidious/channels/about.cr24
-rw-r--r--src/invidious/channels/community.cr12
-rw-r--r--src/invidious/comments.cr18
-rw-r--r--src/invidious/exceptions.cr4
-rw-r--r--src/invidious/hashtag.cr44
-rw-r--r--src/invidious/playlists.cr2
-rw-r--r--src/invidious/routes/api/manifest.cr21
-rw-r--r--src/invidious/routes/api/v1/authenticated.cr2
-rw-r--r--src/invidious/routes/api/v1/channels.cr6
-rw-r--r--src/invidious/routes/api/v1/videos.cr8
-rw-r--r--src/invidious/routes/channels.cr9
-rw-r--r--src/invidious/routes/embed.cr6
-rw-r--r--src/invidious/routes/feeds.cr4
-rw-r--r--src/invidious/routes/playlists.cr14
-rw-r--r--src/invidious/routes/search.cr37
-rw-r--r--src/invidious/routes/video_playback.cr8
-rw-r--r--src/invidious/routes/watch.cr3
-rw-r--r--src/invidious/search/query.cr12
-rw-r--r--src/invidious/videos.cr83
-rw-r--r--src/invidious/views/components/item.ecr33
-rw-r--r--src/invidious/views/components/player.ecr15
-rw-r--r--src/invidious/views/embed.ecr1
-rw-r--r--src/invidious/views/feeds/history.ecr4
-rw-r--r--src/invidious/views/hashtag.ecr39
-rw-r--r--src/invidious/views/licenses.ecr14
-rw-r--r--src/invidious/views/search.ecr10
-rw-r--r--src/invidious/views/template.ecr1
-rw-r--r--src/invidious/views/user/subscription_manager.ecr4
-rw-r--r--src/invidious/views/user/token_manager.ecr4
-rw-r--r--src/invidious/views/watch.ecr42
-rw-r--r--src/invidious/yt_backend/extractors.cr62
-rw-r--r--src/invidious/yt_backend/extractors_utils.cr41
33 files changed, 418 insertions, 173 deletions
diff --git a/src/invidious.cr b/src/invidious.cr
index dd240852..070b4d18 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -133,12 +133,13 @@ Invidious::Database.check_integrity(CONFIG)
# Running the script by itself would show some colorful feedback while this doesn't.
# Perhaps we should just move the script to runtime in order to get that feedback?
- {% puts "\nChecking player dependencies...\n" %}
+ {% puts "\nChecking player dependencies, this may take more than 20 minutes... If it is stuck, check your internet connection.\n" %}
{% if flag?(:minified_player_dependencies) %}
{% puts run("../scripts/fetch-player-dependencies.cr", "--minified").stringify %}
{% else %}
{% puts run("../scripts/fetch-player-dependencies.cr").stringify %}
{% end %}
+ {% puts "\nDone checking player dependencies, now compiling Invidious...\n" %}
{% end %}
# Start jobs
@@ -385,6 +386,7 @@ end
Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch
Invidious::Routing.get "/results", Invidious::Routes::Search, :results
Invidious::Routing.get "/search", Invidious::Routes::Search, :search
+ Invidious::Routing.get "/hashtag/:hashtag", Invidious::Routes::Search, :hashtag
# User routes
define_user_routes()
diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr
index da71e9a8..f60ee7af 100644
--- a/src/invidious/channels/about.cr
+++ b/src/invidious/channels/about.cr
@@ -31,7 +31,12 @@ def get_about_info(ucid, locale) : AboutChannel
end
if initdata.dig?("alerts", 0, "alertRenderer", "type") == "ERROR"
- raise InfoException.new(initdata["alerts"][0]["alertRenderer"]["text"]["simpleText"].as_s)
+ error_message = initdata["alerts"][0]["alertRenderer"]["text"]["simpleText"].as_s
+ if error_message == "This channel does not exist."
+ raise NotFoundException.new(error_message)
+ else
+ raise InfoException.new(error_message)
+ end
end
if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]?
@@ -54,13 +59,11 @@ def get_about_info(ucid, locale) : AboutChannel
banner = banners.try &.[-1]?.try &.["url"].as_s?
description_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]
-
- is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
- allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s)
else
author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s
+ author_verified = has_verified_badge?(initdata.dig?("header", "c4TabbedHeaderRenderer", "badges"))
ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s
@@ -71,18 +74,19 @@ def get_about_info(ucid, locale) : AboutChannel
# if banner.includes? "channels/c4/default_banner"
# banner = nil
# end
- # author_verified_badges = initdata["header"]?.try &.["c4TabbedHeaderRenderer"]?.try &.["badges"]?
- author_verified_badge = initdata["header"].dig?("c4TabbedHeaderRenderer", "badges", 0, "metadataBadgeRenderer", "tooltip")
- author_verified = (author_verified_badge && author_verified_badge == "Verified")
description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?
-
- is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
- allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s)
end
+ is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
+
+ allowed_regions = initdata
+ .dig?("microformat", "microformatDataRenderer", "availableCountries")
+ .try &.as_a.map(&.as_s) || [] of String
+
description = !description_node.nil? ? description_node.as_s : ""
description_html = HTML.escape(description)
+
if !description_node.nil?
if description_node.as_h?.nil?
description_node = text_to_parsed_content(description_node.as_s)
diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr
index 4701ecbd..2a2c74aa 100644
--- a/src/invidious/channels/community.cr
+++ b/src/invidious/channels/community.cr
@@ -6,20 +6,18 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
end
if response.status_code != 200
- raise InfoException.new("This channel does not exist.")
+ raise NotFoundException.new("This channel does not exist.")
end
ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"]
if !continuation || continuation.empty?
initial_data = extract_initial_data(response.body)
- body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]?
+ body = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"])["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]
if !body
raise InfoException.new("Could not extract community tab.")
end
-
- body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]
else
continuation = produce_channel_community_continuation(ucid, continuation)
@@ -49,7 +47,11 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
error_message = (message["text"]["simpleText"]? ||
message["text"]["runs"]?.try &.[0]?.try &.["text"]?)
.try &.as_s || ""
- raise InfoException.new(error_message)
+ if error_message == "This channel does not exist."
+ raise NotFoundException.new(error_message)
+ else
+ raise InfoException.new(error_message)
+ end
end
response = JSON.build do |json|
diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr
index d8496978..5112ad3d 100644
--- a/src/invidious/comments.cr
+++ b/src/invidious/comments.cr
@@ -95,7 +95,7 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b
contents = body["contents"]?
header = body["header"]?
else
- raise InfoException.new("Could not fetch comments")
+ raise NotFoundException.new("Comments not found.")
end
if !contents
@@ -290,7 +290,7 @@ def fetch_reddit_comments(id, sort_by = "confidence")
thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink)
else
- raise InfoException.new("Could not fetch comments")
+ raise NotFoundException.new("Comments not found.")
end
client.close
@@ -481,7 +481,7 @@ def template_reddit_comments(root, locale)
html << <<-END_HTML
<p>
- <a href="javascript:void(0)" data-onclick="toggle_parent">[ - ]</a>
+ <a href="javascript:void(0)" data-onclick="toggle_parent">[ − ]</a>
<b><a href="https://www.reddit.com/user/#{child.author}">#{child.author}</a></b>
#{translate_count(locale, "comments_points_count", child.score, NumberFormatting::Separator)}
<span title="#{child.created_utc.to_s(translate(locale, "%a %B %-d %T %Y UTC"))}">#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}</span>
@@ -500,6 +500,12 @@ def template_reddit_comments(root, locale)
end
def replace_links(html)
+ # Check if the document is empty
+ # Prevents edge-case bug with Reddit comments, see issue #3115
+ if html.nil? || html.empty?
+ return html
+ end
+
html = XML.parse_html(html)
html.xpath_nodes(%q(//a)).each do |anchor|
@@ -541,6 +547,12 @@ def replace_links(html)
end
def fill_links(html, scheme, host)
+ # Check if the document is empty
+ # Prevents edge-case bug with Reddit comments, see issue #3115
+ if html.nil? || html.empty?
+ return html
+ end
+
html = XML.parse_html(html)
html.xpath_nodes("//a").each do |match|
diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr
index bfaa3fd5..471a199a 100644
--- a/src/invidious/exceptions.cr
+++ b/src/invidious/exceptions.cr
@@ -18,3 +18,7 @@ class BrokenTubeException < Exception
return "Missing JSON element \"#{@element}\""
end
end
+
+# Exception threw when an element is not found.
+class NotFoundException < InfoException
+end
diff --git a/src/invidious/hashtag.cr b/src/invidious/hashtag.cr
new file mode 100644
index 00000000..afe31a36
--- /dev/null
+++ b/src/invidious/hashtag.cr
@@ -0,0 +1,44 @@
+module Invidious::Hashtag
+ extend self
+
+ def fetch(hashtag : String, page : Int, region : String? = nil) : Array(SearchItem)
+ cursor = (page - 1) * 60
+ ctoken = generate_continuation(hashtag, cursor)
+
+ client_config = YoutubeAPI::ClientConfig.new(region: region)
+ response = YoutubeAPI.browse(continuation: ctoken, client_config: client_config)
+
+ return extract_items(response)
+ end
+
+ def generate_continuation(hashtag : String, cursor : Int)
+ object = {
+ "80226972:embedded" => {
+ "2:string" => "FEhashtag",
+ "3:base64" => {
+ "1:varint" => cursor.to_i64,
+ },
+ "7:base64" => {
+ "325477796:embedded" => {
+ "1:embedded" => {
+ "2:0:embedded" => {
+ "2:string" => '#' + hashtag,
+ "4:varint" => 0_i64,
+ "11:string" => "",
+ },
+ "4:string" => "browse-feedFEhashtag",
+ },
+ "2:string" => hashtag,
+ },
+ },
+ },
+ }
+
+ continuation = object.try { |i| Protodec::Any.cast_json(i) }
+ .try { |i| Protodec::Any.from_json(i) }
+ .try { |i| Base64.urlsafe_encode(i) }
+ .try { |i| URI.encode_www_form(i) }
+
+ return continuation
+ end
+end
diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr
index aefa34cc..c4eb7507 100644
--- a/src/invidious/playlists.cr
+++ b/src/invidious/playlists.cr
@@ -317,7 +317,7 @@ def get_playlist(plid : String)
if playlist = Invidious::Database::Playlists.select(id: plid)
return playlist
else
- raise InfoException.new("Playlist does not exist.")
+ raise NotFoundException.new("Playlist does not exist.")
end
else
return fetch_playlist(plid)
diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr
index 8bc36946..bfb8a377 100644
--- a/src/invidious/routes/api/manifest.cr
+++ b/src/invidious/routes/api/manifest.cr
@@ -16,6 +16,8 @@ module Invidious::Routes::API::Manifest
video = get_video(id, region: region)
rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id)
+ rescue ex : NotFoundException
+ haltf env, status_code: 404
rescue ex
haltf env, status_code: 403
end
@@ -46,7 +48,7 @@ module Invidious::Routes::API::Manifest
end
end
- audio_streams = video.audio_streams
+ audio_streams = video.audio_streams.sort_by { |stream| {stream["bitrate"].as_i} }.reverse!
video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse!
manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml|
@@ -60,16 +62,22 @@ module Invidious::Routes::API::Manifest
mime_streams = audio_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type }
next if mime_streams.empty?
- xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do
- mime_streams.each do |fmt|
- # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415)
- next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange"))
+ mime_streams.each do |fmt|
+ # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415)
+ next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange"))
+ # Different representations of the same audio should be groupped into one AdaptationSet.
+ # However, most players don't support auto quality switching, so we have to trick them
+ # into providing a quality selector.
+ # See https://github.com/iv-org/invidious/issues/3074 for more details.
+ xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: fmt["bitrate"].to_s + "k") do
codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"')
bandwidth = fmt["bitrate"].as_i
itag = fmt["itag"].as_i
url = fmt["url"].as_s
+ xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: i == 0 ? "main" : "alternate")
+
xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do
xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011",
value: "2")
@@ -79,9 +87,8 @@ module Invidious::Routes::API::Manifest
end
end
end
+ i += 1
end
-
- i += 1
end
potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144}
diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr
index b559a01a..1f5ad8ef 100644
--- a/src/invidious/routes/api/v1/authenticated.cr
+++ b/src/invidious/routes/api/v1/authenticated.cr
@@ -237,6 +237,8 @@ module Invidious::Routes::API::V1::Authenticated
begin
video = get_video(video_id)
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr
index 8650976d..6b81c546 100644
--- a/src/invidious/routes/api/v1/channels.cr
+++ b/src/invidious/routes/api/v1/channels.cr
@@ -13,6 +13,8 @@ module Invidious::Routes::API::V1::Channels
rescue ex : ChannelRedirect
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
@@ -170,6 +172,8 @@ module Invidious::Routes::API::V1::Channels
rescue ex : ChannelRedirect
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
@@ -205,6 +209,8 @@ module Invidious::Routes::API::V1::Channels
rescue ex : ChannelRedirect
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr
index a9f891f5..1b7b4fa7 100644
--- a/src/invidious/routes/api/v1/videos.cr
+++ b/src/invidious/routes/api/v1/videos.cr
@@ -12,6 +12,8 @@ module Invidious::Routes::API::V1::Videos
rescue ex : VideoRedirect
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
@@ -42,6 +44,8 @@ module Invidious::Routes::API::V1::Videos
rescue ex : VideoRedirect
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
+ rescue ex : NotFoundException
+ haltf env, 404
rescue ex
haltf env, 500
end
@@ -167,6 +171,8 @@ module Invidious::Routes::API::V1::Videos
rescue ex : VideoRedirect
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
+ rescue ex : NotFoundException
+ haltf env, 404
rescue ex
haltf env, 500
end
@@ -324,6 +330,8 @@ module Invidious::Routes::API::V1::Videos
begin
comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by)
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr
index cd2e3323..c6e02cbd 100644
--- a/src/invidious/routes/channels.cr
+++ b/src/invidious/routes/channels.cr
@@ -85,6 +85,9 @@ module Invidious::Routes::Channels
rescue ex : InfoException
env.response.status_code = 500
error_message = ex.message
+ rescue ex : NotFoundException
+ env.response.status_code = 404
+ error_message = ex.message
rescue ex
return error_template(500, ex)
end
@@ -118,7 +121,7 @@ module Invidious::Routes::Channels
resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}")
ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"]
rescue ex : InfoException | KeyError
- raise InfoException.new(translate(locale, "This channel does not exist."))
+ return error_template(404, translate(locale, "This channel does not exist."))
end
selected_tab = env.request.path.split("/")[-1]
@@ -141,7 +144,7 @@ module Invidious::Routes::Channels
user = env.params.query["user"]?
if !user
- raise InfoException.new("This channel does not exist.")
+ return error_template(404, "This channel does not exist.")
else
env.redirect "/user/#{user}#{uri_params}"
end
@@ -197,6 +200,8 @@ module Invidious::Routes::Channels
channel = get_about_info(ucid, locale)
rescue ex : ChannelRedirect
return env.redirect env.request.resource.gsub(ucid, ex.channel_id)
+ rescue ex : NotFoundException
+ return error_template(404, ex)
rescue ex
return error_template(500, ex)
end
diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr
index 207970b0..84da9993 100644
--- a/src/invidious/routes/embed.cr
+++ b/src/invidious/routes/embed.cr
@@ -7,6 +7,8 @@ module Invidious::Routes::Embed
playlist = get_playlist(plid)
offset = env.params.query["index"]?.try &.to_i? || 0
videos = get_playlist_videos(playlist, offset: offset)
+ rescue ex : NotFoundException
+ return error_template(404, ex)
rescue ex
return error_template(500, ex)
end
@@ -60,6 +62,8 @@ module Invidious::Routes::Embed
playlist = get_playlist(plid)
offset = env.params.query["index"]?.try &.to_i? || 0
videos = get_playlist_videos(playlist, offset: offset)
+ rescue ex : NotFoundException
+ return error_template(404, ex)
rescue ex
return error_template(500, ex)
end
@@ -119,6 +123,8 @@ module Invidious::Routes::Embed
video = get_video(id, region: params.region)
rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id)
+ rescue ex : NotFoundException
+ return error_template(404, ex)
rescue ex
return error_template(500, ex)
end
diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr
index b5b58399..44a87175 100644
--- a/src/invidious/routes/feeds.cr
+++ b/src/invidious/routes/feeds.cr
@@ -150,6 +150,8 @@ module Invidious::Routes::Feeds
channel = get_about_info(ucid, locale)
rescue ex : ChannelRedirect
return env.redirect env.request.resource.gsub(ucid, ex.channel_id)
+ rescue ex : NotFoundException
+ return error_atom(404, ex)
rescue ex
return error_atom(500, ex)
end
@@ -182,7 +184,7 @@ module Invidious::Routes::Feeds
paid: false,
premium: false,
premiere_timestamp: nil,
- author_verified: false, # ¯\_(ツ)_/¯
+ author_verified: false,
})
end
diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr
index de981d81..fe7e4e1c 100644
--- a/src/invidious/routes/playlists.cr
+++ b/src/invidious/routes/playlists.cr
@@ -66,7 +66,13 @@ module Invidious::Routes::Playlists
user = user.as(User)
playlist_id = env.params.query["list"]
- playlist = get_playlist(playlist_id)
+ begin
+ playlist = get_playlist(playlist_id)
+ rescue ex : NotFoundException
+ return error_template(404, ex)
+ rescue ex
+ return error_template(500, ex)
+ end
subscribe_playlist(user, playlist)
env.redirect "/playlist?list=#{playlist.id}"
@@ -304,6 +310,8 @@ module Invidious::Routes::Playlists
playlist_id = env.params.query["playlist_id"]
playlist = get_playlist(playlist_id).as(InvidiousPlaylist)
raise "Invalid user" if playlist.author != user.email
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
if redirect
return error_template(400, ex)
@@ -334,6 +342,8 @@ module Invidious::Routes::Playlists
begin
video = get_video(video_id)
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
if redirect
return error_template(500, ex)
@@ -394,6 +404,8 @@ module Invidious::Routes::Playlists
begin
playlist = get_playlist(plid)
+ rescue ex : NotFoundException
+ return error_template(404, ex)
rescue ex
return error_template(500, ex)
end
diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr
index e60d0081..2a9705cf 100644
--- a/src/invidious/routes/search.cr
+++ b/src/invidious/routes/search.cr
@@ -59,8 +59,45 @@ module Invidious::Routes::Search
return error_template(500, ex)
end
+ params = query.to_http_params
+ url_prev_page = "/search?#{params}&page=#{query.page - 1}"
+ url_next_page = "/search?#{params}&page=#{query.page + 1}"
+
+ redirect_url = Invidious::Frontend::Misc.redirect_url(env)
+
env.set "search", query.text
templated "search"
end
end
+
+ def self.hashtag(env : HTTP::Server::Context)
+ locale = env.get("preferences").as(Preferences).locale
+
+ hashtag = env.params.url["hashtag"]?
+ if hashtag.nil? || hashtag.empty?
+ return error_template(400, "Invalid request")
+ end
+
+ page = env.params.query["page"]?
+ if page.nil?
+ page = 1
+ else
+ page = Math.max(1, page.to_i)
+ env.params.query.delete_all("page")
+ end
+
+ begin
+ videos = Invidious::Hashtag.fetch(hashtag, page)
+ rescue ex
+ return error_template(500, ex)
+ end
+
+ params = env.params.query.empty? ? "" : "&#{env.params.query}"
+
+ hashtag_encoded = URI.encode_www_form(hashtag, space_to_plus: false)
+ url_prev_page = "/hashtag/#{hashtag_encoded}?page=#{page - 1}#{params}"
+ url_next_page = "/hashtag/#{hashtag_encoded}?page=#{page + 1}#{params}"
+
+ templated "hashtag"
+ end
end
diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr
index 3a92ef96..560f9c19 100644
--- a/src/invidious/routes/video_playback.cr
+++ b/src/invidious/routes/video_playback.cr
@@ -265,7 +265,13 @@ module Invidious::Routes::VideoPlayback
return error_template(403, "Administrator has disabled this endpoint.")
end
- video = get_video(id, region: region)
+ begin
+ video = get_video(id, region: region)
+ rescue ex : NotFoundException
+ return error_template(404, ex)
+ rescue ex
+ return error_template(500, ex)
+ end
fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag }
url = fmt.try &.["url"]?.try &.as_s
diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr
index 7280de4f..fe1d8e54 100644
--- a/src/invidious/routes/watch.cr
+++ b/src/invidious/routes/watch.cr
@@ -63,6 +63,9 @@ module Invidious::Routes::Watch
video = get_video(id, region: params.region)
rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id)
+ rescue ex : NotFoundException
+ LOGGER.error("get_video not found: #{id} : #{ex.message}")
+ return error_template(404, ex)
rescue ex
LOGGER.error("get_video: #{id} : #{ex.message}")
return error_template(500, ex)
diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr
index 34b36b1d..24e79609 100644
--- a/src/invidious/search/query.cr
+++ b/src/invidious/search/query.cr
@@ -57,7 +57,7 @@ module Invidious::Search
# Get the page number (also common to all search types)
@page = params["page"]?.try &.to_i? || 1
- # Stop here is raw query in empty
+ # Stop here if raw query is empty
# NOTE: maybe raise in the future?
return if self.empty_raw_query?
@@ -127,6 +127,16 @@ module Invidious::Search
return items
end
+ # Return the HTTP::Params corresponding to this Query (invidious format)
+ def to_http_params : HTTP::Params
+ params = @filters.to_iv_params
+
+ params["q"] = @query
+ params["channel"] = @channel if !@channel.empty?
+
+ return params
+ end
+
# TODO: clean code
private def unnest_items(all_items) : Array(SearchItem)
items = [] of SearchItem
diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr
index f65b05bb..19ee064c 100644
--- a/src/invidious/videos.cr
+++ b/src/invidious/videos.cr
@@ -323,7 +323,7 @@ struct Video
json.field "viewCount", self.views
json.field "likeCount", self.likes
- json.field "dislikeCount", self.dislikes
+ json.field "dislikeCount", 0_i64
json.field "paid", self.paid
json.field "premium", self.premium
@@ -354,7 +354,7 @@ struct Video
json.field "lengthSeconds", self.length_seconds
json.field "allowRatings", self.allow_ratings
- json.field "rating", self.average_rating
+ json.field "rating", 0_i64
json.field "isListed", self.is_listed
json.field "liveNow", self.live_now
json.field "isUpcoming", self.is_upcoming
@@ -556,11 +556,6 @@ struct Video
info["dislikes"]?.try &.as_i64 || 0_i64
end
- def average_rating : Float64
- # (likes / (likes + dislikes) * 4 + 1)
- info["videoDetails"]["averageRating"]?.try { |t| t.as_f? || t.as_i64?.try &.to_f64 }.try &.round(4) || 0.0
- end
-
def published : Time
info
.dig?("microformat", "playerMicroformatRenderer", "publishDate")
@@ -813,14 +808,6 @@ struct Video
return info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s
end
- def wilson_score : Float64
- ci_lower_bound(likes, likes + dislikes).round(4)
- end
-
- def engagement : Float64
- (((likes + dislikes) / views) * 100).round(4)
- end
-
def reason : String?
info["reason"]?.try &.as_s
end
@@ -853,6 +840,7 @@ end
# the same 11 first entries as the compact rendered.
#
# TODO: "compactRadioRenderer" (Mix) and
+# TODO: Use a proper struct/class instead of a hacky JSON object
def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
return nil if !related["videoId"]?
@@ -868,11 +856,7 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
.try &.dig?("runs", 0)
author = channel_info.try &.dig?("text")
- author_verified_badge = related["ownerBadges"]?.try do |badges_array|
- badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified")
- end
-
- author_verified = (author_verified_badge && author_verified_badge.size > 0).to_s
+ author_verified = has_verified_badge?(related["ownerBadges"]?).to_s
ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) }
@@ -911,13 +895,20 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
- if player_response.dig?("playabilityStatus", "status").try &.as_s != "OK"
+ playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
+
+ if playability_status != "OK"
subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason")
reason = subreason.try &.[]?("simpleText").try &.as_s
reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("")
reason ||= player_response.dig("playabilityStatus", "reason").as_s
+
params["reason"] = JSON::Any.new(reason)
- return params
+
+ # Stop here if video is not a scheduled livestream
+ if playability_status != "LIVE_STREAM_OFFLINE"
+ return params
+ end
end
params["shortDescription"] = player_response.dig?("videoDetails", "shortDescription") || JSON::Any.new(nil)
@@ -1008,7 +999,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
params["relatedVideos"] = JSON::Any.new(related)
- # Likes/dislikes
+ # Likes
toplevel_buttons = video_primary_renderer
.try &.dig?("videoActions", "menuRenderer", "topLevelButtons")
@@ -1026,30 +1017,10 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"")
LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes
end
-
- dislikes_button = toplevel_buttons.as_a
- .find(&.dig("toggleButtonRenderer", "defaultIcon", "iconType").as_s.== "DISLIKE")
- .try &.["toggleButtonRenderer"]
-
- if dislikes_button
- dislikes_txt = (dislikes_button["defaultText"]? || dislikes_button["toggledText"]?)
- .try &.dig?("accessibility", "accessibilityData", "label")
- dislikes = dislikes_txt.as_s.gsub(/\D/, "").to_i64? if dislikes_txt
-
- LOGGER.trace("extract_video_info: Found \"dislikes\" button. Button text is \"#{dislikes_txt}\"")
- LOGGER.debug("extract_video_info: Dislikes count is #{dislikes}") if dislikes
- end
- end
-
- if likes && likes != 0_i64 && (!dislikes || dislikes == 0_i64)
- if rating = player_response.dig?("videoDetails", "averageRating").try { |x| x.as_i64? || x.as_f? }
- dislikes = (likes * ((5 - rating)/(rating - 1))).round.to_i64
- LOGGER.debug("extract_video_info: Dislikes count (using fallback method) is #{dislikes}")
- end
end
params["likes"] = JSON::Any.new(likes || 0_i64)
- params["dislikes"] = JSON::Any.new(dislikes || 0_i64)
+ params["dislikes"] = JSON::Any.new(0_i64)
# Description
@@ -1089,17 +1060,19 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
# Author infos
- author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer")
- author_thumbnail = author_info.try &.dig?("thumbnail", "thumbnails", 0, "url")
+ if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer")
+ author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url")
+ params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "")
- author_verified_badge = author_info.try &.dig?("badges", 0, "metadataBadgeRenderer", "tooltip")
- author_verified = (!author_verified_badge.nil? && author_verified_badge == "Verified")
- params["authorVerified"] = JSON::Any.new(author_verified)
+ author_verified = has_verified_badge?(author_info["badges"]?)
+ params["authorVerified"] = JSON::Any.new(author_verified)
- params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "")
+ subs_text = author_info["subscriberCountText"]?
+ .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }
+ .try &.as_s.split(" ", 2)[0]
- params["subCountText"] = JSON::Any.new(author_info.try &.["subscriberCountText"]?
- .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }.try &.as_s.split(" ", 2)[0] || "-")
+ params["subCountText"] = JSON::Any.new(subs_text || "-")
+ end
# Return data
@@ -1159,7 +1132,11 @@ def fetch_video(id, region)
end
if reason = info["reason"]?
- raise InfoException.new(reason.as_s || "")
+ if reason == "Video unavailable"
+ raise NotFoundException.new(reason.as_s || "")
+ else
+ raise InfoException.new(reason.as_s || "")
+ end
end
video = Video.new({
diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr
index fb7ad1dc..0e959ff2 100644
--- a/src/invidious/views/components/item.ecr
+++ b/src/invidious/views/components/item.ecr
@@ -5,7 +5,7 @@
<a href="/channel/<%= item.ucid %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<center>
- <img loading="lazy" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/>
+ <img loading="lazy" tabindex="-1" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/>
</center>
<% end %>
<p dir="auto"><%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></p>
@@ -23,7 +23,7 @@
<a style="width:100%" href="<%= url %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img loading="lazy" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>"/>
+ <img loading="lazy" tabindex="-1" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>"/>
<p class="length"><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
</div>
<% end %>
@@ -36,7 +36,7 @@
<a href="/watch?v=<%= item.id %>&list=<%= item.rdid %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
+ <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% if item.length_seconds != 0 %>
<p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
<% end %>
@@ -51,16 +51,13 @@
<a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&index=<%= item.index %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
+ <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
+
<% if plid_form = env.get?("remove_playlist_items") %>
<form data-onsubmit="return_false" action="/playlist_ajax?action_remove_video=1&set_video_id=<%= item.index %>&playlist_id=<%= plid_form %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<p class="watched">
- <a data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid_form %>" href="javascript:void(0)">
- <button type="submit" style="all:unset">
- <i class="icon ion-md-trash"></i>
- </button>
- </a>
+ <button type="submit" style="all:unset" data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid_form %>"><i class="icon ion-md-trash"></i></button>
</p>
</form>
<% end %>
@@ -103,29 +100,21 @@
<a style="width:100%" href="/watch?v=<%= item.id %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
+ <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% if env.get? "show_watched" %>
<form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<p class="watched">
- <a data-onclick="mark_watched" data-id="<%= item.id %>" href="javascript:void(0)">
- <button type="submit" style="all:unset">
- <i data-mouse="switch_classes" data-switch-classes="ion-ios-eye-off,ion-ios-eye"
- class="icon ion-ios-eye">
- </i>
- </button>
- </a>
+ <button type="submit" style="all:unset" data-onclick="mark_watched" data-id="<%= item.id %>">
+ <i data-mouse="switch_classes" data-switch-classes="ion-ios-eye-off,ion-ios-eye" class="icon ion-ios-eye"></i>
+ </button>
</p>
</form>
<% elsif plid_form = env.get? "add_playlist_items" %>
<form data-onsubmit="return_false" action="/playlist_ajax?action_add_video=1&video_id=<%= item.id %>&playlist_id=<%= plid_form %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<p class="watched">
- <a data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>" href="javascript:void(0)">
- <button type="submit" style="all:unset">
- <i class="icon ion-md-add"></i>
- </button>
- </a>
+ <button type="submit" style="all:unset" data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>"><i class="icon ion-md-add"></i></button>
</p>
</form>
<% end %>
diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr
index fffefc9a..c3c02df0 100644
--- a/src/invidious/views/components/player.ecr
+++ b/src/invidious/views/components/player.ecr
@@ -7,14 +7,25 @@
<source src="<%= URI.parse(hlsvp).request_target %><% if params.local %>?local=true<% end %>" type="application/x-mpegURL" label="livestream">
<% else %>
<% if params.listen %>
- <% audio_streams.each_with_index do |fmt, i|
+ <% # default to 128k m4a stream
+ best_m4a_stream_index = 0
+ best_m4a_stream_bitrate = 0
+ audio_streams.each_with_index do |fmt, i|
+ bandwidth = fmt["bitrate"].as_i
+ if (fmt["mimeType"].as_s.starts_with?("audio/mp4") && bandwidth > best_m4a_stream_bitrate)
+ best_m4a_stream_bitrate = bandwidth
+ best_m4a_stream_index = i
+ end
+ end
+
+ audio_streams.each_with_index do |fmt, i|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
src_url += "&local=true" if params.local
bitrate = fmt["bitrate"]
mimetype = HTML.escape(fmt["mimeType"].as_s)
- selected = i == 0 ? true : false
+ selected = (i == best_m4a_stream_index)
%>
<source src="<%= src_url %>" type='<%= mimetype %>' label="<%= bitrate %>k" selected="<%= selected %>">
<% if !params.local && !CONFIG.disabled?("local") %>
diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr
index ce5ff7f0..1bf5cc3e 100644
--- a/src/invidious/views/embed.ecr
+++ b/src/invidious/views/embed.ecr
@@ -11,6 +11,7 @@
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/embed.css?v=<%= ASSET_COMMIT %>">
<title><%= HTML.escape(video.title) %> - Invidious</title>
+ <script src="/js/_helpers.js?v=<%= ASSET_COMMIT %>"></script>
</head>
<body class="dark-theme">
diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr
index 6c1243c5..471d21db 100644
--- a/src/invidious/views/feeds/history.ecr
+++ b/src/invidious/views/feeds/history.ecr
@@ -38,9 +38,7 @@
<form data-onsubmit="return_false" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
<p class="watched">
- <a data-onclick="mark_unwatched" data-id="<%= item %>" href="javascript:void(0)">
- <button type="submit" style="all:unset"><i class="icon ion-md-trash"></i></button>
- </a>
+ <button type="submit" style="all:unset" data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button>
</p>
</form>
</div>
diff --git a/src/invidious/views/hashtag.ecr b/src/invidious/views/hashtag.ecr
new file mode 100644
index 00000000..0ecfe832
--- /dev/null
+++ b/src/invidious/views/hashtag.ecr
@@ -0,0 +1,39 @@
+<% content_for "header" do %>
+<title><%= HTML.escape(hashtag) %> - Invidious</title>
+<% end %>
+
+<hr/>
+
+<div class="pure-g h-box v-box">
+ <div class="pure-u-1 pure-u-lg-1-5">
+ <%- if page > 1 -%>
+ <a href="<%= url_prev_page %>"><%= translate(locale, "Previous page") %></a>
+ <%- end -%>
+ </div>
+ <div class="pure-u-1 pure-u-lg-3-5"></div>
+ <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
+ <%- if videos.size >= 60 -%>
+ <a href="<%= url_next_page %>"><%= translate(locale, "Next page") %></a>
+ <%- end -%>
+ </div>
+</div>
+
+<div class="pure-g">
+ <%- videos.each do |item| -%>
+ <%= rendered "components/item" %>
+ <%- end -%>
+</div>
+
+<div class="pure-g h-box">
+ <div class="pure-u-1 pure-u-lg-1-5">
+ <%- if page > 1 -%>
+ <a href="<%= url_prev_page %>"><%= translate(locale, "Previous page") %></a>
+ <%- end -%>
+ </div>
+ <div class="pure-u-1 pure-u-lg-3-5"></div>
+ <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
+ <%- if videos.size >= 60 -%>
+ <a href="<%= url_next_page %>"><%= translate(locale, "Next page") %></a>
+ <%- end -%>
+ </div>
+</div>
diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr
index 861913d0..25b24ed4 100644
--- a/src/invidious/views/licenses.ecr
+++ b/src/invidious/views/licenses.ecr
@@ -11,6 +11,20 @@
<table id="jslicense-labels1">
<tr>
<td>
+ <a href="/js/_helpers.js?v=<%= ASSET_COMMIT %>">_helpers.js</a>
+ </td>
+
+ <td>
+ <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
+ </td>
+
+ <td>
+ <a href="/js/_helpers.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
+ </td>
+ </tr>
+
+ <tr>
+ <td>
<a href="/js/community.js?v=<%= ASSET_COMMIT %>">community.js</a>
</td>
diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr
index 7110703e..254449a1 100644
--- a/src/invidious/views/search.ecr
+++ b/src/invidious/views/search.ecr
@@ -3,16 +3,6 @@
<link rel="stylesheet" href="/css/search.css?v=<%= ASSET_COMMIT %>">
<% end %>
-<%-
- search_query_encoded = URI.encode_www_form(query.text, space_to_plus: true)
- filter_params = query.filters.to_iv_params
-
- url_prev_page = "/search?q=#{search_query_encoded}&#{filter_params}&page=#{query.page - 1}"
- url_next_page = "/search?q=#{search_query_encoded}&#{filter_params}&page=#{query.page + 1}"
-
- redirect_url = Invidious::Frontend::Misc.redirect_url(env)
--%>
-
<!-- Search redirection and filtering UI -->
<%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
<hr/>
diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr
index bd908dd6..4e2b29f0 100644
--- a/src/invidious/views/template.ecr
+++ b/src/invidious/views/template.ecr
@@ -17,6 +17,7 @@
<link rel="stylesheet" href="/css/grids-responsive-min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/ionicons.min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
+ <script src="/js/_helpers.js?v=<%= ASSET_COMMIT %>"></script>
</head>
<%
diff --git a/src/invidious/views/user/subscription_manager.ecr b/src/invidious/views/user/subscription_manager.ecr
index c2a89ca2..c9801f09 100644
--- a/src/invidious/views/user/subscription_manager.ecr
+++ b/src/invidious/views/user/subscription_manager.ecr
@@ -39,9 +39,7 @@
<h3 style="padding-right:0.5em">
<form data-onsubmit="return_false" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
- <a data-onclick="remove_subscription" data-ucid="<%= channel.id %>" href="#">
- <input style="all:unset" type="submit" value="<%= translate(locale, "unsubscribe") %>">
- </a>
+ <input style="all:unset" type="submit" data-onclick="remove_subscription" data-ucid="<%= channel.id %>" value="<%= translate(locale, "unsubscribe") %>">
</form>
</h3>
</div>
diff --git a/src/invidious/views/user/token_manager.ecr b/src/invidious/views/user/token_manager.ecr
index 79f905a1..a73fa048 100644
--- a/src/invidious/views/user/token_manager.ecr
+++ b/src/invidious/views/user/token_manager.ecr
@@ -31,9 +31,7 @@
<h3 style="padding-right:0.5em">
<form data-onsubmit="return_false" action="/token_ajax?action_revoke_token=1&session=<%= token[:session] %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
- <a data-onclick="revoke_token" data-session="<%= token[:session] %>" href="#">
- <input style="all:unset" type="submit" value="<%= translate(locale, "revoke") %>">
- </a>
+ <input style="all:unset" type="submit" data-onclick="revoke_token" data-session="<%= token[:session] %>" value="<%= translate(locale, "revoke") %>">
</form>
</h3>
</div>
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr
index 783eff1d..50c63d21 100644
--- a/src/invidious/views/watch.ecr
+++ b/src/invidious/views/watch.ecr
@@ -173,7 +173,7 @@ we're going to need to do it here in order to allow for translations.
<p id="views"><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p>
<p id="likes"><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p>
- <p id="dislikes"></p>
+ <p id="dislikes" style="display: none; visibility: hidden;"></p>
<p id="genre"><%= translate(locale, "Genre: ") %>
<% if !video.genre_url %>
<%= video.genre %>
@@ -185,9 +185,9 @@ we're going to need to do it here in order to allow for translations.
<p id="license"><%= translate(locale, "License: ") %><%= video.license %></p>
<% end %>
<p id="family_friendly"><%= translate(locale, "Family friendly? ") %><%= translate_bool(locale, video.is_family_friendly) %></p>
- <p id="wilson"><%= translate(locale, "Wilson score: ") %><%= video.wilson_score %></p>
- <p id="rating"></p>
- <p id="engagement"><%= translate(locale, "Engagement: ") %><%= video.engagement %>%</p>
+ <p id="wilson" style="display: none; visibility: hidden;"></p>
+ <p id="rating" style="display: none; visibility: hidden;"></p>
+ <p id="engagement" style="display: none; visibility: hidden;"></p>
<% if video.allowed_regions.size != REGIONS.size %>
<p id="allowed_regions">
<% if video.allowed_regions.size < REGIONS.size // 2 %>
@@ -278,24 +278,24 @@ we're going to need to do it here in order to allow for translations.
</div>
<% end %>
<p style="width:100%"><%= rv["title"] %></p>
- <h5 class="pure-g">
- <div class="pure-u-14-24">
- <% if rv["ucid"]? %>
- <b style="width:100%"><a href="/channel/<%= rv["ucid"] %>"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b>
- <% else %>
- <b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></b>
- <% end %>
- </div>
-
- <div class="pure-u-10-24" style="text-align:right">
- <b class="width:100%"><%=
- views = rv["view_count"]?.try &.to_i?
- views ||= rv["view_count_short"]?.try { |x| short_text_to_number(x) }
- translate_count(locale, "generic_views_count", views || 0, NumberFormatting::Short)
- %></b>
- </div>
- </h5>
</a>
+ <h5 class="pure-g">
+ <div class="pure-u-14-24">
+ <% if rv["ucid"]? %>
+ <b style="width:100%"><a href="/channel/<%= rv["ucid"] %>"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b>
+ <% else %>
+ <b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></b>
+ <% end %>
+ </div>
+
+ <div class="pure-u-10-24" style="text-align:right">
+ <b class="width:100%"><%=
+ views = rv["view_count"]?.try &.to_i?
+ views ||= rv["view_count_short"]?.try { |x| short_text_to_number(x) }
+ translate_count(locale, "generic_views_count", views || 0, NumberFormatting::Short)
+ %></b>
+ </div>
+ </h5>
<% end %>
<% end %>
</div>
diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr
index a2ec7d59..b9609eb9 100644
--- a/src/invidious/yt_backend/extractors.cr
+++ b/src/invidious/yt_backend/extractors.cr
@@ -1,3 +1,5 @@
+require "../helpers/serialized_yt_data"
+
# This file contains helper methods to parse the Youtube API json data into
# neat little packages we can use
@@ -14,6 +16,7 @@ private ITEM_PARSERS = {
Parsers::GridPlaylistRendererParser,
Parsers::PlaylistRendererParser,
Parsers::CategoryRendererParser,
+ Parsers::RichItemRendererParser,
}
record AuthorFallback, name : String, id : String
@@ -57,6 +60,8 @@ private module Parsers
author_id = author_fallback.id
end
+ author_verified = has_verified_badge?(item_contents["ownerBadges"]?)
+
# For live videos (and possibly recently premiered videos) there is no published information.
# Instead, in its place is the amount of people currently watching. This behavior should be replicated
# on Invidious once all features of livestreams are supported. On an unrelated note, defaulting to the current
@@ -102,11 +107,7 @@ private module Parsers
premium = false
premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) }
- author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array|
- badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified")
- end
- author_verified = (author_verified_badge && author_verified_badge.size > 0)
item_contents["badges"]?.try &.as_a.each do |badge|
b = badge["metadataBadgeRenderer"]
case b["label"].as_s
@@ -133,7 +134,7 @@ private module Parsers
live_now: live_now,
premium: premium,
premiere_timestamp: premiere_timestamp,
- author_verified: author_verified || false,
+ author_verified: author_verified,
})
end
@@ -161,12 +162,9 @@ private module Parsers
private def self.parse(item_contents, author_fallback)
author = extract_text(item_contents["title"]) || author_fallback.name
author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id
- author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array|
- badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified")
- end
-
- author_verified = (author_verified_badge && author_verified_badge.size > 0)
+ author_verified = has_verified_badge?(item_contents["ownerBadges"]?)
author_thumbnail = HelperExtractors.get_thumbnails(item_contents)
+
# When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube.
# Always simpleText
# TODO change default value to nil
@@ -188,7 +186,7 @@ private module Parsers
video_count: video_count,
description_html: description_html,
auto_generated: auto_generated,
- author_verified: author_verified || false,
+ author_verified: author_verified,
})
end
@@ -216,11 +214,9 @@ private module Parsers
private def self.parse(item_contents, author_fallback)
title = extract_text(item_contents["title"]) || ""
plid = item_contents["playlistId"]?.try &.as_s || ""
- author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array|
- badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified")
- end
- author_verified = (author_verified_badge && author_verified_badge.size > 0)
+ author_verified = has_verified_badge?(item_contents["ownerBadges"]?)
+
video_count = HelperExtractors.get_video_count(item_contents)
playlist_thumbnail = HelperExtractors.get_thumbnails(item_contents)
@@ -232,7 +228,7 @@ private module Parsers
video_count: video_count,
videos: [] of SearchPlaylistVideo,
thumbnail: playlist_thumbnail,
- author_verified: author_verified || false,
+ author_verified: author_verified,
})
end
@@ -266,11 +262,8 @@ private module Parsers
author_info = item_contents.dig?("shortBylineText", "runs", 0)
author = author_info.try &.["text"].as_s || author_fallback.name
author_id = author_info.try { |x| HelperExtractors.get_browse_id(x) } || author_fallback.id
- author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array|
- badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified")
- end
+ author_verified = has_verified_badge?(item_contents["ownerBadges"]?)
- author_verified = (author_verified_badge && author_verified_badge.size > 0)
videos = item_contents["videos"]?.try &.as_a.map do |v|
v = v["childVideoRenderer"]
v_title = v.dig?("title", "simpleText").try &.as_s || ""
@@ -293,7 +286,7 @@ private module Parsers
video_count: video_count,
videos: videos,
thumbnail: playlist_thumbnail,
- author_verified: author_verified || false,
+ author_verified: author_verified,
})
end
@@ -374,6 +367,29 @@ private module Parsers
return {{@type.name}}
end
end
+
+ # Parses an InnerTube richItemRenderer into a SearchVideo.
+ # Returns nil when the given object isn't a shelfRenderer
+ #
+ # A richItemRenderer seems to be a simple wrapper for a videoRenderer, used
+ # by the result page for hashtags. It is located inside a continuationItems
+ # container.
+ #
+ module RichItemRendererParser
+ def self.process(item : JSON::Any, author_fallback : AuthorFallback)
+ if item_contents = item.dig?("richItemRenderer", "content")
+ return self.parse(item_contents, author_fallback)
+ end
+ end
+
+ private def self.parse(item_contents, author_fallback)
+ return VideoRendererParser.process(item_contents, author_fallback)
+ end
+
+ def self.parser_name
+ return {{@type.name}}
+ end
+ end
end
# The following are the extractors for extracting an array of items from
@@ -401,7 +417,7 @@ private module Extractors
# {"tabRenderer": {
# "endpoint": {...}
# "title": "Playlists",
- # "selected": true,
+ # "selected": true, # Is nil unless tab is selected
# "content": {...},
# ...
# }}
@@ -501,6 +517,8 @@ private module Extractors
self.extract(target)
elsif target = initial_data["appendContinuationItemsAction"]?
self.extract(target)
+ elsif target = initial_data["reloadContinuationItemsCommand"]?
+ self.extract(target)
end
end
diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr
index add5f488..f8245160 100644
--- a/src/invidious/yt_backend/extractors_utils.cr
+++ b/src/invidious/yt_backend/extractors_utils.cr
@@ -29,6 +29,45 @@ def extract_text(item : JSON::Any?) : String?
end
end
+# Check if an "ownerBadges" or a "badges" element contains a verified badge.
+# There is currently two known types of verified badges:
+#
+# "ownerBadges": [{
+# "metadataBadgeRenderer": {
+# "icon": { "iconType": "CHECK_CIRCLE_THICK" },
+# "style": "BADGE_STYLE_TYPE_VERIFIED",
+# "tooltip": "Verified",
+# "accessibilityData": { "label": "Verified" }
+# }
+# }],
+#
+# "ownerBadges": [{
+# "metadataBadgeRenderer": {
+# "icon": { "iconType": "OFFICIAL_ARTIST_BADGE" },
+# "style": "BADGE_STYLE_TYPE_VERIFIED_ARTIST",
+# "tooltip": "Official Artist Channel",
+# "accessibilityData": { "label": "Official Artist Channel" }
+# }
+# }],
+#
+def has_verified_badge?(badges : JSON::Any?)
+ return false if badges.nil?
+
+ badges.as_a.each do |badge|
+ style = badge.dig("metadataBadgeRenderer", "style").as_s
+
+ return true if style == "BADGE_STYLE_TYPE_VERIFIED"
+ return true if style == "BADGE_STYLE_TYPE_VERIFIED_ARTIST"
+ end
+
+ return false
+rescue ex
+ LOGGER.debug("Unable to parse owner badges. Got exception: #{ex.message}")
+ LOGGER.trace("Owner badges data: #{badges.to_json}")
+
+ return false
+end
+
def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil)
extracted = extract_items(initial_data, author_fallback, author_id_fallback)
@@ -45,7 +84,7 @@ end
def extract_selected_tab(tabs)
# Extract the selected tab from the array of tabs Youtube returns
- return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]["tabRenderer"]
+ return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"]
end
def fetch_continuation_token(items : Array(JSON::Any))