diff --git a/spec/invidious/videos/regular_videos_extract_spec.cr b/spec/invidious/videos/regular_videos_extract_spec.cr index 02f5b3a5..614111d0 100644 --- a/spec/invidious/videos/regular_videos_extract_spec.cr +++ b/spec/invidious/videos/regular_videos_extract_spec.cr @@ -205,3 +205,19 @@ Spectator.describe "parse_video_info" do expect(info["relatedVideos"].as_a.size).to eq(20) end end + +Spectator.describe "Video error accessors" do + it "returns nil for a subreason stored as JSON null" do + # Some error paths store `subreason` as a JSON `null`; the accessor must + # not raise a TypeCastError on it. + info = { + "reason" => JSON::Any.new("Video unavailable"), + "subreason" => JSON::Any.new(nil), + } of String => JSON::Any + + video = Video.new({id: "dQw4w9WgXcQ", info: info, updated: Time.utc}) + + expect(video.reason).to eq("Video unavailable") + expect(video.subreason).to be_nil + end +end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index cfb3dd4e..21092d3d 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -179,11 +179,13 @@ struct Video end def reason : String? - info["reason"]?.try &.as_s + # Use `as_s?` so a JSON `null` (stored by some error paths) yields `nil` + # instead of raising a TypeCastError. + info["reason"]?.try &.as_s? end def subreason : String? - info["subreason"]?.try &.as_s + info["subreason"]?.try &.as_s? end def music : Array(VideoMusic) @@ -310,7 +312,7 @@ def get_video(id, refresh = true, region = nil, force_refresh = false) video.schema_version != Video::SCHEMA_VERSION # cache control begin video = fetch_video(id, region) - Invidious::Database::Videos.update(video) if CONFIG.invidious_companion.present? + Invidious::Database::Videos.update(video) rescue ex Invidious::Database::Videos.delete(id) raise ex @@ -318,7 +320,7 @@ def get_video(id, refresh = true, region = nil, force_refresh = false) end else video = fetch_video(id, region) - Invidious::Database::Videos.insert(video) if !region && CONFIG.invidious_companion.present? + Invidious::Database::Videos.insert(video) if !region end return video @@ -329,22 +331,10 @@ rescue DB::Error end def fetch_video(id, region) + # `extract_video_info` always returns a hash (it raises `NotFoundException` + # for genuinely unavailable videos), so there is no nil case to handle here. info = Invidious::Videos::Parser.extract_video_info(video_id: id) - if info.nil? - raise InfoException.new("Invidious companion is not available. \ - Video playback cannot continue. \ - If you are the administrator of this instance, install Invidious companion \ - following the installation instructions \ - https://docs.invidious.io/installation/") - end - - if reason = info["reason"]? - if reason == "Video unavailable" && !info["title"]? - raise NotFoundException.new(reason.as_s || "") - end - end - video = Video.new({ id: id, info: info, diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index db5c0594..40ef2362 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -55,6 +55,18 @@ module Invidious::Videos::Parser } end + # Builds the minimal "error" info hash returned when a video can't be + # played but we still want to render the watch page. `subreason` is only + # included when present, so it never gets stored as a JSON `null`. + private def self.error_video_info(reason : String?, subreason : String?) : Hash(String, JSON::Any) + params = { + "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), + } of String => JSON::Any + params["reason"] = JSON::Any.new(reason) if reason + params["subreason"] = JSON::Any.new(subreason) if subreason + params + end + def extract_video_info(video_id : String) # Fetch data from the player endpoint player_response = nil @@ -93,18 +105,21 @@ If you are the administrator, install Invidious Companion: \ reason ||= player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "reason", "simpleText").try &.as_s subreason_main = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason") - subreason = subreason_main.try &.[]?("simpleText").try &.as_s - subreason ||= subreason_main.try &.[]("runs").as_a.map(&.[]("text")).join("") + subreason = subreason_main.try &.["simpleText"]?.try &.as_s + subreason ||= subreason_main.try &.["runs"]?.try &.as_a.map(&.["text"]).join("") - # Stop here if video is not found, or private with no further data. - # For other statuses (UNPLAYABLE, etc.), continue to extract as much - # data as possible from the /next endpoint. - if reason == "Video unavailable" && playability_status != "UNPLAYABLE" - return { - "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), - "reason" => JSON::Any.new(reason), - "subreason" => JSON::Any.new(subreason), - } + # These strings come straight from YouTube and are rendered as raw HTML + # on the watch/embed pages, so escape them to avoid HTML injection. + reason = HTML.escape(reason) if reason + subreason = HTML.escape(subreason) if subreason + + # Stop here if the video is genuinely unavailable (deleted, removed or + # nonexistent): YouTube returns status "ERROR" for these regardless of + # the (localized) reason text, and there is nothing to extract from the + # /next endpoint. For other statuses (UNPLAYABLE, LOGIN_REQUIRED, + # LIVE_STREAM_OFFLINE, ...) we continue and extract as much as possible. + if playability_status == "ERROR" + raise NotFoundException.new(reason || "Video unavailable") end elsif video_id != player_response.dig?("videoDetails", "videoId") # YouTube may return a different video player response than expected. @@ -115,10 +130,7 @@ If you are the administrator, install Invidious Companion: \ # wrong video means that we should count it as a failure. Helpers.get_playback_statistic["totalRequests"] += 1 - 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."), - } + return error_video_info("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.", nil) else reason = nil end @@ -134,11 +146,7 @@ If you are the administrator, install Invidious Companion: \ # If we're in an error state and /next fails, return what we have if reason LOGGER.debug("extract_video_info: /next failed for #{video_id}: #{ex.message}") - return { - "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), - "reason" => JSON::Any.new(reason), - "subreason" => JSON::Any.new(subreason), - } + return error_video_info(reason, subreason) end raise ex end @@ -147,11 +155,7 @@ If you are the administrator, install Invidious Companion: \ params = self.parse_video_info(video_id, player_response) rescue ex : BrokenTubeException if reason - return { - "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), - "reason" => JSON::Any.new(reason), - "subreason" => JSON::Any.new(subreason), - } + return error_video_info(reason, subreason) end raise ex end @@ -257,16 +261,23 @@ If you are the administrator, install Invidious Companion: \ .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } if published.nil? + # Fall back to the human-readable date shown on the watch page (e.g. + # "Started streaming 3 hours ago" or "Premiered Jan 1, 2024"). published_txt = video_primary_renderer - .try &.dig?("dateText", "simpleText") + .try &.dig?("dateText", "simpleText").try &.as_s - if published_txt.try &.as_s.includes?("ago") && !published_txt.nil? - published = decode_date(published_txt.as_s.lchop("Started streaming ")) - elsif published_txt && published_txt.try &.as_s.matches?(/(\w{3} \d{1,2}, \d{4})$/) - published = Time.parse(published_txt.as_s.match!(/(\w{3} \d{1,2}, \d{4})$/)[0], "%b %-d, %Y", Time::Location::UTC) - else - published = Time.utc - end + date_regex = /(\w{3} \d{1,2}, \d{4})$/ + + published = + if published_txt.nil? + Time.utc + elsif published_txt.includes?("ago") + decode_date(published_txt.lchop("Started streaming ")) + elsif match = published_txt.match(date_regex) + Time.parse(match[0], "%b %-d, %Y", Time::Location::UTC) + else + Time.utc + end end premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp") @@ -295,12 +306,13 @@ If you are the administrator, install Invidious Companion: \ .try &.as_a.map &.as_s || [] of String allow_ratings = video_details["allowRatings"]?.try &.as_bool + # When the microformat is missing (e.g. /next-only data) we can't know for + # sure, so optimistically assume the video is family safe. family_friendly = microformat["isFamilySafe"]?.try &.as_bool - if family_friendly.nil? - family_friendly = true - end + family_friendly = true if family_friendly.nil? is_listed = video_details["isCrawlable"]?.try &.as_bool if is_listed.nil? + # Without videoDetails, infer from the "Unlisted" badge if present. if video_badges = video_primary_renderer.try &.dig?("badges") is_listed = !has_unlisted_badge?(video_badges) else diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index c2d16983..467cbbf9 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -463,7 +463,9 @@ module YoutubeAPI if CONFIG.invidious_companion.present? return self._post_invidious_companion("/youtubei/v1/player", data) else - return self._post_json("/youtubei/v1/player", data, nil) + # Without Invidious Companion the player endpoint is unavailable. + # Returning nil lets the caller fall back to the /next endpoint. + return nil end end