From 842b12a5515a8440d216c303206377cbfc6fb140 Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:58:11 +0200 Subject: [PATCH 01/12] Rebase onto master: graceful fallback to /next endpoint for major watch page data - Display watch page even when player endpoint fails, using data from /next - Show error message in player container placeholder (like YouTube does) - Extract title, description, views, likes, comments etc. from /next - Handle missing videoDetails gracefully with fallback to renderer data - Fix is_listed detection from video badges when videoDetails unavailable - Add subreason display for better error messages - Add has_unlisted_badge? helper function - Bump SCHEMA_VERSION to 4 - Integrate embed page with the same error handling - Guard against empty streams when video has errors - Fix JSON::Any.new(nil) crash for shortDescription - Add locale key: error_from_youtube_unplayable PR TODO status: [x] Integrate the embed page with the changes [x] Display the subreason on the watch page when there is an error [x] Fix description (nil safety) [ ] Add mocks for a video without videoDetails --- locales/en-US.json | 3 +- src/invidious/routes/watch.cr | 4 +- src/invidious/videos.cr | 14 +-- src/invidious/videos/parser.cr | 101 ++++++++++++++----- src/invidious/views/embed.ecr | 12 +++ src/invidious/views/watch.ecr | 18 +++- src/invidious/yt_backend/extractors_utils.cr | 17 ++++ 7 files changed, 134 insertions(+), 35 deletions(-) diff --git a/locales/en-US.json b/locales/en-US.json index d05c0d16..6c494847 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -508,5 +508,6 @@ "timeline_parse_error_placeholder_heading": "Unable to parse item", "timeline_parse_error_placeholder_message": "Invidious encountered an error while trying to parse this item. For more information see below:", "timeline_parse_error_show_technical_details": "Show technical details", - "dmca_content": "This video cannot be downloaded on this instance due to a DMCA/copyright infringement letter sent to the instance administrator." + "dmca_content": "This video cannot be downloaded on this instance due to a DMCA/copyright infringement letter sent to the instance administrator.", + "error_from_youtube_unplayable": "Video unplayable due to an error from YouTube:" } diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 7a68a145..cd839132 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -130,7 +130,7 @@ module Invidious::Routes::Watch audio_streams = video.audio_streams # Videos that are a premiere do not have audio streams. - if video.premiere_timestamp.nil? + if video.premiere_timestamp.nil? && video.reason.nil? # Older videos may not have audio sources available. # We redirect here so they're not unplayable if audio_streams.empty? && !video.live_now @@ -162,7 +162,7 @@ module Invidious::Routes::Watch thumbnail = "/vi/#{video.id}/maxres.jpg" - if params.raw + if params.raw && video.reason.nil? if params.listen url = audio_streams[0]["url"].as_s diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 99713d18..b73295e3 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -15,7 +15,7 @@ struct Video # NOTE: don't forget to bump this number if any change is made to # the `params` structure in videos/parser.cr!!! # - SCHEMA_VERSION = 3 + SCHEMA_VERSION = 4 property id : String @@ -182,6 +182,10 @@ struct Video info["reason"]?.try &.as_s end + def subreason : String? + info["subreason"]?.try &.as_s + end + def music : Array(VideoMusic) info["music"].as_a.map { |music_json| VideoMusic.new( @@ -314,7 +318,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 + Invidious::Database::Videos.insert(video) if !region && video.reason.nil? end return video @@ -336,12 +340,8 @@ def fetch_video(id, region) end if reason = info["reason"]? - if reason == "Video unavailable" + if reason == "Video unavailable" && !info["title"]? raise NotFoundException.new(reason.as_s || "") - elsif !reason.as_s.starts_with? "Premieres" - # dont error when it's a premiere. - # we already parsed most of the data and display the premiere date - raise InfoException.new(reason.as_s || "") end end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 914c5963..d1baf4f3 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -66,18 +66,21 @@ module Invidious::Videos::Parser 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 + reason = player_response.dig?("playabilityStatus", "reason").try &.as_s + reason ||= player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "reason", "simpleText").try &.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") + 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("") + + # 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" && !{"UNPLAYABLE"}.any?(playability_status) return { - "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), - "reason" => JSON::Any.new(reason), + "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), + "reason" => JSON::Any.new(reason), + "subreason" => JSON::Any.new(subreason), } end elsif video_id != player_response.dig?("videoDetails", "videoId") @@ -97,17 +100,40 @@ module Invidious::Videos::Parser reason = nil end - # 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 + # Fetch the /next endpoint for additional data. + begin + 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) + rescue ex + # 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), + } + end + raise ex + end - params = self.parse_video_info(video_id, player_response) + begin + 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), + } + end + raise ex + end params["reason"] = JSON::Any.new(reason) if reason + params["subreason"] = JSON::Any.new(subreason) if subreason {"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f| params[f] = player_response[f] if player_response[f]? @@ -177,16 +203,20 @@ module Invidious::Videos::Parser end video_details = player_response.dig?("videoDetails") + video_details ||= {} of String => JSON::Any 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 + title ||= extract_text( + video_primary_renderer + .try &.dig?("title") + ) + # 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). @@ -197,11 +227,24 @@ module Invidious::Videos::Parser views_txt ||= video_details["viewCount"]?.try &.as_s || "" views = views_txt.gsub(/\D/, "").to_i64? - length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"]) + 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 + .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } + + if published.nil? + published_txt = video_primary_renderer + .try &.dig?("dateText", "simpleText") + + 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 + end premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp") .try { |t| Time.parse_rfc3339(t.as_s) } @@ -228,7 +271,17 @@ module Invidious::Videos::Parser allow_ratings = video_details["allowRatings"]?.try &.as_bool family_friendly = microformat["isFamilySafe"]?.try &.as_bool + if family_friendly.nil? + family_friendly = true + end is_listed = video_details["isCrawlable"]?.try &.as_bool + if is_listed.nil? + if video_badges = video_primary_renderer.try &.dig?("badges") + is_listed = !has_unlisted_badge?(video_badges) + else + is_listed = true + end + end is_upcoming = video_details["isUpcoming"]?.try &.as_bool keywords = video_details["keywords"]? @@ -428,7 +481,7 @@ module Invidious::Videos::Parser # Description "description" => JSON::Any.new(description || ""), "descriptionHtml" => JSON::Any.new(description_html || "

"), - "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), + "shortDescription" => JSON::Any.new(short_description.try &.as_s || ""), # Video metadata "genre" => JSON::Any.new(genre.try &.as_s || ""), "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s?), diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr index 5551cd0a..efaed016 100644 --- a/src/invidious/views/embed.ecr +++ b/src/invidious/views/embed.ecr @@ -31,7 +31,19 @@ %> +<% if video.reason.nil? %> <%= rendered "components/player" %> +<% else %> +
+
+

+ <%= I18n.translate(preferences.locale, "error_from_youtube_unplayable") %>
+ <%= video.reason %> + <% if video.subreason %>
<%= video.subreason %><% end %> +

+
+
+<% end %> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 796c0c91..41914f0a 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -71,9 +71,21 @@ we're going to need to do it here in order to allow for translations. %> +<% if video.reason.nil? %>
<%= rendered "components/player" %>
+<% else %> +
+
+

+ <%= I18n.translate(locale, "error_from_youtube_unplayable") %>
+ <%= video.reason %> + <% if video.subreason %>
<%= video.subreason %><% end %> +

+
+
+<% end %>

@@ -97,8 +109,12 @@ we're going to need to do it here in order to allow for translations. <% if video.reason %>

- <%= video.reason %> + <%= I18n.translate(locale, "error_from_youtube_unplayable") %> <%= video.reason %>

+ <% if video.subreason %> +

<%= video.subreason %>

+ <% end %> + <%= error_redirect_helper(env) %> <% elsif video.premiere_timestamp.try &.> Time.utc %>

<%= video.premiere_timestamp.try { |t| I18n.translate(locale, "Premieres in `x`", recode_date((t - Time.utc).ago, locale)) } %> diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr index c83a2de5..d83b5764 100644 --- a/src/invidious/yt_backend/extractors_utils.cr +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -68,6 +68,23 @@ rescue ex return false end +def has_unlisted_badge?(badges : JSON::Any?) + return false if badges.nil? + + badges.as_a.each do |badge| + icon_type = badge.dig("metadataBadgeRenderer", "icon", "iconType").as_s + + return true if icon_type == "PRIVACY_UNLISTED" + end + + return false +rescue ex + LOGGER.debug("Unable to parse badges for unlisted check. Got exception: #{ex.message}") + LOGGER.trace("Badges data: #{badges.to_json}") + + return false +end + # This function extracts SearchVideo items from a Category. # Categories are commonly returned in search results and trending pages. def extract_category(category : Category) : Array(SearchVideo) From 537c7a86ceb469805c3139a65d8fc2086d9b727e Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:04:35 +0200 Subject: [PATCH 02/12] Fix duplicate error reason display on watch page - Keep error message only in player container placeholder (like YouTube does) - Show only error_redirect_helper links below the title, no duplicate text - Fixes visual duplication reported during review --- src/invidious/views/watch.ecr | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 41914f0a..0911fceb 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -108,12 +108,6 @@ we're going to need to do it here in order to allow for translations. <% end %> <% if video.reason %> -

- <%= I18n.translate(locale, "error_from_youtube_unplayable") %> <%= video.reason %> -

- <% if video.subreason %> -

<%= video.subreason %>

- <% end %> <%= error_redirect_helper(env) %> <% elsif video.premiere_timestamp.try &.> Time.utc %>

From d4a5593ab99a366acd4c55e5621d399846fd3ab6 Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:10:49 +0200 Subject: [PATCH 03/12] Fix indentation for crystal format compliance --- src/invidious/videos/parser.cr | 32 ++++++++++++++++---------------- src/invidious/views/watch.ecr | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index d1baf4f3..ddad7056 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -102,23 +102,23 @@ module Invidious::Videos::Parser # Fetch the /next endpoint for additional data. begin - 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) - rescue ex - # 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), - } - end - raise ex + 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) + rescue ex + # 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), + } end + raise ex + end begin params = self.parse_video_info(video_id, player_response) diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 0911fceb..07a3344b 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -76,7 +76,7 @@ we're going to need to do it here in order to allow for translations. <%= rendered "components/player" %>

<% else %> -
+

<%= I18n.translate(locale, "error_from_youtube_unplayable") %>
From f86bddd8b886940ff84d5c655345d8617dd6a969 Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:32:47 +0200 Subject: [PATCH 04/12] Make watch page work without Invidious Companion - player endpoint now uses direct _post_json when companion is absent - When player endpoint fails, fall back to /next-only data extraction - Watch page renders with title, description, comments from /next - Set reason when companion unavailable for graceful error display - Handle not-found videos correctly with 404 redirect - Disabled companion in config for dev testing --- src/invidious/videos/parser.cr | 25 +++++++++++++++++++++++-- src/invidious/yt_backend/youtube_api.cr | 2 +- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index ddad7056..1b3efd9d 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -57,10 +57,31 @@ module Invidious::Videos::Parser def extract_video_info(video_id : String) # Fetch data from the player endpoint - player_response = YoutubeAPI.player(video_id: video_id) + player_response = nil + begin + player_response = YoutubeAPI.player(video_id: video_id) + rescue ex + LOGGER.debug("extract_video_info: player endpoint failed for #{video_id}: #{ex.message}") + end if player_response.nil? - return nil + # Player endpoint failed (e.g. no companion). Try to get data from /next only. + begin + player_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) + rescue ex + LOGGER.debug("extract_video_info: /next also failed for #{video_id}: #{ex.message}") + raise NotFoundException.new("Video unavailable") + end + begin + params = self.parse_video_info(video_id, player_response) + params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64) + params["reason"] = JSON::Any.new("Video unavailable") + params["subreason"] = JSON::Any.new("Invidious Companion is not available. Video playback is not possible.") + return params + rescue ex + LOGGER.debug("extract_video_info: parse from /next failed for #{video_id}: #{ex.message}") + raise NotFoundException.new("Video unavailable") + end end playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 1783a542..c2d16983 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -463,7 +463,7 @@ module YoutubeAPI if CONFIG.invidious_companion.present? return self._post_invidious_companion("/youtubei/v1/player", data) else - return nil + return self._post_json("/youtubei/v1/player", data, nil) end end From 77259ad42c121080e90385543c29195b83ab6e0a Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:33:35 +0200 Subject: [PATCH 05/12] Add companion setup docs link for admins --- src/invidious/videos.cr | 2 +- src/invidious/videos/parser.cr | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index b73295e3..527730b3 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -318,7 +318,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 && video.reason.nil? + Invidious::Database::Videos.insert(video) if !region end return video diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 1b3efd9d..7072d91d 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -76,7 +76,9 @@ module Invidious::Videos::Parser params = self.parse_video_info(video_id, player_response) params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64) params["reason"] = JSON::Any.new("Video unavailable") - params["subreason"] = JSON::Any.new("Invidious Companion is not available. Video playback is not possible.") + params["subreason"] = JSON::Any.new("Invidious Companion is not available. Video playback is not possible. \ +If you are the administrator, install Invidious Companion: \ +https://docs.invidious.io/installation/") return params rescue ex LOGGER.debug("extract_video_info: parse from /next failed for #{video_id}: #{ex.message}") From 65e58e083cca471768afbee1fde17e3d572444f7 Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:35:10 +0200 Subject: [PATCH 06/12] Skip caching entirely when Invidious Companion is not configured When companion is absent, videos are always fetched fresh since they lack streaming data. Caching is only enabled when companion is present. --- src/invidious/videos.cr | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 527730b3..e843e3e5 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -310,7 +310,8 @@ 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) + Invidious::Database::Videos.update(video) if CONFIG.invidious_companion.present? +>>>>>>> 465a2cc6 (Skip caching entirely when Invidious Companion is not configured) rescue ex Invidious::Database::Videos.delete(id) raise ex @@ -318,7 +319,8 @@ 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 + Invidious::Database::Videos.insert(video) if !region && CONFIG.invidious_companion.present? +>>>>>>> 465a2cc6 (Skip caching entirely when Invidious Companion is not configured) end return video From 9ae7bb4ff6a253a7fafa205d10b0167b205ff727 Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:42:29 +0200 Subject: [PATCH 07/12] Fix missing channel name when falling back to /next-only data Use extract_text() for author name from videoOwnerRenderer, which handles both 'simpleText' and 'runs' formats. --- src/invidious/videos.cr | 2 -- src/invidious/videos/parser.cr | 3 +++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index e843e3e5..cfb3dd4e 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -311,7 +311,6 @@ def get_video(id, refresh = true, region = nil, force_refresh = false) begin video = fetch_video(id, region) Invidious::Database::Videos.update(video) if CONFIG.invidious_companion.present? ->>>>>>> 465a2cc6 (Skip caching entirely when Invidious Companion is not configured) rescue ex Invidious::Database::Videos.delete(id) raise ex @@ -320,7 +319,6 @@ def get_video(id, refresh = true, region = nil, force_refresh = false) else video = fetch_video(id, region) Invidious::Database::Videos.insert(video) if !region && CONFIG.invidious_companion.present? ->>>>>>> 465a2cc6 (Skip caching entirely when Invidious Companion is not configured) end return video diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 7072d91d..0468202d 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -470,6 +470,9 @@ If you are the administrator, install Invidious Companion: \ subs_text = author_info["subscriberCountText"]? .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") } .try &.as_s.split(" ", 2)[0] + + author ||= extract_text(author_info.dig?("title")) + ucid ||= author_info.dig?("title", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId").try &.as_s end # Return data From c3f16be5c4b0b6e04f95af8ffcd4cc2f7b2e4be6 Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:51:13 +0200 Subject: [PATCH 08/12] Move error message and action links before video title Error reason, subreason, and 'After which you should try to' links now appear above the

title, making them the first thing users see when a video fails to play. --- src/invidious/views/embed.ecr | 14 +++++--------- src/invidious/views/watch.ecr | 23 ++++++++++------------- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr index efaed016..6f793b1d 100644 --- a/src/invidious/views/embed.ecr +++ b/src/invidious/views/embed.ecr @@ -34,15 +34,11 @@ <% if video.reason.nil? %> <%= rendered "components/player" %> <% else %> -
-
-

- <%= I18n.translate(preferences.locale, "error_from_youtube_unplayable") %>
- <%= video.reason %> - <% if video.subreason %>
<%= video.subreason %><% end %> -

-
-
+

+ <%= I18n.translate(preferences.locale, "error_from_youtube_unplayable") %> + <%= video.reason %> + <% if video.subreason %>
<%= video.subreason %><% end %> +

<% end %> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 07a3344b..2bdeb2e0 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -75,19 +75,18 @@ we're going to need to do it here in order to allow for translations.
<%= rendered "components/player" %>
-<% else %> -
-
-

- <%= I18n.translate(locale, "error_from_youtube_unplayable") %>
- <%= video.reason %> - <% if video.subreason %>
<%= video.subreason %><% end %> -

-
-
<% end %>
+ <% if video.reason %> +

+ <%= I18n.translate(locale, "error_from_youtube_unplayable") %> <%= video.reason %> +

+ <% if video.subreason %> +

<%= video.subreason %>

+ <% end %> + <%= error_redirect_helper(env) %> + <% end %>

<%= title %> <% if params.listen %> @@ -107,9 +106,7 @@ we're going to need to do it here in order to allow for translations.

<% end %> - <% if video.reason %> - <%= error_redirect_helper(env) %> - <% elsif video.premiere_timestamp.try &.> Time.utc %> + <% if video.premiere_timestamp.try &.> Time.utc %>

<%= video.premiere_timestamp.try { |t| I18n.translate(locale, "Premieres in `x`", recode_date((t - Time.utc).ago, locale)) } %>

From c5eb4cee9d57f18997aefd7977c71c2b7e0fd154 Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:58:11 +0200 Subject: [PATCH 09/12] Add mock and test for video without videoDetails - New mock: no-videodetails.player.json (player response without videoDetails) - Test verifies fallback extraction from videoPrimaryInfoRenderer, videoSecondaryInfoRenderer, and videoOwnerRenderer when videoDetails is missing from the player response --- mocks | 2 +- .../videos/regular_videos_extract_spec.cr | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/mocks b/mocks index b55d58de..56219224 160000 --- a/mocks +++ b/mocks @@ -1 +1 @@ -Subproject commit b55d58dea94f7144ff0205857dfa70ec14eaa872 +Subproject commit 562192243e0348bd53bb85a5bf6d404109b21da0 diff --git a/spec/invidious/videos/regular_videos_extract_spec.cr b/spec/invidious/videos/regular_videos_extract_spec.cr index 00b85bd0..02f5b3a5 100644 --- a/spec/invidious/videos/regular_videos_extract_spec.cr +++ b/spec/invidious/videos/regular_videos_extract_spec.cr @@ -163,4 +163,45 @@ Spectator.describe "parse_video_info" do expect(info["authorVerified"].as_bool).to be_false expect(info["subCountText"].as_s).to eq("3.11K") end + + it "parses a video without videoDetails (fallback to /next data)" do + _player = load_mock("video/no-videodetails.player") + _next = load_mock("video/regular_no-description.next") + + raw_data = _player.merge!(_next) + info = Invidious::Videos::Parser.parse_video_info("no-videodetails-id", raw_data) + + expect(typeof(info)).to eq(Hash(String, JSON::Any)) + + # Title falls back to videoPrimaryInfoRenderer + expect(info["title"].as_s).to eq("Chris Rea - Auberge") + + # Views from videoPrimaryInfoRenderer + expect(info["views"].as_i).to eq(14_324_584) + + # Likes still work (from videoPrimaryInfoRenderer buttons) + expect(info["likes"].as_i).to eq(35_870) + + # Length from microformat + expect(info["lengthSeconds"].as_i).to eq(283_i64) + + # Published from microformat, or dateText fallback + expect(info["published"].as_s).to eq("2012-05-21T00:00:00Z") + + # Author from videoOwnerRenderer + expect(info["author"].as_s).to eq("ChrisReaVideos") + expect(info["ucid"].as_s).to eq("UC_5q6nWPbD30-y6oiWF_oNA") + + # Family friendly defaults to true when missing + expect(info["isFamilyFriendly"].as_bool).to be_true + + # isListed defaults to true when no badges + expect(info["isListed"].as_bool).to be_true + + # Description + expect(info["descriptionHtml"].as_s).to eq("") + + # Related videos still work from /next + expect(info["relatedVideos"].as_a.size).to eq(20) + end end From b80ac061b9b094de8ec07e326f20c50c1e7769a1 Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:02:41 +0200 Subject: [PATCH 10/12] Apply remaining review feedback from PR #5237 - Simplify has_unlisted_badge? to one-liner with any? + dig? - Add video_primary_renderer fallback for live_now detection - Simplify early return condition (playability_status != 'UNPLAYABLE') --- src/invidious/videos/parser.cr | 5 +++-- src/invidious/yt_backend/extractors_utils.cr | 17 +++-------------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 0468202d..72965c79 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -99,7 +99,7 @@ If you are the administrator, install Invidious Companion: \ # 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" && !{"UNPLAYABLE"}.any?(playability_status) + if reason == "Video unavailable" && playability_status != "UNPLAYABLE" return { "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), "reason" => JSON::Any.new(reason), @@ -282,7 +282,8 @@ If you are the administrator, install Invidious Companion: \ live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow") .try &.as_bool - live_now ||= video_details.dig?("isLive").try &.as_bool || false + live_now ||= video_details.dig?("isLive").try &.as_bool + live_now ||= video_primary_renderer.try &.dig?("viewCount", "videoViewCountRenderer", "isLive").try &.as_bool || false post_live_dvr = video_details.dig?("isPostLiveDvr") .try &.as_bool || false diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr index d83b5764..9c299a03 100644 --- a/src/invidious/yt_backend/extractors_utils.cr +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -69,20 +69,9 @@ rescue ex end def has_unlisted_badge?(badges : JSON::Any?) - return false if badges.nil? - - badges.as_a.each do |badge| - icon_type = badge.dig("metadataBadgeRenderer", "icon", "iconType").as_s - - return true if icon_type == "PRIVACY_UNLISTED" - end - - return false -rescue ex - LOGGER.debug("Unable to parse badges for unlisted check. Got exception: #{ex.message}") - LOGGER.trace("Badges data: #{badges.to_json}") - - return false + return badges.try &.as_a.any? { |badge| + badge.dig?("metadataBadgeRenderer", "icon", "iconType").try &.as_s == "PRIVACY_UNLISTED" + } || false end # This function extracts SearchVideo items from a Category. From 24182a28d538af96d646e737c14f1f25fdb507c2 Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:08:58 +0200 Subject: [PATCH 11/12] Fix live_now detection to not override false with nil via ||= ||= with false then nil resets live_now to nil in Crystal. Use explicit nil checks instead to preserve false values from microformat.isLiveNow for scheduled streams. --- src/invidious/videos/parser.cr | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 72965c79..db5c0594 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -282,8 +282,9 @@ If you are the administrator, install Invidious Companion: \ live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow") .try &.as_bool - live_now ||= video_details.dig?("isLive").try &.as_bool - live_now ||= video_primary_renderer.try &.dig?("viewCount", "videoViewCountRenderer", "isLive").try &.as_bool || false + live_now = video_details.dig?("isLive").try &.as_bool if live_now.nil? + live_now = video_primary_renderer.try &.dig?("viewCount", "videoViewCountRenderer", "isLive").try &.as_bool if live_now.nil? + live_now ||= false post_live_dvr = video_details.dig?("isPostLiveDvr") .try &.as_bool || false From 8864b41b0f4d19b3c90c049954033f3f4e54237e Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:35:51 +0200 Subject: [PATCH 12/12] Harden /next fallback: fix null subreason, escaping, caching Review fixes on top of the rebased /next-fallback work: - Fix TypeCastError when subreason is stored as JSON null: accessors use as_s?, and error returns go through a helper that omits nil subreason. - Restore graceful no-companion path by reverting player() to return nil, and drop the now-dead info.nil? branch in fetch_video. - Re-enable video caching when Invidious Companion is not configured. - Detect genuinely unavailable videos via playabilityStatus == "ERROR" instead of the localized "Video unavailable" string. - HTML-escape reason/subreason coming from YouTube to avoid HTML injection, and guard against a missing "runs" key. - Tidy published-date fallback and add a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../videos/regular_videos_extract_spec.cr | 16 ++++ src/invidious/videos.cr | 26 ++---- src/invidious/videos/parser.cr | 84 +++++++++++-------- src/invidious/yt_backend/youtube_api.cr | 4 +- 4 files changed, 75 insertions(+), 55 deletions(-) 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