mirror of
				https://github.com/iv-org/invidious.git
				synced 2025-11-03 22:21:55 +00:00 
			
		
		
		
	videos: move player/next parsing code to a dedicated file
This commit is contained in:
		@@ -12,6 +12,7 @@ require "../src/invidious/helpers/logger"
 | 
			
		||||
require "../src/invidious/helpers/utils"
 | 
			
		||||
 | 
			
		||||
require "../src/invidious/videos"
 | 
			
		||||
require "../src/invidious/videos/*"
 | 
			
		||||
require "../src/invidious/comments"
 | 
			
		||||
 | 
			
		||||
require "../src/invidious/helpers/serialized_yt_data"
 | 
			
		||||
 
 | 
			
		||||
@@ -535,342 +535,6 @@ class VideoRedirect < Exception
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
# 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
 | 
			
		||||
# 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"]?
 | 
			
		||||
 | 
			
		||||
  # 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")
 | 
			
		||||
  author_verified = has_verified_badge?(related["ownerBadges"]?).to_s
 | 
			
		||||
 | 
			
		||||
  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"),
 | 
			
		||||
    "author_verified"  => JSON::Any.new(author_verified),
 | 
			
		||||
  }
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil)
 | 
			
		||||
  # Init client config for the API
 | 
			
		||||
  client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region)
 | 
			
		||||
  if context_screen == "embed"
 | 
			
		||||
    client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Fetch data from the player endpoint
 | 
			
		||||
  player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
 | 
			
		||||
 | 
			
		||||
  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
 | 
			
		||||
 | 
			
		||||
    # Stop here if video is not a scheduled livestream
 | 
			
		||||
    if playability_status != "LIVE_STREAM_OFFLINE"
 | 
			
		||||
      return {
 | 
			
		||||
        "reason" => JSON::Any.new(reason),
 | 
			
		||||
      }
 | 
			
		||||
    end
 | 
			
		||||
  elsif video_id != player_response.dig("videoDetails", "videoId")
 | 
			
		||||
    # YouTube may return a different video player response than expected.
 | 
			
		||||
    # See: https://github.com/TeamNewPipe/NewPipe/issues/8713
 | 
			
		||||
    raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (WEB client)")
 | 
			
		||||
  else
 | 
			
		||||
    reason = nil
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Don't fetch the next endpoint if the video is unavailable.
 | 
			
		||||
  if {"OK", "LIVE_STREAM_OFFLINE"}.any?(playability_status)
 | 
			
		||||
    next_response = YoutubeAPI.next({"videoId": video_id, "params": ""})
 | 
			
		||||
    player_response = player_response.merge(next_response)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  params = parse_video_info(video_id, player_response)
 | 
			
		||||
  params["reason"] = JSON::Any.new(reason) if reason
 | 
			
		||||
 | 
			
		||||
  # Fetch the video streams using an Android client in order to get the decrypted URLs and
 | 
			
		||||
  # maybe fix throttling issues (#2194).See for the explanation about the decrypted URLs:
 | 
			
		||||
  # https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
 | 
			
		||||
  if reason.nil?
 | 
			
		||||
    if context_screen == "embed"
 | 
			
		||||
      client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed
 | 
			
		||||
    else
 | 
			
		||||
      client_config.client_type = YoutubeAPI::ClientType::Android
 | 
			
		||||
    end
 | 
			
		||||
    android_player = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
 | 
			
		||||
 | 
			
		||||
    # Sometimes, the video is available from the web client, but not on Android, so check
 | 
			
		||||
    # that here, and fallback to the streaming data from the web client if needed.
 | 
			
		||||
    # See: https://github.com/iv-org/invidious/issues/2549
 | 
			
		||||
    if video_id != android_player.dig("videoDetails", "videoId")
 | 
			
		||||
      # YouTube may return a different video player response than expected.
 | 
			
		||||
      # See: https://github.com/TeamNewPipe/NewPipe/issues/8713
 | 
			
		||||
      raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (ANDROID client)")
 | 
			
		||||
    elsif android_player["playabilityStatus"]["status"] == "OK"
 | 
			
		||||
      params["streamingData"] = android_player["streamingData"]? || JSON::Any.new("")
 | 
			
		||||
    else
 | 
			
		||||
      params["streamingData"] = player_response["streamingData"]? || JSON::Any.new("")
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # TODO: clean that up
 | 
			
		||||
  {"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f|
 | 
			
		||||
    params[f] = player_response[f] if player_response[f]?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  return params
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any)
 | 
			
		||||
  # Top level elements
 | 
			
		||||
 | 
			
		||||
  main_results = player_response.dig?("contents", "twoColumnWatchNextResults")
 | 
			
		||||
 | 
			
		||||
  raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results
 | 
			
		||||
 | 
			
		||||
  # Primary results are not available on Music videos
 | 
			
		||||
  # See: https://github.com/iv-org/invidious/pull/3238#issuecomment-1207193725
 | 
			
		||||
  if primary_results = main_results.dig?("results", "results", "contents")
 | 
			
		||||
    video_primary_renderer = primary_results
 | 
			
		||||
      .as_a.find(&.["videoPrimaryInfoRenderer"]?)
 | 
			
		||||
      .try &.["videoPrimaryInfoRenderer"]
 | 
			
		||||
 | 
			
		||||
    video_secondary_renderer = primary_results
 | 
			
		||||
      .as_a.find(&.["videoSecondaryInfoRenderer"]?)
 | 
			
		||||
      .try &.["videoSecondaryInfoRenderer"]
 | 
			
		||||
 | 
			
		||||
    raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer
 | 
			
		||||
    raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  video_details = player_response.dig?("videoDetails")
 | 
			
		||||
  microformat = player_response.dig?("microformat", "playerMicroformatRenderer")
 | 
			
		||||
 | 
			
		||||
  raise BrokenTubeException.new("videoDetails") if !video_details
 | 
			
		||||
  raise BrokenTubeException.new("microformat") if !microformat
 | 
			
		||||
 | 
			
		||||
  # Basic video infos
 | 
			
		||||
 | 
			
		||||
  title = video_details["title"]?.try &.as_s
 | 
			
		||||
 | 
			
		||||
  # We have to try to extract viewCount from videoPrimaryInfoRenderer first,
 | 
			
		||||
  # then from videoDetails, as the latter is "0" for livestreams (we want
 | 
			
		||||
  # to get the amount of viewers watching).
 | 
			
		||||
  views = video_primary_renderer
 | 
			
		||||
    .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount", "runs", 0, "text")
 | 
			
		||||
      .try &.as_s.to_i64
 | 
			
		||||
  views ||= video_details["viewCount"]?.try &.as_s.to_i64
 | 
			
		||||
 | 
			
		||||
  length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"])
 | 
			
		||||
    .try &.as_s.to_i64
 | 
			
		||||
 | 
			
		||||
  published = microformat["publishDate"]?
 | 
			
		||||
    .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc
 | 
			
		||||
 | 
			
		||||
  premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp")
 | 
			
		||||
    .try { |t| Time.parse_rfc3339(t.as_s) }
 | 
			
		||||
 | 
			
		||||
  live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow")
 | 
			
		||||
    .try &.as_bool || false
 | 
			
		||||
 | 
			
		||||
  # Extra video infos
 | 
			
		||||
 | 
			
		||||
  allowed_regions = microformat["availableCountries"]?
 | 
			
		||||
    .try &.as_a.map &.as_s || [] of String
 | 
			
		||||
 | 
			
		||||
  allow_ratings = video_details["allowRatings"]?.try &.as_bool
 | 
			
		||||
  family_friendly = microformat["isFamilySafe"].try &.as_bool
 | 
			
		||||
  is_listed = video_details["isCrawlable"]?.try &.as_bool
 | 
			
		||||
  is_upcoming = video_details["isUpcoming"]?.try &.as_bool
 | 
			
		||||
 | 
			
		||||
  keywords = video_details["keywords"]?
 | 
			
		||||
    .try &.as_a.map &.as_s || [] of String
 | 
			
		||||
 | 
			
		||||
  # Related videos
 | 
			
		||||
 | 
			
		||||
  LOGGER.debug("extract_video_info: parsing related videos...")
 | 
			
		||||
 | 
			
		||||
  related = [] of JSON::Any
 | 
			
		||||
 | 
			
		||||
  # Parse "compactVideoRenderer" items (under secondary results)
 | 
			
		||||
  secondary_results = main_results
 | 
			
		||||
    .dig?("secondaryResults", "secondaryResults", "results")
 | 
			
		||||
  secondary_results.try &.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
 | 
			
		||||
 | 
			
		||||
  # Likes
 | 
			
		||||
 | 
			
		||||
  toplevel_buttons = video_primary_renderer
 | 
			
		||||
    .try &.dig?("videoActions", "menuRenderer", "topLevelButtons")
 | 
			
		||||
 | 
			
		||||
  if toplevel_buttons
 | 
			
		||||
    likes_button = toplevel_buttons.as_a
 | 
			
		||||
      .find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE")
 | 
			
		||||
      .try &.["toggleButtonRenderer"]
 | 
			
		||||
 | 
			
		||||
    if likes_button
 | 
			
		||||
      likes_txt = (likes_button["defaultText"]? || likes_button["toggledText"]?)
 | 
			
		||||
        .try &.dig?("accessibility", "accessibilityData", "label")
 | 
			
		||||
      likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt
 | 
			
		||||
 | 
			
		||||
      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
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Description
 | 
			
		||||
 | 
			
		||||
  description = microformat.dig?("description", "simpleText").try &.as_s || ""
 | 
			
		||||
  short_description = player_response.dig?("videoDetails", "shortDescription")
 | 
			
		||||
 | 
			
		||||
  description_html = video_secondary_renderer.try &.dig?("description", "runs")
 | 
			
		||||
    .try &.as_a.try { |t| content_to_comment_html(t, video_id) }
 | 
			
		||||
 | 
			
		||||
  # Video metadata
 | 
			
		||||
 | 
			
		||||
  metadata = video_secondary_renderer
 | 
			
		||||
    .try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows")
 | 
			
		||||
      .try &.as_a
 | 
			
		||||
 | 
			
		||||
  genre = microformat["category"]?
 | 
			
		||||
  genre_ucid = nil
 | 
			
		||||
  license = nil
 | 
			
		||||
 | 
			
		||||
  metadata.try &.each do |row|
 | 
			
		||||
    metadata_title = row.dig?("metadataRowRenderer", "title", "simpleText").try &.as_s
 | 
			
		||||
    contents = row.dig?("metadataRowRenderer", "contents", 0)
 | 
			
		||||
 | 
			
		||||
    if metadata_title == "Category"
 | 
			
		||||
      contents = contents.try &.dig?("runs", 0)
 | 
			
		||||
 | 
			
		||||
      genre = contents.try &.["text"]?
 | 
			
		||||
      genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId")
 | 
			
		||||
    elsif metadata_title == "License"
 | 
			
		||||
      license = contents.try &.dig?("runs", 0, "text")
 | 
			
		||||
    elsif metadata_title == "Licensed to YouTube by"
 | 
			
		||||
      license = contents.try &.["simpleText"]?
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Author infos
 | 
			
		||||
 | 
			
		||||
  author = video_details["author"]?.try &.as_s
 | 
			
		||||
  ucid = video_details["channelId"]?.try &.as_s
 | 
			
		||||
 | 
			
		||||
  if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer")
 | 
			
		||||
    author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url")
 | 
			
		||||
    author_verified = has_verified_badge?(author_info["badges"]?)
 | 
			
		||||
 | 
			
		||||
    subs_text = author_info["subscriberCountText"]?
 | 
			
		||||
      .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }
 | 
			
		||||
      .try &.as_s.split(" ", 2)[0]
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Return data
 | 
			
		||||
 | 
			
		||||
  if live_now
 | 
			
		||||
    video_type = VideoType::Livestream
 | 
			
		||||
  elsif !premiere_timestamp.nil?
 | 
			
		||||
    video_type = VideoType::Scheduled
 | 
			
		||||
    published = premiere_timestamp || Time.utc
 | 
			
		||||
  else
 | 
			
		||||
    video_type = VideoType::Video
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  params = {
 | 
			
		||||
    "videoType" => JSON::Any.new(video_type.to_s),
 | 
			
		||||
    # Basic video infos
 | 
			
		||||
    "title"         => JSON::Any.new(title || ""),
 | 
			
		||||
    "views"         => JSON::Any.new(views || 0_i64),
 | 
			
		||||
    "likes"         => JSON::Any.new(likes || 0_i64),
 | 
			
		||||
    "lengthSeconds" => JSON::Any.new(length_txt || 0_i64),
 | 
			
		||||
    "published"     => JSON::Any.new(published.to_rfc3339),
 | 
			
		||||
    # Extra video infos
 | 
			
		||||
    "allowedRegions"   => JSON::Any.new(allowed_regions.map { |v| JSON::Any.new(v) }),
 | 
			
		||||
    "allowRatings"     => JSON::Any.new(allow_ratings || false),
 | 
			
		||||
    "isFamilyFriendly" => JSON::Any.new(family_friendly || false),
 | 
			
		||||
    "isListed"         => JSON::Any.new(is_listed || false),
 | 
			
		||||
    "isUpcoming"       => JSON::Any.new(is_upcoming || false),
 | 
			
		||||
    "keywords"         => JSON::Any.new(keywords.map { |v| JSON::Any.new(v) }),
 | 
			
		||||
    # Related videos
 | 
			
		||||
    "relatedVideos" => JSON::Any.new(related),
 | 
			
		||||
    # Description
 | 
			
		||||
    "description"      => JSON::Any.new(description || ""),
 | 
			
		||||
    "descriptionHtml"  => JSON::Any.new(description_html || "<p></p>"),
 | 
			
		||||
    "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil),
 | 
			
		||||
    # Video metadata
 | 
			
		||||
    "genre"     => JSON::Any.new(genre.try &.as_s || ""),
 | 
			
		||||
    "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""),
 | 
			
		||||
    "license"   => JSON::Any.new(license.try &.as_s || ""),
 | 
			
		||||
    # Author infos
 | 
			
		||||
    "author"          => JSON::Any.new(author || ""),
 | 
			
		||||
    "ucid"            => JSON::Any.new(ucid || ""),
 | 
			
		||||
    "authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""),
 | 
			
		||||
    "authorVerified"  => JSON::Any.new(author_verified || false),
 | 
			
		||||
    "subCountText"    => JSON::Any.new(subs_text || "-"),
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return params
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def get_video(id, refresh = true, region = nil, force_refresh = false)
 | 
			
		||||
  if (video = Invidious::Database::Videos.select(id)) && !region
 | 
			
		||||
    # If record was last updated over 10 minutes ago, or video has since premiered,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										337
									
								
								src/invidious/videos/parser.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										337
									
								
								src/invidious/videos/parser.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,337 @@
 | 
			
		||||
require "json"
 | 
			
		||||
 | 
			
		||||
# 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
 | 
			
		||||
# 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"]?
 | 
			
		||||
 | 
			
		||||
  # 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")
 | 
			
		||||
  author_verified = has_verified_badge?(related["ownerBadges"]?).to_s
 | 
			
		||||
 | 
			
		||||
  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"),
 | 
			
		||||
    "author_verified"  => JSON::Any.new(author_verified),
 | 
			
		||||
  }
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil)
 | 
			
		||||
  # Init client config for the API
 | 
			
		||||
  client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region)
 | 
			
		||||
  if context_screen == "embed"
 | 
			
		||||
    client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Fetch data from the player endpoint
 | 
			
		||||
  player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
 | 
			
		||||
 | 
			
		||||
  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
 | 
			
		||||
 | 
			
		||||
    # Stop here if video is not a scheduled livestream
 | 
			
		||||
    if playability_status != "LIVE_STREAM_OFFLINE"
 | 
			
		||||
      return {
 | 
			
		||||
        "reason" => JSON::Any.new(reason),
 | 
			
		||||
      }
 | 
			
		||||
    end
 | 
			
		||||
  elsif video_id != player_response.dig("videoDetails", "videoId")
 | 
			
		||||
    # YouTube may return a different video player response than expected.
 | 
			
		||||
    # See: https://github.com/TeamNewPipe/NewPipe/issues/8713
 | 
			
		||||
    raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (WEB client)")
 | 
			
		||||
  else
 | 
			
		||||
    reason = nil
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Don't fetch the next endpoint if the video is unavailable.
 | 
			
		||||
  if {"OK", "LIVE_STREAM_OFFLINE"}.any?(playability_status)
 | 
			
		||||
    next_response = YoutubeAPI.next({"videoId": video_id, "params": ""})
 | 
			
		||||
    player_response = player_response.merge(next_response)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  params = parse_video_info(video_id, player_response)
 | 
			
		||||
  params["reason"] = JSON::Any.new(reason) if reason
 | 
			
		||||
 | 
			
		||||
  # Fetch the video streams using an Android client in order to get the decrypted URLs and
 | 
			
		||||
  # maybe fix throttling issues (#2194).See for the explanation about the decrypted URLs:
 | 
			
		||||
  # https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
 | 
			
		||||
  if reason.nil?
 | 
			
		||||
    if context_screen == "embed"
 | 
			
		||||
      client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed
 | 
			
		||||
    else
 | 
			
		||||
      client_config.client_type = YoutubeAPI::ClientType::Android
 | 
			
		||||
    end
 | 
			
		||||
    android_player = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
 | 
			
		||||
 | 
			
		||||
    # Sometimes, the video is available from the web client, but not on Android, so check
 | 
			
		||||
    # that here, and fallback to the streaming data from the web client if needed.
 | 
			
		||||
    # See: https://github.com/iv-org/invidious/issues/2549
 | 
			
		||||
    if video_id != android_player.dig("videoDetails", "videoId")
 | 
			
		||||
      # YouTube may return a different video player response than expected.
 | 
			
		||||
      # See: https://github.com/TeamNewPipe/NewPipe/issues/8713
 | 
			
		||||
      raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (ANDROID client)")
 | 
			
		||||
    elsif android_player["playabilityStatus"]["status"] == "OK"
 | 
			
		||||
      params["streamingData"] = android_player["streamingData"]? || JSON::Any.new("")
 | 
			
		||||
    else
 | 
			
		||||
      params["streamingData"] = player_response["streamingData"]? || JSON::Any.new("")
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # TODO: clean that up
 | 
			
		||||
  {"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f|
 | 
			
		||||
    params[f] = player_response[f] if player_response[f]?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  return params
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any)
 | 
			
		||||
  # Top level elements
 | 
			
		||||
 | 
			
		||||
  main_results = player_response.dig?("contents", "twoColumnWatchNextResults")
 | 
			
		||||
 | 
			
		||||
  raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results
 | 
			
		||||
 | 
			
		||||
  # Primary results are not available on Music videos
 | 
			
		||||
  # See: https://github.com/iv-org/invidious/pull/3238#issuecomment-1207193725
 | 
			
		||||
  if primary_results = main_results.dig?("results", "results", "contents")
 | 
			
		||||
    video_primary_renderer = primary_results
 | 
			
		||||
      .as_a.find(&.["videoPrimaryInfoRenderer"]?)
 | 
			
		||||
      .try &.["videoPrimaryInfoRenderer"]
 | 
			
		||||
 | 
			
		||||
    video_secondary_renderer = primary_results
 | 
			
		||||
      .as_a.find(&.["videoSecondaryInfoRenderer"]?)
 | 
			
		||||
      .try &.["videoSecondaryInfoRenderer"]
 | 
			
		||||
 | 
			
		||||
    raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer
 | 
			
		||||
    raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  video_details = player_response.dig?("videoDetails")
 | 
			
		||||
  microformat = player_response.dig?("microformat", "playerMicroformatRenderer")
 | 
			
		||||
 | 
			
		||||
  raise BrokenTubeException.new("videoDetails") if !video_details
 | 
			
		||||
  raise BrokenTubeException.new("microformat") if !microformat
 | 
			
		||||
 | 
			
		||||
  # Basic video infos
 | 
			
		||||
 | 
			
		||||
  title = video_details["title"]?.try &.as_s
 | 
			
		||||
 | 
			
		||||
  # We have to try to extract viewCount from videoPrimaryInfoRenderer first,
 | 
			
		||||
  # then from videoDetails, as the latter is "0" for livestreams (we want
 | 
			
		||||
  # to get the amount of viewers watching).
 | 
			
		||||
  views = video_primary_renderer
 | 
			
		||||
    .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount", "runs", 0, "text")
 | 
			
		||||
      .try &.as_s.to_i64
 | 
			
		||||
  views ||= video_details["viewCount"]?.try &.as_s.to_i64
 | 
			
		||||
 | 
			
		||||
  length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"])
 | 
			
		||||
    .try &.as_s.to_i64
 | 
			
		||||
 | 
			
		||||
  published = microformat["publishDate"]?
 | 
			
		||||
    .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc
 | 
			
		||||
 | 
			
		||||
  premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp")
 | 
			
		||||
    .try { |t| Time.parse_rfc3339(t.as_s) }
 | 
			
		||||
 | 
			
		||||
  live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow")
 | 
			
		||||
    .try &.as_bool || false
 | 
			
		||||
 | 
			
		||||
  # Extra video infos
 | 
			
		||||
 | 
			
		||||
  allowed_regions = microformat["availableCountries"]?
 | 
			
		||||
    .try &.as_a.map &.as_s || [] of String
 | 
			
		||||
 | 
			
		||||
  allow_ratings = video_details["allowRatings"]?.try &.as_bool
 | 
			
		||||
  family_friendly = microformat["isFamilySafe"].try &.as_bool
 | 
			
		||||
  is_listed = video_details["isCrawlable"]?.try &.as_bool
 | 
			
		||||
  is_upcoming = video_details["isUpcoming"]?.try &.as_bool
 | 
			
		||||
 | 
			
		||||
  keywords = video_details["keywords"]?
 | 
			
		||||
    .try &.as_a.map &.as_s || [] of String
 | 
			
		||||
 | 
			
		||||
  # Related videos
 | 
			
		||||
 | 
			
		||||
  LOGGER.debug("extract_video_info: parsing related videos...")
 | 
			
		||||
 | 
			
		||||
  related = [] of JSON::Any
 | 
			
		||||
 | 
			
		||||
  # Parse "compactVideoRenderer" items (under secondary results)
 | 
			
		||||
  secondary_results = main_results
 | 
			
		||||
    .dig?("secondaryResults", "secondaryResults", "results")
 | 
			
		||||
  secondary_results.try &.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
 | 
			
		||||
 | 
			
		||||
  # Likes
 | 
			
		||||
 | 
			
		||||
  toplevel_buttons = video_primary_renderer
 | 
			
		||||
    .try &.dig?("videoActions", "menuRenderer", "topLevelButtons")
 | 
			
		||||
 | 
			
		||||
  if toplevel_buttons
 | 
			
		||||
    likes_button = toplevel_buttons.as_a
 | 
			
		||||
      .find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE")
 | 
			
		||||
      .try &.["toggleButtonRenderer"]
 | 
			
		||||
 | 
			
		||||
    if likes_button
 | 
			
		||||
      likes_txt = (likes_button["defaultText"]? || likes_button["toggledText"]?)
 | 
			
		||||
        .try &.dig?("accessibility", "accessibilityData", "label")
 | 
			
		||||
      likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt
 | 
			
		||||
 | 
			
		||||
      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
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Description
 | 
			
		||||
 | 
			
		||||
  description = microformat.dig?("description", "simpleText").try &.as_s || ""
 | 
			
		||||
  short_description = player_response.dig?("videoDetails", "shortDescription")
 | 
			
		||||
 | 
			
		||||
  description_html = video_secondary_renderer.try &.dig?("description", "runs")
 | 
			
		||||
    .try &.as_a.try { |t| content_to_comment_html(t, video_id) }
 | 
			
		||||
 | 
			
		||||
  # Video metadata
 | 
			
		||||
 | 
			
		||||
  metadata = video_secondary_renderer
 | 
			
		||||
    .try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows")
 | 
			
		||||
      .try &.as_a
 | 
			
		||||
 | 
			
		||||
  genre = microformat["category"]?
 | 
			
		||||
  genre_ucid = nil
 | 
			
		||||
  license = nil
 | 
			
		||||
 | 
			
		||||
  metadata.try &.each do |row|
 | 
			
		||||
    metadata_title = row.dig?("metadataRowRenderer", "title", "simpleText").try &.as_s
 | 
			
		||||
    contents = row.dig?("metadataRowRenderer", "contents", 0)
 | 
			
		||||
 | 
			
		||||
    if metadata_title == "Category"
 | 
			
		||||
      contents = contents.try &.dig?("runs", 0)
 | 
			
		||||
 | 
			
		||||
      genre = contents.try &.["text"]?
 | 
			
		||||
      genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId")
 | 
			
		||||
    elsif metadata_title == "License"
 | 
			
		||||
      license = contents.try &.dig?("runs", 0, "text")
 | 
			
		||||
    elsif metadata_title == "Licensed to YouTube by"
 | 
			
		||||
      license = contents.try &.["simpleText"]?
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Author infos
 | 
			
		||||
 | 
			
		||||
  author = video_details["author"]?.try &.as_s
 | 
			
		||||
  ucid = video_details["channelId"]?.try &.as_s
 | 
			
		||||
 | 
			
		||||
  if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer")
 | 
			
		||||
    author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url")
 | 
			
		||||
    author_verified = has_verified_badge?(author_info["badges"]?)
 | 
			
		||||
 | 
			
		||||
    subs_text = author_info["subscriberCountText"]?
 | 
			
		||||
      .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }
 | 
			
		||||
      .try &.as_s.split(" ", 2)[0]
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Return data
 | 
			
		||||
 | 
			
		||||
  if live_now
 | 
			
		||||
    video_type = VideoType::Livestream
 | 
			
		||||
  elsif !premiere_timestamp.nil?
 | 
			
		||||
    video_type = VideoType::Scheduled
 | 
			
		||||
    published = premiere_timestamp || Time.utc
 | 
			
		||||
  else
 | 
			
		||||
    video_type = VideoType::Video
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  params = {
 | 
			
		||||
    "videoType" => JSON::Any.new(video_type.to_s),
 | 
			
		||||
    # Basic video infos
 | 
			
		||||
    "title"         => JSON::Any.new(title || ""),
 | 
			
		||||
    "views"         => JSON::Any.new(views || 0_i64),
 | 
			
		||||
    "likes"         => JSON::Any.new(likes || 0_i64),
 | 
			
		||||
    "lengthSeconds" => JSON::Any.new(length_txt || 0_i64),
 | 
			
		||||
    "published"     => JSON::Any.new(published.to_rfc3339),
 | 
			
		||||
    # Extra video infos
 | 
			
		||||
    "allowedRegions"   => JSON::Any.new(allowed_regions.map { |v| JSON::Any.new(v) }),
 | 
			
		||||
    "allowRatings"     => JSON::Any.new(allow_ratings || false),
 | 
			
		||||
    "isFamilyFriendly" => JSON::Any.new(family_friendly || false),
 | 
			
		||||
    "isListed"         => JSON::Any.new(is_listed || false),
 | 
			
		||||
    "isUpcoming"       => JSON::Any.new(is_upcoming || false),
 | 
			
		||||
    "keywords"         => JSON::Any.new(keywords.map { |v| JSON::Any.new(v) }),
 | 
			
		||||
    # Related videos
 | 
			
		||||
    "relatedVideos" => JSON::Any.new(related),
 | 
			
		||||
    # Description
 | 
			
		||||
    "description"      => JSON::Any.new(description || ""),
 | 
			
		||||
    "descriptionHtml"  => JSON::Any.new(description_html || "<p></p>"),
 | 
			
		||||
    "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil),
 | 
			
		||||
    # Video metadata
 | 
			
		||||
    "genre"     => JSON::Any.new(genre.try &.as_s || ""),
 | 
			
		||||
    "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""),
 | 
			
		||||
    "license"   => JSON::Any.new(license.try &.as_s || ""),
 | 
			
		||||
    # Author infos
 | 
			
		||||
    "author"          => JSON::Any.new(author || ""),
 | 
			
		||||
    "ucid"            => JSON::Any.new(ucid || ""),
 | 
			
		||||
    "authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""),
 | 
			
		||||
    "authorVerified"  => JSON::Any.new(author_verified || false),
 | 
			
		||||
    "subCountText"    => JSON::Any.new(subs_text || "-"),
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return params
 | 
			
		||||
end
 | 
			
		||||
		Reference in New Issue
	
	Block a user