From 4ae227ce914b01c36b1670227ebfc7f4c56045a6 Mon Sep 17 00:00:00 2001 From: Fijxu Date: Thu, 28 May 2026 13:11:41 -0400 Subject: [PATCH] Encapsulate videos parser and clip functions inside it's own `Invidious::Videos::Parser` and `Invidious::Videos::Clip` module (#5745) Part of https://github.com/iv-org/invidious/issues/5744 --- .../videos/regular_videos_extract_spec.cr | 4 +- .../videos/scheduled_live_extract_spec.cr | 2 +- src/invidious/routes/api/v1/videos.cr | 2 +- src/invidious/routes/embed.cr | 2 +- src/invidious/routes/watch.cr | 4 +- src/invidious/videos.cr | 2 +- src/invidious/videos/clip.cr | 34 +- src/invidious/videos/parser.cr | 820 +++++++++--------- src/invidious/videos/video_preferences.cr | 324 +++---- 9 files changed, 603 insertions(+), 591 deletions(-) diff --git a/spec/invidious/videos/regular_videos_extract_spec.cr b/spec/invidious/videos/regular_videos_extract_spec.cr index b82a08ee..00b85bd0 100644 --- a/spec/invidious/videos/regular_videos_extract_spec.cr +++ b/spec/invidious/videos/regular_videos_extract_spec.cr @@ -7,7 +7,7 @@ Spectator.describe "parse_video_info" do _next = load_mock("video/regular_mrbeast.next") raw_data = _player.merge!(_next) - info = parse_video_info("2isYuQZMbdU", raw_data) + info = Invidious::Videos::Parser.parse_video_info("2isYuQZMbdU", raw_data) # Some basic verifications expect(typeof(info)).to eq(Hash(String, JSON::Any)) @@ -88,7 +88,7 @@ Spectator.describe "parse_video_info" do _next = load_mock("video/regular_no-description.next") raw_data = _player.merge!(_next) - info = parse_video_info("iuevw6218F0", raw_data) + info = Invidious::Videos::Parser.parse_video_info("iuevw6218F0", raw_data) # Some basic verifications expect(typeof(info)).to eq(Hash(String, JSON::Any)) diff --git a/spec/invidious/videos/scheduled_live_extract_spec.cr b/spec/invidious/videos/scheduled_live_extract_spec.cr index 6bb03e42..95c90813 100644 --- a/spec/invidious/videos/scheduled_live_extract_spec.cr +++ b/spec/invidious/videos/scheduled_live_extract_spec.cr @@ -7,7 +7,7 @@ Spectator.describe "parse_video_info" do _next = load_mock("video/scheduled_live_PBD-Podcast.next") raw_data = _player.merge!(_next) - info = parse_video_info("N-yVic7BbY0", raw_data) + info = Invidious::Videos::Parser.parse_video_info("N-yVic7BbY0", raw_data) # Some basic verifications expect(typeof(info)).to eq(Hash(String, JSON::Any)) diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 5f98321b..0a5d5a4e 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -410,7 +410,7 @@ module Invidious::Routes::API::V1::Videos clip_title = nil if params = response.dig?("endpoint", "watchEndpoint", "params").try &.as_s - start_time, end_time, clip_title = parse_clip_parameters(params) + start_time, end_time, clip_title = Invidious::Videos::Clip.parse_clip_parameters(params) end begin diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index ccd4136f..41dc517c 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -122,7 +122,7 @@ module Invidious::Routes::Embed else nil # Continue end - params = process_video_params(env.params.query, preferences) + params = Invidious::Videos.process_video_params(env.params.query, preferences) user = env.get?("user").try &.as(User) if user diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index b829b0f5..8f244e92 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -47,7 +47,7 @@ module Invidious::Routes::Watch end subscriptions ||= [] of String - params = process_video_params(env.params.query, preferences) + params = Invidious::Videos.process_video_params(env.params.query, preferences) env.params.query.delete_all("listen") begin @@ -273,7 +273,7 @@ module Invidious::Routes::Watch if video_id = response.dig?("endpoint", "watchEndpoint", "videoId") if params = response.dig?("endpoint", "watchEndpoint", "params").try &.as_s - start_time, end_time, _ = parse_clip_parameters(params) + start_time, end_time, _ = Invidious::Videos::Clip.parse_clip_parameters(params) env.params.query["start"] = start_time.to_s if start_time != nil env.params.query["end"] = end_time.to_s if end_time != nil end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 0446922f..5b778725 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -324,7 +324,7 @@ rescue DB::Error end def fetch_video(id, region) - info = extract_video_info(video_id: id) + info = Invidious::Videos::Parser.extract_video_info(video_id: id) if info.nil? raise InfoException.new("Invidious companion is not available. \ diff --git a/src/invidious/videos/clip.cr b/src/invidious/videos/clip.cr index 29c57182..080d2e92 100644 --- a/src/invidious/videos/clip.cr +++ b/src/invidious/videos/clip.cr @@ -1,22 +1,26 @@ require "json" -# returns start_time, end_time and clip_title -def parse_clip_parameters(params) : {Float64?, Float64?, String?} - decoded_protobuf = params.try { |i| URI.decode_www_form(i) } - .try { |i| Base64.decode(i) } - .try { |i| IO::Memory.new(i) } - .try { |i| Protodec::Any.parse(i) } +module Invidious::Videos::Clip + extend self - start_time = decoded_protobuf - .try(&.["50:0:embedded"]["2:1:varint"].as_i64) - .try { |i| i/1000 } + # returns start_time, end_time and clip_title + def parse_clip_parameters(params) : {Float64?, Float64?, String?} + decoded_protobuf = params.try { |i| URI.decode_www_form(i) } + .try { |i| Base64.decode(i) } + .try { |i| IO::Memory.new(i) } + .try { |i| Protodec::Any.parse(i) } - end_time = decoded_protobuf - .try(&.["50:0:embedded"]["3:2:varint"].as_i64) - .try { |i| i/1000 } + start_time = decoded_protobuf + .try(&.["50:0:embedded"]["2:1:varint"].as_i64) + .try { |i| i/1000 } - clip_title = decoded_protobuf - .try(&.["50:0:embedded"]["4:3:string"].as_s) + end_time = decoded_protobuf + .try(&.["50:0:embedded"]["3:2:varint"].as_i64) + .try { |i| i/1000 } - return start_time, end_time, clip_title + clip_title = decoded_protobuf + .try(&.["50:0:embedded"]["4:3:string"].as_s) + + return start_time, end_time, clip_title + end end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index d9107d5a..914c5963 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -1,465 +1,469 @@ 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"]? +module Invidious::Videos::Parser + extend self - # 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 + # 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) } + + short_view_count = related.try do |r| + HelperExtractors.get_short_view_count(r).to_s + end + + LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container") + + if published_time_text = related["publishedTimeText"]? + decoded_time = decode_date(published_time_text["simpleText"].to_s) + published = decoded_time.to_rfc3339.to_s + else + published = nil + end + + # 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"), + "short_view_count" => JSON::Any.new(short_view_count || "0"), + "author_verified" => JSON::Any.new(author_verified), + "published" => JSON::Any.new(published || ""), + } end - # Both have "short", so the "long" option shouldn't be required - channel_info = (related["shortBylineText"]? || related["longBylineText"]?) - .try &.dig?("runs", 0) + def extract_video_info(video_id : String) + # Fetch data from the player endpoint + player_response = YoutubeAPI.player(video_id: video_id) - author = channel_info.try &.dig?("text") - author_verified = has_verified_badge?(related["ownerBadges"]?).to_s + if player_response.nil? + return nil + end - ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } + playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s - short_view_count = related.try do |r| - HelperExtractors.get_short_view_count(r).to_s - end + 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 - LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container") + # Stop here if video is not a scheduled livestream or + # for LOGIN_REQUIRED when videoDetails element is not found because retrying won't help + if !{"LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) || + playability_status == "LOGIN_REQUIRED" && !player_response.dig?("videoDetails") + return { + "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), + "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 + # Line to be reverted if one day we solve the video not available issue. - if published_time_text = related["publishedTimeText"]? - decoded_time = decode_date(published_time_text["simpleText"].to_s) - published = decoded_time.to_rfc3339.to_s - else - published = nil - end + # Although technically not a call to /videoplayback the fact that YouTube is returning the + # wrong video means that we should count it as a failure. + Helpers.get_playback_statistic["totalRequests"] += 1 - # 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"), - "short_view_count" => JSON::Any.new(short_view_count || "0"), - "author_verified" => JSON::Any.new(author_verified), - "published" => JSON::Any.new(published || ""), - } -end - -def extract_video_info(video_id : String) - # Fetch data from the player endpoint - player_response = YoutubeAPI.player(video_id: video_id) - - if player_response.nil? - return nil - end - - 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 or - # for LOGIN_REQUIRED when videoDetails element is not found because retrying won't help - if !{"LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) || - playability_status == "LOGIN_REQUIRED" && !player_response.dig?("videoDetails") return { "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), - "reason" => JSON::Any.new(reason), + "reason" => JSON::Any.new("Can't load the video on this Invidious instance. YouTube is currently trying to block Invidious instances. Click here for more info about the issue."), } + else + reason = nil 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 - # Line to be reverted if one day we solve the video not available issue. - # Although technically not a call to /videoplayback the fact that YouTube is returning the - # wrong video means that we should count it as a failure. - Helpers.get_playback_statistic["totalRequests"] += 1 + # Don't fetch the next endpoint if the video is unavailable. + if {"OK", "LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) + next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) + # Remove the microformat returned by the /next endpoint on some videos + # to prevent player_response microformat from being overwritten. + next_response.delete("microformat") + player_response = player_response.merge(next_response) + end - return { - "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), - "reason" => JSON::Any.new("Can't load the video on this Invidious instance. YouTube is currently trying to block Invidious instances. Click here for more info about the issue."), - } - else - reason = nil - end + params = self.parse_video_info(video_id, player_response) + params["reason"] = JSON::Any.new(reason) if reason - # Don't fetch the next endpoint if the video is unavailable. - if {"OK", "LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) - next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) - # Remove the microformat returned by the /next endpoint on some videos - # to prevent player_response microformat from being overwritten. - next_response.delete("microformat") - player_response = player_response.merge(next_response) - end + {"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f| + params[f] = player_response[f] if player_response[f]? + end - params = parse_video_info(video_id, player_response) - params["reason"] = JSON::Any.new(reason) if reason - - {"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f| - params[f] = player_response[f] if player_response[f]? - end - - # Convert URLs, if those are present - if streaming_data = player_response["streamingData"]? - %w[formats adaptiveFormats].each do |key| - streaming_data.as_h[key]?.try &.as_a.each do |format| - format = format.as_h - if format["url"]?.nil? - format["url"] = format["signatureCipher"] + # Convert URLs, if those are present + if streaming_data = player_response["streamingData"]? + %w[formats adaptiveFormats].each do |key| + streaming_data.as_h[key]?.try &.as_a.each do |format| + format = format.as_h + if format["url"]?.nil? + format["url"] = format["signatureCipher"] + end + format["url"] = JSON::Any.new(convert_url(format)) end - format["url"] = JSON::Any.new(convert_url(format)) end + + params["streamingData"] = streaming_data end - params["streamingData"] = streaming_data + # Data structure version, for cache control + params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64) + + return params end - # Data structure version, for cache control - params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64) + def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)? + LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.") + response = YoutubeAPI.player(video_id: id) - return params -end + playability_status = response["playabilityStatus"]["status"] + LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.") -def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)? - LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.") - response = YoutubeAPI.player(video_id: id) - - playability_status = response["playabilityStatus"]["status"] - LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.") - - if id != response.dig?("videoDetails", "videoId") - # YouTube may return a different video player response than expected. - # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 - raise InfoException.new( - "The video returned by YouTube isn't the requested one. (#{client_config.client_type} client)" - ) - elsif playability_status == "OK" - return response - else - return nil - end -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") - if !(microformat = player_response.dig?("microformat", "playerMicroformatRenderer")) - microformat = {} of String => JSON::Any - end - - raise BrokenTubeException.new("videoDetails") if !video_details - - # 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_txt = extract_text( - video_primary_renderer - .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount") - ) - views_txt ||= video_details["viewCount"]?.try &.as_s || "" - views = views_txt.gsub(/\D/, "").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) } - - premiere_timestamp ||= player_response.dig?( - "playabilityStatus", "liveStreamability", - "liveStreamabilityRenderer", "offlineSlate", - "liveStreamOfflineSlateRenderer", "scheduledStartTime" - ) - .try &.as_s.to_i64 - .try { |t| Time.unix(t) } - - live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow") - .try &.as_bool - live_now ||= video_details.dig?("isLive").try &.as_bool || false - - post_live_dvr = video_details.dig?("isPostLiveDvr") - .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 + if id != response.dig?("videoDetails", "videoId") + # YouTube may return a different video player response than expected. + # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 + raise InfoException.new( + "The video returned by YouTube isn't the requested one. (#{client_config.client_type} client)" + ) + elsif playability_status == "OK" + return response + else + return nil 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" - ) + def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any) + # Top level elements - player_overlays.try &.as_a.each do |element| - if item = element["endScreenVideoRenderer"]? - related_video = parse_related_video(item) + 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") + if !(microformat = player_response.dig?("microformat", "playerMicroformatRenderer")) + microformat = {} of String => JSON::Any + end + + raise BrokenTubeException.new("videoDetails") if !video_details + + # 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_txt = extract_text( + video_primary_renderer + .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount") + ) + views_txt ||= video_details["viewCount"]?.try &.as_s || "" + views = views_txt.gsub(/\D/, "").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) } + + premiere_timestamp ||= player_response.dig?( + "playabilityStatus", "liveStreamability", + "liveStreamabilityRenderer", "offlineSlate", + "liveStreamOfflineSlateRenderer", "scheduledStartTime" + ) + .try &.as_s.to_i64 + .try { |t| Time.unix(t) } + + live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow") + .try &.as_bool + live_now ||= video_details.dig?("isLive").try &.as_bool || false + + post_live_dvr = video_details.dig?("isPostLiveDvr") + .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 = self.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 - # New Format as of december 2023 - likes_button = toplevel_buttons.dig?(0, - "segmentedLikeDislikeButtonViewModel", - "likeButtonViewModel", - "likeButtonViewModel", - "toggleButtonViewModel", - "toggleButtonViewModel", - "defaultButtonViewModel", - "buttonViewModel" - ) - - likes_button ||= toplevel_buttons.try &.as_a - .find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE") - .try &.["toggleButtonRenderer"] - - # New format as of september 2022 - likes_button ||= toplevel_buttons.try &.as_a - .find(&.["segmentedLikeDislikeButtonRenderer"]?) - .try &.dig?( - "segmentedLikeDislikeButtonRenderer", - "likeButton", "toggleButtonRenderer" + # 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" ) - if likes_button - likes_txt = likes_button.dig?("accessibilityText") - # Note: The like count from `toggledText` is off by one, as it would - # represent the new like count in the event where the user clicks on "like". - 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) } - - description_html = parse_description(video_secondary_renderer.try &.dig?("attributedDescription"), 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 = extract_text(row.dig?("metadataRowRenderer", "title")) - 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 - - # Music section - - music_list = [] of VideoMusic - music_desclist = player_response.dig?( - "engagementPanels", 1, "engagementPanelSectionListRenderer", - "content", "structuredDescriptionContentRenderer", "items", 2, - "videoDescriptionMusicSectionRenderer", "carouselLockups" - ) - - music_desclist.try &.as_a.each do |music_desc| - artist = nil - album = nil - music_license = nil - - # Used when the video has multiple songs - if song_title = music_desc.dig?("carouselLockupRenderer", "videoLockup", "compactVideoRenderer", "title") - # "simpleText" for plain text / "runs" when song has a link - song = song_title["simpleText"]? || song_title.dig?("runs", 0, "text") - - # some videos can have empty tracks. See: https://www.youtube.com/watch?v=eBGIQ7ZuuiU - next if !song - end - - music_desc.dig?("carouselLockupRenderer", "infoRows").try &.as_a.each do |desc| - desc_title = extract_text(desc.dig?("infoRowRenderer", "title")) - if desc_title == "ARTIST" - artist = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) - elsif desc_title == "SONG" - song = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) - elsif desc_title == "ALBUM" - album = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) - elsif desc_title == "LICENSES" - music_license = extract_text(desc.dig?("infoRowRenderer", "expandedMetadata")) + player_overlays.try &.as_a.each do |element| + if item = element["endScreenVideoRenderer"]? + related_video = self.parse_related_video(item) + related << JSON::Any.new(related_video) if related_video + end end end - music_list << VideoMusic.new(song.to_s, album.to_s, artist.to_s, music_license.to_s) - end - # Author infos + # Likes - author = video_details["author"]?.try &.as_s - ucid = video_details["channelId"]?.try &.as_s + toplevel_buttons = video_primary_renderer + .try &.dig?("videoActions", "menuRenderer", "topLevelButtons") - 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"]?) + if toplevel_buttons + # New Format as of december 2023 + likes_button = toplevel_buttons.dig?(0, + "segmentedLikeDislikeButtonViewModel", + "likeButtonViewModel", + "likeButtonViewModel", + "toggleButtonViewModel", + "toggleButtonViewModel", + "defaultButtonViewModel", + "buttonViewModel" + ) - subs_text = author_info["subscriberCountText"]? - .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") } - .try &.as_s.split(" ", 2)[0] - end + likes_button ||= toplevel_buttons.try &.as_a + .find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE") + .try &.["toggleButtonRenderer"] - # Return data + # New format as of september 2022 + likes_button ||= toplevel_buttons.try &.as_a + .find(&.["segmentedLikeDislikeButtonRenderer"]?) + .try &.dig?( + "segmentedLikeDislikeButtonRenderer", + "likeButton", "toggleButtonRenderer" + ) - 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 + if likes_button + likes_txt = likes_button.dig?("accessibilityText") + # Note: The like count from `toggledText` is off by one, as it would + # represent the new like count in the event where the user clicks on "like". + 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 - 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) }), - "isPostLiveDvr" => JSON::Any.new(post_live_dvr), - # Related videos - "relatedVideos" => JSON::Any.new(related), # Description - "description" => JSON::Any.new(description || ""), - "descriptionHtml" => JSON::Any.new(description_html || "

"), - "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), + + 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) } + + description_html = parse_description(video_secondary_renderer.try &.dig?("attributedDescription"), video_id) + # 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 || ""), + + 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 = extract_text(row.dig?("metadataRowRenderer", "title")) + 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 + # Music section - "music" => JSON.parse(music_list.to_json), + + music_list = [] of VideoMusic + music_desclist = player_response.dig?( + "engagementPanels", 1, "engagementPanelSectionListRenderer", + "content", "structuredDescriptionContentRenderer", "items", 2, + "videoDescriptionMusicSectionRenderer", "carouselLockups" + ) + + music_desclist.try &.as_a.each do |music_desc| + artist = nil + album = nil + music_license = nil + + # Used when the video has multiple songs + if song_title = music_desc.dig?("carouselLockupRenderer", "videoLockup", "compactVideoRenderer", "title") + # "simpleText" for plain text / "runs" when song has a link + song = song_title["simpleText"]? || song_title.dig?("runs", 0, "text") + + # some videos can have empty tracks. See: https://www.youtube.com/watch?v=eBGIQ7ZuuiU + next if !song + end + + music_desc.dig?("carouselLockupRenderer", "infoRows").try &.as_a.each do |desc| + desc_title = extract_text(desc.dig?("infoRowRenderer", "title")) + if desc_title == "ARTIST" + artist = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) + elsif desc_title == "SONG" + song = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) + elsif desc_title == "ALBUM" + album = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) + elsif desc_title == "LICENSES" + music_license = extract_text(desc.dig?("infoRowRenderer", "expandedMetadata")) + end + end + music_list << VideoMusic.new(song.to_s, album.to_s, artist.to_s, music_license.to_s) + end + # 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 + author = video_details["author"]?.try &.as_s + ucid = video_details["channelId"]?.try &.as_s -private def convert_url(fmt) - if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) } - url = URI.parse(cfr["url"]) - params = url.query_params + 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"]?) - LOGGER.debug("convert_url: Decoding '#{cfr}'") - else - url = URI.parse(fmt["url"].as_s) - params = url.query_params + 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) }), + "isPostLiveDvr" => JSON::Any.new(post_live_dvr), + # Related videos + "relatedVideos" => JSON::Any.new(related), + # Description + "description" => JSON::Any.new(description || ""), + "descriptionHtml" => JSON::Any.new(description_html || "

"), + "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 || ""), + # Music section + "music" => JSON.parse(music_list.to_json), + # 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 - url.query_params = params - LOGGER.trace("convert_url: new url is '#{url}'") + private def convert_url(fmt) + if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) } + url = URI.parse(cfr["url"]) + params = url.query_params - return url.to_s -rescue ex - LOGGER.debug("convert_url: Error when parsing video URL") - LOGGER.trace(ex.inspect_with_backtrace) - return "" + LOGGER.debug("convert_url: Decoding '#{cfr}'") + else + url = URI.parse(fmt["url"].as_s) + params = url.query_params + end + + url.query_params = params + LOGGER.trace("convert_url: new url is '#{url}'") + + return url.to_s + rescue ex + LOGGER.debug("convert_url: Error when parsing video URL") + LOGGER.trace(ex.inspect_with_backtrace) + return "" + end end diff --git a/src/invidious/videos/video_preferences.cr b/src/invidious/videos/video_preferences.cr index 48177bd8..38870947 100644 --- a/src/invidious/videos/video_preferences.cr +++ b/src/invidious/videos/video_preferences.cr @@ -1,162 +1,166 @@ -struct VideoPreferences - include JSON::Serializable +module Invidious::Videos + extend self - property annotations : Bool - property preload : Bool - property autoplay : Bool - property comments : Array(String) - property continue : Bool - property continue_autoplay : Bool - property controls : Bool - property listen : Bool - property local : Bool - property preferred_captions : Array(String) - property player_style : String - property quality : String - property quality_dash : String - property raw : Bool - property region : String? - property related_videos : Bool - property speed : Float32 | Float64 - property video_end : Float64 | Int32 - property video_loop : Bool - property extend_desc : Bool - property video_start : Float64 | Int32 - property volume : Int32 - property vr_mode : Bool - property save_player_pos : Bool -end - -def process_video_params(query, preferences) - annotations = query["iv_load_policy"]?.try &.to_i? - preload = query["preload"]?.try { |q| (q == "true" || q == "1").to_unsafe } - autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } - comments = query["comments"]?.try &.split(",").map(&.downcase) - continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe } - continue_autoplay = query["continue_autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } - listen = query["listen"]?.try { |q| (q == "true" || q == "1").to_unsafe } - local = query["local"]?.try { |q| (q == "true" || q == "1").to_unsafe } - player_style = query["player_style"]? - preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase) - quality = query["quality"]? - quality_dash = query["quality_dash"]? - region = query["region"]? - related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe } - speed = query["speed"]?.try &.rchop("x").to_f? - video_loop = query["loop"]?.try { |q| (q == "true" || q == "1").to_unsafe } - extend_desc = query["extend_desc"]?.try { |q| (q == "true" || q == "1").to_unsafe } - volume = query["volume"]?.try &.to_i? - vr_mode = query["vr_mode"]?.try { |q| (q == "true" || q == "1").to_unsafe } - save_player_pos = query["save_player_pos"]?.try { |q| (q == "true" || q == "1").to_unsafe } - - if preferences - # region ||= preferences.region - annotations ||= preferences.annotations.to_unsafe - preload ||= preferences.preload.to_unsafe - autoplay ||= preferences.autoplay.to_unsafe - comments ||= preferences.comments - continue ||= preferences.continue.to_unsafe - continue_autoplay ||= preferences.continue_autoplay.to_unsafe - listen ||= preferences.listen.to_unsafe - local ||= preferences.local.to_unsafe - player_style ||= preferences.player_style - preferred_captions ||= preferences.captions - quality ||= preferences.quality - quality_dash ||= preferences.quality_dash - related_videos ||= preferences.related_videos.to_unsafe - speed ||= preferences.speed - video_loop ||= preferences.video_loop.to_unsafe - extend_desc ||= preferences.extend_desc.to_unsafe - volume ||= preferences.volume - vr_mode ||= preferences.vr_mode.to_unsafe - save_player_pos ||= preferences.save_player_pos.to_unsafe - end - - annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe - preload ||= CONFIG.default_user_preferences.preload.to_unsafe - autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe - comments ||= CONFIG.default_user_preferences.comments - continue ||= CONFIG.default_user_preferences.continue.to_unsafe - continue_autoplay ||= CONFIG.default_user_preferences.continue_autoplay.to_unsafe - listen ||= CONFIG.default_user_preferences.listen.to_unsafe - local ||= CONFIG.default_user_preferences.local.to_unsafe - player_style ||= CONFIG.default_user_preferences.player_style - preferred_captions ||= CONFIG.default_user_preferences.captions - quality ||= CONFIG.default_user_preferences.quality - quality_dash ||= CONFIG.default_user_preferences.quality_dash - related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe - speed ||= CONFIG.default_user_preferences.speed - video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe - extend_desc ||= CONFIG.default_user_preferences.extend_desc.to_unsafe - volume ||= CONFIG.default_user_preferences.volume - vr_mode ||= CONFIG.default_user_preferences.vr_mode.to_unsafe - save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe - - annotations = annotations == 1 - preload = preload == 1 - autoplay = autoplay == 1 - continue = continue == 1 - continue_autoplay = continue_autoplay == 1 - listen = listen == 1 - local = local == 1 - related_videos = related_videos == 1 - video_loop = video_loop == 1 - extend_desc = extend_desc == 1 - vr_mode = vr_mode == 1 - save_player_pos = save_player_pos == 1 - - if CONFIG.disabled?("dash") && quality == "dash" - quality = "high" - end - - if CONFIG.disabled?("local") && local - local = false - end - - if start = query["t"]? || query["time_continue"]? || query["start"]? - video_start = decode_time(start) - end - video_start ||= 0 - - if query["end"]? - video_end = decode_time(query["end"]) - end - video_end ||= -1 - - raw = query["raw"]?.try &.to_i? - raw ||= 0 - raw = raw == 1 - - controls = query["controls"]?.try &.to_i? - controls ||= 1 - controls = controls >= 1 - - params = VideoPreferences.new({ - annotations: annotations, - preload: preload, - autoplay: autoplay, - comments: comments, - continue: continue, - continue_autoplay: continue_autoplay, - controls: controls, - listen: listen, - local: local, - player_style: player_style, - preferred_captions: preferred_captions, - quality: quality, - quality_dash: quality_dash, - raw: raw, - region: region, - related_videos: related_videos, - speed: speed, - video_end: video_end, - video_loop: video_loop, - extend_desc: extend_desc, - video_start: video_start, - volume: volume, - vr_mode: vr_mode, - save_player_pos: save_player_pos, - }) - - return params + struct VideoPreferences + include JSON::Serializable + + property annotations : Bool + property preload : Bool + property autoplay : Bool + property comments : Array(String) + property continue : Bool + property continue_autoplay : Bool + property controls : Bool + property listen : Bool + property local : Bool + property preferred_captions : Array(String) + property player_style : String + property quality : String + property quality_dash : String + property raw : Bool + property region : String? + property related_videos : Bool + property speed : Float32 | Float64 + property video_end : Float64 | Int32 + property video_loop : Bool + property extend_desc : Bool + property video_start : Float64 | Int32 + property volume : Int32 + property vr_mode : Bool + property save_player_pos : Bool + end + + def process_video_params(query, preferences) + annotations = query["iv_load_policy"]?.try &.to_i? + preload = query["preload"]?.try { |q| (q == "true" || q == "1").to_unsafe } + autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } + comments = query["comments"]?.try &.split(",").map(&.downcase) + continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe } + continue_autoplay = query["continue_autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } + listen = query["listen"]?.try { |q| (q == "true" || q == "1").to_unsafe } + local = query["local"]?.try { |q| (q == "true" || q == "1").to_unsafe } + player_style = query["player_style"]? + preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase) + quality = query["quality"]? + quality_dash = query["quality_dash"]? + region = query["region"]? + related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe } + speed = query["speed"]?.try &.rchop("x").to_f? + video_loop = query["loop"]?.try { |q| (q == "true" || q == "1").to_unsafe } + extend_desc = query["extend_desc"]?.try { |q| (q == "true" || q == "1").to_unsafe } + volume = query["volume"]?.try &.to_i? + vr_mode = query["vr_mode"]?.try { |q| (q == "true" || q == "1").to_unsafe } + save_player_pos = query["save_player_pos"]?.try { |q| (q == "true" || q == "1").to_unsafe } + + if preferences + # region ||= preferences.region + annotations ||= preferences.annotations.to_unsafe + preload ||= preferences.preload.to_unsafe + autoplay ||= preferences.autoplay.to_unsafe + comments ||= preferences.comments + continue ||= preferences.continue.to_unsafe + continue_autoplay ||= preferences.continue_autoplay.to_unsafe + listen ||= preferences.listen.to_unsafe + local ||= preferences.local.to_unsafe + player_style ||= preferences.player_style + preferred_captions ||= preferences.captions + quality ||= preferences.quality + quality_dash ||= preferences.quality_dash + related_videos ||= preferences.related_videos.to_unsafe + speed ||= preferences.speed + video_loop ||= preferences.video_loop.to_unsafe + extend_desc ||= preferences.extend_desc.to_unsafe + volume ||= preferences.volume + vr_mode ||= preferences.vr_mode.to_unsafe + save_player_pos ||= preferences.save_player_pos.to_unsafe + end + + annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe + preload ||= CONFIG.default_user_preferences.preload.to_unsafe + autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe + comments ||= CONFIG.default_user_preferences.comments + continue ||= CONFIG.default_user_preferences.continue.to_unsafe + continue_autoplay ||= CONFIG.default_user_preferences.continue_autoplay.to_unsafe + listen ||= CONFIG.default_user_preferences.listen.to_unsafe + local ||= CONFIG.default_user_preferences.local.to_unsafe + player_style ||= CONFIG.default_user_preferences.player_style + preferred_captions ||= CONFIG.default_user_preferences.captions + quality ||= CONFIG.default_user_preferences.quality + quality_dash ||= CONFIG.default_user_preferences.quality_dash + related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe + speed ||= CONFIG.default_user_preferences.speed + video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe + extend_desc ||= CONFIG.default_user_preferences.extend_desc.to_unsafe + volume ||= CONFIG.default_user_preferences.volume + vr_mode ||= CONFIG.default_user_preferences.vr_mode.to_unsafe + save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe + + annotations = annotations == 1 + preload = preload == 1 + autoplay = autoplay == 1 + continue = continue == 1 + continue_autoplay = continue_autoplay == 1 + listen = listen == 1 + local = local == 1 + related_videos = related_videos == 1 + video_loop = video_loop == 1 + extend_desc = extend_desc == 1 + vr_mode = vr_mode == 1 + save_player_pos = save_player_pos == 1 + + if CONFIG.disabled?("dash") && quality == "dash" + quality = "high" + end + + if CONFIG.disabled?("local") && local + local = false + end + + if start = query["t"]? || query["time_continue"]? || query["start"]? + video_start = decode_time(start) + end + video_start ||= 0 + + if query["end"]? + video_end = decode_time(query["end"]) + end + video_end ||= -1 + + raw = query["raw"]?.try &.to_i? + raw ||= 0 + raw = raw == 1 + + controls = query["controls"]?.try &.to_i? + controls ||= 1 + controls = controls >= 1 + + params = VideoPreferences.new({ + annotations: annotations, + preload: preload, + autoplay: autoplay, + comments: comments, + continue: continue, + continue_autoplay: continue_autoplay, + controls: controls, + listen: listen, + local: local, + player_style: player_style, + preferred_captions: preferred_captions, + quality: quality, + quality_dash: quality_dash, + raw: raw, + region: region, + related_videos: related_videos, + speed: speed, + video_end: video_end, + video_loop: video_loop, + extend_desc: extend_desc, + video_start: video_start, + volume: volume, + vr_mode: vr_mode, + save_player_pos: save_player_pos, + }) + + return params + end end