diff options
Diffstat (limited to 'src')
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 %> <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" %> <i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b> - <% else %> - <b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <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" %> <i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b> + <% else %> + <b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <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)) |
