diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/invidious/config.cr | 4 | ||||
| -rw-r--r-- | src/invidious/exceptions.cr | 8 | ||||
| -rw-r--r-- | src/invidious/helpers/i18n.cr | 2 | ||||
| -rw-r--r-- | src/invidious/helpers/tokens.cr | 2 | ||||
| -rw-r--r-- | src/invidious/helpers/utils.cr | 12 | ||||
| -rw-r--r-- | src/invidious/playlists.cr | 2 | ||||
| -rw-r--r-- | src/invidious/routes/preferences.cr | 2 | ||||
| -rw-r--r-- | src/invidious/videos.cr | 134 | ||||
| -rw-r--r-- | src/invidious/views/watch.ecr | 10 | ||||
| -rw-r--r-- | src/invidious/yt_backend/extractors.cr | 18 | ||||
| -rw-r--r-- | src/invidious/yt_backend/youtube_api.cr | 14 |
11 files changed, 144 insertions, 64 deletions
diff --git a/src/invidious/config.cr b/src/invidious/config.cr index c4a8bf83..72e145da 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -91,8 +91,8 @@ class Config @[YAML::Field(converter: Preferences::FamilyConverter)] property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) - property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument) - property host_binding : String = "0.0.0.0" # Host to bind (overrided by command line argument) + property port : Int32 = 3000 # Port to listen for connections (overridden by command line argument) + property host_binding : String = "0.0.0.0" # Host to bind (overridden by command line argument) property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) property use_quic : Bool = false # Use quic transport for youtube api diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr new file mode 100644 index 00000000..391a574d --- /dev/null +++ b/src/invidious/exceptions.cr @@ -0,0 +1,8 @@ +# Exception used to hold the name of the missing item +# Should be used in all parsing functions +class BrokenTubeException < InfoException + getter element : String + + def initialize(@element) + end +end diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index 3cf9ad1c..6571dbe6 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -135,7 +135,7 @@ def translate_count(locale : String, key : String, count : Int, format = NumberF # Try #2: Fallback to english translation = translate_count("en-US", key, count) else - # Return key if we're already in english, as the tranlation is missing + # Return key if we're already in english, as the translation is missing LOGGER.warn("i18n: Missing translation key \"#{key}\"") return key end diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr index 9b664646..a44988cd 100644 --- a/src/invidious/helpers/tokens.cr +++ b/src/invidious/helpers/tokens.cr @@ -44,7 +44,7 @@ def sign_token(key, hash) # TODO: figure out which "key" variable is used # Ameba reports a warning for "Lint/ShadowingOuterLocalVar" on this - # variable, but its preferrable to not touch that (works fine atm). + # variable, but it's preferable to not touch that (works fine atm). hash.each do |key, value| next if key == "signature" diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 3ab9a0fc..a58a21b1 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -161,11 +161,11 @@ def short_text_to_number(short_text : String) : Int32 end def number_to_short_text(number) - seperated = number_with_separator(number).gsub(",", ".").split("") - text = seperated.first(2).join + separated = number_with_separator(number).gsub(",", ".").split("") + text = separated.first(2).join - if seperated[2]? && seperated[2] != "." - text += seperated[2] + if separated[2]? && separated[2] != "." + text += separated[2] end text = text.rchop(".0") @@ -323,8 +323,8 @@ def fetch_random_instance instance_list.each do |data| # TODO Check if current URL is onion instance and use .onion types if so. if data[1]["type"] == "https" - # Instances can have statisitics disabled, which is an requirement of version validation. - # as_nil? doesn't exist. Thus we'll have to handle the error rasied if as_nil fails. + # Instances can have statistics disabled, which is an requirement of version validation. + # as_nil? doesn't exist. Thus we'll have to handle the error raised if as_nil fails. begin data[1]["stats"].as_nil next diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 88888a65..aefa34cc 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -401,7 +401,7 @@ def fetch_playlist(plid : String) end def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32, video_id = nil) - # Show empy playlist if requested page is out of range + # Show empty playlist if requested page is out of range # (e.g, when a new playlist has been created, offset will be negative) if offset >= playlist.video_count || offset < 0 return [] of PlaylistVideo diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 9c740cf2..930c588b 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -136,7 +136,7 @@ module Invidious::Routes::PreferencesRoute notifications_only ||= "off" notifications_only = notifications_only == "on" - # Convert to JSON and back again to take advantage of converters used for compatability + # Convert to JSON and back again to take advantage of converters used for compatibility preferences = Preferences.from_json({ annotations: annotations, annotations_subscribed: annotations_subscribed, diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index d77d56d2..446e8e03 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -446,7 +446,7 @@ struct Video end json.field "author", rv["author"] - json.field "authorUrl", rv["author_url"]? + json.field "authorUrl", "/channel/#{rv["ucid"]?}" json.field "authorId", rv["ucid"]? if rv["author_thumbnail"]? json.field "authorThumbnails" do @@ -455,7 +455,7 @@ struct Video qualities.each do |quality| json.object do - json.field "url", rv["author_thumbnail"]?.try &.gsub(/s\d+-/, "s#{quality}-") + json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-") json.field "width", quality json.field "height", quality end @@ -465,7 +465,7 @@ struct Video end json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i - json.field "viewCountText", rv["short_view_count_text"]? + json.field "viewCountText", rv["short_view_count"]? json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64 end end @@ -802,23 +802,50 @@ class VideoRedirect < Exception end end -def parse_related(r : JSON::Any) : JSON::Any? - # TODO: r["endScreenPlaylistRenderer"], etc. - return if !r["endScreenVideoRenderer"]? - r = r["endScreenVideoRenderer"].as_h - - return if !r["lengthInSeconds"]? - - rv = {} of String => JSON::Any - rv["author"] = r["shortBylineText"]["runs"][0]?.try &.["text"] || JSON::Any.new("") - rv["ucid"] = r["shortBylineText"]["runs"][0]?.try &.["navigationEndpoint"]["browseEndpoint"]["browseId"] || JSON::Any.new("") - rv["author_url"] = JSON::Any.new("/channel/#{rv["ucid"]}") - rv["length_seconds"] = JSON::Any.new(r["lengthInSeconds"].as_i.to_s) - rv["title"] = r["title"]["simpleText"] - rv["short_view_count_text"] = JSON::Any.new(r["shortViewCountText"]?.try &.["simpleText"]?.try &.as_s || "") - rv["view_count"] = JSON::Any.new(r["title"]["accessibility"]?.try &.["accessibilityData"]["label"].as_s.match(/(?<views>[1-9](\d+,?)*) views/).try &.["views"].gsub(/\D/, "") || "") - rv["id"] = r["videoId"] - JSON::Any.new(rv) +# Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer". +# The former is preferred as it has more videos in it. The second has +# the same 11 first entries as the compact rendered. +# +# TODO: "compactRadioRenderer" (Mix) and +def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? + return nil if !related["videoId"]? + + # The compact renderer has video length in seconds, where the end + # screen rendered has a full text version ("42:40") + length = related["lengthInSeconds"]?.try &.as_i.to_s + length ||= related.dig?("lengthText", "simpleText").try do |box| + decode_length_seconds(box.as_s).to_s + end + + # Both have "short", so the "long" option shouldn't be required + channel_info = (related["shortBylineText"]? || related["longBylineText"]?) + .try &.dig?("runs", 0) + + author = channel_info.try &.dig?("text") + ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } + + # "4,088,033 views", only available on compact renderer + # and when video is not a livestream + view_count = related.dig?("viewCountText", "simpleText") + .try &.as_s.gsub(/\D/, "") + + short_view_count = related.try do |r| + HelperExtractors.get_short_view_count(r).to_s + end + + LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container") + + # TODO: when refactoring video types, make a struct for related videos + # or reuse an existing type, if that fits. + return { + "id" => related["videoId"], + "title" => related["title"]["simpleText"], + "author" => author || JSON::Any.new(""), + "ucid" => JSON::Any.new(ucid || ""), + "length_seconds" => JSON::Any.new(length || "0"), + "view_count" => JSON::Any.new(view_count || "0"), + "short_view_count" => JSON::Any.new(short_view_count || "0"), + } end def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil) @@ -871,30 +898,61 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ params[f] = player_response[f] if player_response[f]? end - params["relatedVideos"] = ( - player_response - .dig?("playerOverlays", "playerOverlayRenderer", "endScreen", "watchNextEndScreenRenderer", "results") - .try &.as_a.compact_map { |r| parse_related r } || \ - player_response - .dig?("webWatchNextResponseExtensionData", "relatedVideoArgs") - .try &.as_s.split(",").map { |r| - r = HTTP::Params.parse(r).to_h - JSON::Any.new(Hash.zip(r.keys, r.values.map { |v| JSON::Any.new(v) })) - } - ).try { |a| JSON::Any.new(a) } || JSON::Any.new([] of JSON::Any) - # Top level elements - primary_results = player_response - .dig?("contents", "twoColumnWatchNextResults", "results", "results", "contents") + main_results = player_response.dig?("contents", "twoColumnWatchNextResults") + + raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results + + primary_results = main_results.dig?("results", "results", "contents") + secondary_results = main_results + .dig?("secondaryResults", "secondaryResults", "results") + + raise BrokenTubeException.new("results") if !primary_results + raise BrokenTubeException.new("secondaryResults") if !secondary_results video_primary_renderer = primary_results - .try &.as_a.find(&.["videoPrimaryInfoRenderer"]?) - .try &.["videoPrimaryInfoRenderer"] + .as_a.find(&.["videoPrimaryInfoRenderer"]?) + .try &.["videoPrimaryInfoRenderer"] video_secondary_renderer = primary_results - .try &.as_a.find(&.["videoSecondaryInfoRenderer"]?) - .try &.["videoSecondaryInfoRenderer"] + .as_a.find(&.["videoSecondaryInfoRenderer"]?) + .try &.["videoSecondaryInfoRenderer"] + + raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer + raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer + + # Related videos + + LOGGER.debug("extract_video_info: parsing related videos...") + + related = [] of JSON::Any + + # Parse "compactVideoRenderer" items (under secondary results) + secondary_results.as_a.each do |element| + if item = element["compactVideoRenderer"]? + related_video = parse_related_video(item) + related << JSON::Any.new(related_video) if related_video + end + end + + # If nothing was found previously, fall back to end screen renderer + if related.empty? + # Container for "endScreenVideoRenderer" items + player_overlays = player_response.dig?( + "playerOverlays", "playerOverlayRenderer", + "endScreen", "watchNextEndScreenRenderer", "results" + ) + + player_overlays.try &.as_a.each do |element| + if item = element["endScreenVideoRenderer"]? + related_video = parse_related_video(item) + related << JSON::Any.new(related_video) if related_video + end + end + end + + params["relatedVideos"] = JSON::Any.new(related) # Likes/dislikes diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 00f5f8b7..2e0aee99 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -321,11 +321,11 @@ we're going to need to do it here in order to allow for translations. </div> <div class="pure-u-10-24" style="text-align:right"> - <% if views = rv["short_view_count_text"]?.try &.delete(", views watching") %> - <% if !views.empty? %> - <b class="width:100%"><%= translate_count(locale, "generic_views_count", views.to_i? || 0) %></b> - <% end %> - <% end %> + <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> diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 66b3cdef..ce39bc28 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -505,7 +505,7 @@ end # # Mostly used to extract out repeated structures to deal with code # repetition. -private module HelperExtractors +module HelperExtractors # Retrieves the amount of videos present within the given InnerTube data. # # Returns a 0 when it's unable to do so @@ -519,6 +519,20 @@ private module HelperExtractors end end + # Retrieves the amount of views/viewers a video has. + # Seems to be used on related videos only + # + # Returns "0" when unable to parse + def self.get_short_view_count(container : JSON::Any) : String + box = container["shortViewCountText"]? + return "0" if !box + + # Simpletext: "4M views" + # runs: {"text": "1.1K"},{"text":" watching"} + return box["simpleText"]?.try &.as_s.sub(" views", "") || + box.dig?("runs", 0, "text").try &.as_s || "0" + end + # Retrieve lowest quality thumbnail from InnerTube data # # TODO allow configuration of image quality (-1 is highest) @@ -554,7 +568,7 @@ def extract_item(item : JSON::Any, author_fallback : String? = "", # Cycles through all of the item parsers and attempt to parse the raw YT JSON data. # Each parser automatically validates the data given to see if the data is - # applicable to itself. If not nil is returned and the next parser is attemped. + # applicable to itself. If not nil is returned and the next parser is attempted. ITEM_PARSERS.each do |parser| LOGGER.trace("extract_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)") diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 426c076a..5bbd9213 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -90,7 +90,7 @@ module YoutubeAPI property client_type : ClientType # Region to provide to youtube, e.g to alter search results - # (this is passed as the `gl` parmeter). + # (this is passed as the `gl` parameter). property region : String | Nil # ISO code of country where the proxy is located. @@ -205,7 +205,7 @@ module YoutubeAPI # :ditto: def browse( browse_id : String, - *, # Force the following paramters to be passed by name + *, # Force the following parameters to be passed by name params : String, client_config : ClientConfig | Nil = nil ) @@ -215,7 +215,7 @@ module YoutubeAPI "context" => self.make_context(client_config), } - # Append the additionnal parameters if those were provided + # Append the additional parameters if those were provided # (this is required for channel info, playlist and community, e.g) if params != "" data["params"] = params @@ -292,14 +292,14 @@ module YoutubeAPI # and POST data in order to get a JSON reply. # # The requested data is a video ID (`v=` parameter), with some - # additional paramters, formatted as a base64 string. + # additional parameters, formatted as a base64 string. # # An optional ClientConfig parameter can be passed, too (see # `struct ClientConfig` above for more details). # def player( video_id : String, - *, # Force the following paramters to be passed by name + *, # Force the following parameters to be passed by name params : String, client_config : ClientConfig | Nil = nil ) @@ -309,7 +309,7 @@ module YoutubeAPI "context" => self.make_context(client_config), } - # Append the additionnal parameters if those were provided + # Append the additional parameters if those were provided if params != "" data["params"] = params end @@ -363,7 +363,7 @@ module YoutubeAPI # order to get non-US results. # # The requested data is a search string, with some additional - # paramters, formatted as a base64 string. + # parameters, formatted as a base64 string. # # An optional ClientConfig parameter can be passed, too (see # `struct ClientConfig` above for more details). |
