From 6659cbbbd8df1455ae7fb3b7f02b1dd7c0f3fe1c Mon Sep 17 00:00:00 2001 From: Fijxu Date: Tue, 26 May 2026 17:46:32 -0400 Subject: [PATCH 01/16] fix: fix channel videos and playlists on searches (#5736) * fix: fix channel videos and playlists on searches Channel videos are now encapsulated in a `lockupViewModel`. There is 3 types of content that can be inside a `lockupViewModel`: - LOCKUP_CONTENT_TYPE_VIDEO - LOCKUP_CONTENT_TYPE_PLAYLIST - LOCKUP_CONTENT_TYPE_PODCAST This commit parses `LOCKUP_CONTENT_TYPE_VIDEO`, `LOCKUP_CONTENT_TYPE_PLAYLIST`, `LOCKUP_CONTENT_TYPE_PODCAST` types to fix videos in channels, playlists in channels, podcast in channels, and other parts of Invidious were playlists and videos are displayed. * remove unused variable `author_verified` * fix parsing for podcasts For some reason, Podcasts contains an empty JSON Object that we have to skip, therefore we just iterate metadataRows until finding metadataParts since metadataRows will not always contain a single Object. * fix length_seconds for channel videos * fix playlists parsing for playlists without metadataParts On some channels like MrBeast, metadataParts is absent, missing the author information, this is intended behaviour by Youtube since there is no author information attached to them. Example URL: https://www.youtube.com/channel/UCX6OQ3DkcsbYNE6H8uQQuVA/playlists * restore author_verified functionality * more robust metadata_parts parsing Videos that have two or more authors (in collaborations), have their author information in JSON Objects inside metadataParts, alongside with their view count and their published date, therefore, we need to iterate the metadataParts array and do some filtering based on the content of each JSON Object to find the view count and published date for some videos. Example: ```json "metadataParts": [ { "text": { "content": "Veritasium" }, "icon": { "name": "CHECK_CIRCLE_FILLED", "height": 14, "width": 14, "accessibilityLabel": "Verified" } }, { "text": { "content": "and Linus Tech Tips" }, "icon": { "name": "CHECK_CIRCLE_FILLED", "height": 14, "width": 14, "accessibilityLabel": "Verified" } }, { "text": { "content": "10M" }, "accessibilityLabel": "10 million views" }, { "text": { "content": "1y ago" } } ] ``` * improve playlist metadataRows and metadataParts parsing for channel playlists * apply ameba suggestion * Also parse lessons for playlists that are a course --- src/invidious/yt_backend/extractors.cr | 218 ++++++++++++++++++++----- 1 file changed, 173 insertions(+), 45 deletions(-) diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 38c869e4..b2226e74 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -630,13 +630,14 @@ private module Parsers end end - # Parses an InnerTube lockupViewModel into a SearchPlaylist. + # Parses an InnerTube lockupViewModel into a SearchPlaylist or a SearchVideo # Returns nil when the given object is not a lockupViewModel. # # This structure is present since November 2024 on the "podcasts" and # "playlists" tabs of the channel page. It is usually encapsulated in either # a richItemRenderer or a richGridRenderer. # + # Since 2026-05-21, now channel videos are encapsulated in a lockupViewModel. module LockupViewModelParser extend self include BaseParser @@ -648,58 +649,185 @@ private module Parsers end private def parse_internal(item_contents, author_fallback) - playlist_id = item_contents["contentId"].as_s + content_type = item_contents["contentType"].as_s - thumbnail_view_model = item_contents.dig( - "contentImage", "thumbnailViewModel" - ) + if content_type == "LOCKUP_CONTENT_TYPE_VIDEO" + thumbnail_view_model = item_contents.dig( + "contentImage", "thumbnailViewModel" + ) + thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s - thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s + video_id = item_contents["contentId"].as_s - # This complicated sequences tries to extract the following data structure: - # "overlays": [{ - # "thumbnailOverlayBadgeViewModel": { - # "thumbnailBadges": [{ - # "thumbnailBadgeViewModel": { - # "text": "430 episodes", - # "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT" - # } - # }] - # } - # }] - # - # NOTE: this simplistic `.to_i` conversion might not work on larger - # playlists and hasn't been tested. - video_count = thumbnail_view_model.dig("overlays").as_a - .compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a) - .flatten - .find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node| - {"episodes", "videos"}.any? { |str| node.as_s.ends_with?(str) } + metadata = item_contents.dig("metadata", "lockupMetadataViewModel") + title = metadata.dig("title", "content").as_s + # Contains the views of the video and the published time of the video. + metadata_parts = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows", 0, "metadataParts").try &.as_a + + view_count_text = metadata_parts.try &.find { |item| item["icon"]?.nil? && item.dig?("text", "content").try &.as_s.includes?("views") } + .try &.dig("text", "content").as_s + published = metadata_parts.try &.find { |item| item["icon"]?.nil? && item.dig?("text", "content").try &.as_s.includes?("ago") } + .try { |item| decode_date(item.dig("text", "content").as_s) } || Time.local + + view_count = short_text_to_number(view_count_text || "0") + + length = thumbnail_view_model.dig("overlays", 0, "thumbnailBottomOverlayViewModel", "badges", 0, "thumbnailBadgeViewModel", "text").try &.as_s + + length_seconds = decode_length_seconds(length) if length + + return SearchVideo.new({ + title: title, + id: video_id, + author: author_fallback.name, + ucid: author_fallback.id, + published: published, + views: view_count, + description_html: "", + length_seconds: length_seconds || 0, + premiere_timestamp: Time.unix(0), + author_verified: false, + author_thumbnail: nil, + badges: VideoBadges::None, }) - .try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false) + # If it's a playlist, it's content_type would be "LOCKUP_CONTENT_TYPE_PLAYLIST" + # If it's a podcast, it's content_type would be "LOCKUP_CONTENT_TYPE_PODCAST" + # Playlist and Podcasts structures are quite similar, so we can use the same logic + # we use to parse Playlists data, for Podcasts. + else + thumbnail_view_model = item_contents.dig( + "contentImage", "collectionThumbnailViewModel", + "primaryThumbnail", "thumbnailViewModel" + ) + thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s - metadata = item_contents.dig("metadata", "lockupMetadataViewModel") - title = metadata.dig("title", "content").as_s + playlist_id = item_contents["contentId"].as_s - # TODO: Retrieve "updated" info from metadata parts - # rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").as_a - # parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s) - # One of these parts should contain a string like: "Updated 2 days ago" + # This complicated sequences tries to extract the following data structure: + # + # "overlays": [ + # { + # "thumbnailOverlayBadgeViewModel": { + # "thumbnailBadges": [ + # { + # "thumbnailBadgeViewModel": { + # "icon": { + # "sources": [ + # { + # "clientResource": { + # "imageName": "BROADCAST" + # } + # } + # ] + # }, + # "text": "5 episodes", + # "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT", + # "backgroundColor": { + # "lightTheme": 991526, + # "darkTheme": 991526 + # } + # } + # } + # ], + # "position": "THUMBNAIL_OVERLAY_BADGE_POSITION_BOTTOM_END" + # } + # }, + # ... <-- There is another item bellow the Object we use to extract episodes/videos + # ] + # + # NOTE: this simplistic `.to_i` conversion might not work on larger + # playlists and hasn't been tested. + video_count = thumbnail_view_model.dig("overlays").as_a + .compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a) + .flatten + .find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node| + {"episodes", "videos", "lessons"}.any? { |str| node.as_s.ends_with?(str) } + }) + .try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false) - # TODO: Maybe add a button to access the first video of the playlist? - # item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint") - # Available fields: "videoId", "playlistId", "params" + metadata = item_contents.dig("metadata", "lockupMetadataViewModel") + title = metadata.dig("title", "content").as_s - return SearchPlaylist.new({ - title: title, - id: playlist_id, - author: author_fallback.name, - ucid: author_fallback.id, - video_count: video_count || -1, - videos: [] of SearchPlaylistVideo, - thumbnail: thumbnail, - author_verified: false, - }) + # metadataParts is not always in the first place of the metadataRows array, therefore, + # we search for it iterating the array. We have only seen metadataRows with at least + # 2 items inside it. + # + # It looks like this: + # "metadataRows": [ + # {}, <-- empty Object + # { + # "metadataParts": [ ... ] <-- metadataParts with the information we are searching for. + # } + # ] + # + # Playlist on channels also contain metadataRows, but not with the type of data we are searching + # for which are the channel name and channel ID, instead they have two fields depending of the playlist + # updated date: + # + # It looks like this: + # "metadataRows": [ + # { + # "metadataParts": [ + # { + # "text": { + # "content": "Updated 4 days ago" + # } + # } <-- This object is missing if the playlist has not been updated in around 7 + # days + # ] + # }, + # { + # "metadataParts": [ + # { + # "text": { + # "content": "View full playlist", + # "commandRuns": [ ... ], + # "styleRuns": [ ... ]. + # } + # } <-- This object is always present, so we use this to determine if the + # metadataParts can be used or not. + # ] + # } + # ] + # + metadata_rows = metadata.dig?("metadata", "contentMetadataViewModel", "metadataRows").try &.as_a + metadata_parts = metadata_rows.try &.find { |row| + parts = row["metadataParts"]?.try &.as_a + parts && !parts.any? { |item| item.dig?("text", "content").try &.as_s == "View full playlist" } + }.try &.["metadataParts"].as_a + + if author_info = metadata_parts.try &.find(&.dig?("text", "commandRuns")) + .try &.["text"] + author = author_info["content"].as_s + author_id = author_info.dig?("commandRuns", 0, "onTap", "innertubeCommand", "browseEndpoint", "browseId") + .try &.as_s || author_fallback.id + author_verified = (author_info.dig?("attachmentRuns", 0, "element", "type", "imageType", "image", "sources", 0, "clientResource", "imageName") + .try &.as_s) == "CHECK_CIRCLE_FILLED" || false + else + author = author_fallback.name + author_id = author_fallback.id + author_verified = false + end + + # TODO: Retrieve "updated" info from metadata parts + # rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").as_a + # parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s) + # One of these parts should contain a string like: "Updated 2 days ago" + + # TODO: Maybe add a button to access the first video of the playlist? + # item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint") + # Available fields: "videoId", "playlistId", "params" + + return SearchPlaylist.new({ + title: title, + id: playlist_id, + author: author, + ucid: author_id, + video_count: video_count || -1, + videos: [] of SearchPlaylistVideo, + thumbnail: thumbnail, + author_verified: author_verified, + }) + end end def self.parser_name From edb3a0fc360914c705bb749f27d05f2323b7782e Mon Sep 17 00:00:00 2001 From: Fijxu Date: Tue, 26 May 2026 18:13:51 -0400 Subject: [PATCH 02/16] Add support for `/pl_c` and `/tvfilm_banner` paths (thumbnails used in some playlists) (#5742) * feat: add support for /pl_c/ images This path can be found on Podcast images. * add support for /pl_c and /tvfilm_banner paths * add support for /pl_c and /tvfilm_banner paths 2 * remove leftover comment --- src/invidious/routes/images.cr | 14 ++++++++++++-- src/invidious/routing.cr | 2 ++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr index c06955c0..d76bcc58 100644 --- a/src/invidious/routes/images.cr +++ b/src/invidious/routes/images.cr @@ -51,7 +51,7 @@ module Invidious::Routes::Images end # ??? maybe also for storyboards? - def self.s_p_image(env) + def self.s_p_image(env, authority = "i9") id = env.params.url["id"] name = env.params.url["name"] url = env.request.resource @@ -65,13 +65,23 @@ module Invidious::Routes::Images end begin - get_ytimg_pool("i9").client &.get(url, headers) do |resp| + get_ytimg_pool(authority).client &.get(url, headers) do |resp| return self.proxy_image(env, resp) end rescue ex end end + # Both pl_c and tvfilm_banner use the same logic used in s_p_image(env) + # just with a different authority ("i"). + def self.pl_c_image(env) + self.s_p_image(env, "i") + end + + def self.tvfilm_banner_image(env) + self.s_p_image(env, "i") + end + def self.yts_image(env) headers = HTTP::Headers.new REQUEST_HEADERS_WHITELIST.each do |header| diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 32e8554c..a4ed1f57 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -222,6 +222,8 @@ module Invidious::Routing get "/s_p/:id/:name", Routes::Images, :s_p_image get "/yts/img/:name", Routes::Images, :yts_image get "/vi/:id/:name", Routes::Images, :thumbnails + get "/pl_c/:id/:name", Routes::Images, :pl_c_image + get "/tvfilm_banner/:id/:name", Routes::Images, :tvfilm_banner_image end def register_companion_routes From 8b183caa2aca5128a8c3a409bd40af35fd356f6c Mon Sep 17 00:00:00 2001 From: Fijxu Date: Thu, 28 May 2026 13:10:58 -0400 Subject: [PATCH 03/16] fix: fix author verification in channels (#5751) * fix author verification in channels Fixes https://github.com/iv-org/invidious/issues/5730 Author verification badge is not longer located in `c4TabbedHeaderRenderer`. It may be deprecated. * Also detect AUDIO_BADGE for verified author --- src/invidious/channels/about.cr | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 537aa034..bb55147b 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -83,11 +83,16 @@ def get_about_info(ucid, locale) : AboutChannel author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s - author_verified = has_verified_badge?(initdata.dig?("header", "c4TabbedHeaderRenderer", "badges")) - + author_badge = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "title", "dynamicTextViewModel", "text", "attachmentRuns", 0, "element", "type", "imageType", "image", "sources", 0, "clientResource", "imageName") + .try &.as_s + # CHECK_CIRCLE_FILLED is used for normal channels and AUDIO_BADGE if used For + # music/artist channels + # TODO: Maybe separate verified author from verified artist? + author_verified = author_badge.try { |badge| badge == "CHECK_CIRCLE_FILLED" || badge == "AUDIO_BADGE" } || false ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s # Raises a KeyError on failure. + # TODO: Check if `c4TabbedHeaderRenderer` still exists on some channels. banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? banners ||= initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "banner", "imageBannerViewModel", "image", "sources") banner = banners.try &.[-1]?.try &.["url"].as_s? From 4ae227ce914b01c36b1670227ebfc7f4c56045a6 Mon Sep 17 00:00:00 2001 From: Fijxu Date: Thu, 28 May 2026 13:11:41 -0400 Subject: [PATCH 04/16] 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 From 86c425b43f75dd1f22034bbfbd99476651661348 Mon Sep 17 00:00:00 2001 From: shiny-comic Date: Fri, 29 May 2026 05:51:06 +0900 Subject: [PATCH 05/16] Fix disappearing end of the comments with emoji (#5587) Previous code use UTF-8 to count characters however Emojis are UTF-16 units. This difference leads to misalignment of index offsets. Co-authored-by: shiny-comic --- src/invidious/videos/description.cr | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr index 1371bebb..18b4122e 100644 --- a/src/invidious/videos/description.cr +++ b/src/invidious/videos/description.cr @@ -21,8 +21,6 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I str << cp.chr end - # A codepoint from the SMP counts twice - copied += 1 if cp > 0xFFFF copied += 1 end @@ -44,10 +42,6 @@ def parse_description(desc, video_id : String) : String? end end - # Not everything is stored in UTF-8 on youtube's side. The SMP codepoints - # (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are - # automatically decoded by the JSON parser. It means that we need to count - # copied byte in a special manner, preventing the use of regular string copy. iter = content.each_codepoint index = 0 From 8ef5ea03d45cb3bb2a7e77ff5e5c1e918f4f31a8 Mon Sep 17 00:00:00 2001 From: Fijxu Date: Fri, 29 May 2026 23:48:11 -0400 Subject: [PATCH 06/16] feat: Add support for POST requests on searches for privacy (#5551) Use already set preferences variable Use span for description, grammar fix from Copilot --- assets/css/default.css | 7 +++++++ locales/en-US.json | 2 ++ src/invidious/config.cr | 1 + src/invidious/routes/preferences.cr | 5 +++++ src/invidious/routes/search.cr | 11 +++++++++-- src/invidious/routing.cr | 1 + src/invidious/user/preferences.cr | 1 + src/invidious/views/components/search_box.ecr | 8 ++++++++ src/invidious/views/search.ecr | 7 ++++++- src/invidious/views/user/preferences.ecr | 6 ++++++ 10 files changed, 46 insertions(+), 3 deletions(-) diff --git a/assets/css/default.css b/assets/css/default.css index ff07bdb4..726a4c37 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -902,4 +902,11 @@ h1, h2, h3, h4, h5, p, .error-issue-template { padding: 20px; background: rgba(0, 0, 0, 0.12345); +} + +.preference-description { + width: 250px; + padding-left: 10px; + display: inline-block; + vertical-align: top; } \ No newline at end of file diff --git a/locales/en-US.json b/locales/en-US.json index 5b2ef8d0..d05c0d16 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -124,6 +124,8 @@ "preferences_sort_label": "Sort videos by: ", "preferences_default_playlist": "Default playlist: ", "preferences_default_playlist_none": "No default playlist set", + "preferences_search_privacy_label": "Search privacy: ", + "preferences_search_privacy_description": "Enabling this preference will prevent your search queries from being saved in your browser history.", "published": "published", "published - reverse": "published - reverse", "alphabetically": "alphabetically", diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 7853d9a3..914d828c 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -54,6 +54,7 @@ struct ConfigPreferences property save_player_pos : Bool = false @[YAML::Field(ignore: true)] property default_playlist : String? = nil + property search_privacy : Bool = false def to_tuple {% begin %} diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index d9fad1b1..a2108b9b 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -145,6 +145,10 @@ module Invidious::Routes::PreferencesRoute default_playlist = env.params.body["default_playlist"]?.try &.as(String) + search_privacy = env.params.body["search_privacy"]?.try &.as(String) + search_privacy ||= "off" + search_privacy = search_privacy == "on" + # Convert to JSON and back again to take advantage of converters used for compatibility preferences = Preferences.from_json({ annotations: annotations, @@ -182,6 +186,7 @@ module Invidious::Routes::PreferencesRoute show_nick: show_nick, save_player_pos: save_player_pos, default_playlist: default_playlist, + search_privacy: search_privacy, }.to_json) if user = env.get? "user" diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 11e6f171..968a5554 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -40,9 +40,16 @@ module Invidious::Routes::Search preferences = env.get("preferences").as(Preferences) locale = preferences.locale - region = env.params.query["region"]? || preferences.region + uri_params = URI::Params.new + if env.request.method == "GET" + uri_params = env.params.query + else + uri_params = env.params.body + end - query = Invidious::Search::Query.new(env.params.query, :regular, region) + region = uri_params["region"]? || preferences.region + + query = Invidious::Search::Query.new(uri_params, :regular, region) if query.empty? # Display the full page search box implemented in #1977 diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index a4ed1f57..12bf5459 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -185,6 +185,7 @@ module Invidious::Routing get "/opensearch.xml", Routes::Search, :opensearch get "/results", Routes::Search, :results get "/search", Routes::Search, :search + post "/search", Routes::Search, :search get "/hashtag/:hashtag", Routes::Search, :hashtag end diff --git a/src/invidious/user/preferences.cr b/src/invidious/user/preferences.cr index df195dd6..ad4a9bc7 100644 --- a/src/invidious/user/preferences.cr +++ b/src/invidious/user/preferences.cr @@ -57,6 +57,7 @@ struct Preferences property volume : Int32 = CONFIG.default_user_preferences.volume property save_player_pos : Bool = CONFIG.default_user_preferences.save_player_pos property default_playlist : String? = nil + property search_privacy : Bool = CONFIG.default_user_preferences.search_privacy module BoolToString def self.to_json(value : String, json : JSON::Builder) diff --git a/src/invidious/views/components/search_box.ecr b/src/invidious/views/components/search_box.ecr index f957c25c..1a14475a 100644 --- a/src/invidious/views/components/search_box.ecr +++ b/src/invidious/views/components/search_box.ecr @@ -1,4 +1,12 @@ +<% + search_privacy = preferences.search_privacy +%> + +<% if search_privacy %> +
+<% else %> +<% end %>
autofocus<% end %> diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index 2ffe27a1..ff0ced66 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -1,5 +1,10 @@ +<% + search_privacy = preferences.search_privacy + search_query = query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "…" : HTML.escape(query.text) +%> + <% content_for "header" do %> -<%= query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "…" : HTML.escape(query.text) %> - Invidious +<%= search_privacy ? "Search" : search_query %> - Invidious <% end %> diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr index 703423c0..aa2e0a7b 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -221,6 +221,12 @@ checked<% end %>> +
+ + checked<% end %>> + <%= translate(locale, "preferences_search_privacy_description") %> +
+ <% if env.get? "user" %> <%= I18n.translate(locale, "preferences_category_subscription") %> From 3a35552a667a0a65ce7d1a99b90f75b365d950d7 Mon Sep 17 00:00:00 2001 From: Fijxu Date: Fri, 29 May 2026 23:51:34 -0400 Subject: [PATCH 07/16] fix: fix wrong call to I18n.translate() function --- src/invidious/views/user/preferences.ecr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr index aa2e0a7b..d6692081 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -222,9 +222,9 @@
- + checked<% end %>> - <%= translate(locale, "preferences_search_privacy_description") %> + <%= I18n.translate(locale, "preferences_search_privacy_description") %>
<% if env.get? "user" %> From 1a5a71b086c92f16e9c1df74b834c80c7e41e42c Mon Sep 17 00:00:00 2001 From: Fijxu Date: Fri, 29 May 2026 23:54:08 -0400 Subject: [PATCH 08/16] Fix Youtube and Invidious links not rewinding their time when video timestamp is rewinded (#5601) Revert "Fix Youtube and Invidious links not rewinding their time when video timestamp is rewinded" This reverts commit c3ee8a5d90df83d6e53b78b6d6e9669729b391aa. --- assets/js/player.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/player.js b/assets/js/player.js index 455b557c..805ae6ef 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -133,7 +133,7 @@ var timeupdate_last_ts = 5; player.on('timeupdate', function () { // Only update once every second let current_ts = Math.floor(player.currentTime()); - if (current_ts > timeupdate_last_ts) timeupdate_last_ts = current_ts; + if (current_ts != timeupdate_last_ts) timeupdate_last_ts = current_ts; else return; // YouTube links From e96ad036ca7f69edbf0936922afb15e9c32f642e Mon Sep 17 00:00:00 2001 From: Fijxu Date: Sat, 30 May 2026 15:49:21 -0400 Subject: [PATCH 09/16] fix: Do not append query params `quality=medium` to videos that are about to premiere (#5755) * fix: Do not append query params `quality=medium` to videos that are about to premiere Video premieres do not have `audio_streams` and they aren't `video.live_now == true` either, therefore we skip the checks that are made for older videos (`if audio_streams.empty? && !video.live_now` closure) if the video is a premiere. This commit also adds the `microformat` field to the `info` instance variable of the `Video` struct, since it's needed for the function `Video#premiere_timestamp : Time?`. This is more like a quick fix than a proper fix because in `Invidious::Videos::Parser#parse_video_info`, `premiere_timestamp` is gathered from the `microformat` JSON Object but is used to set the `published` hash key for the `params` variable. The parsers and structs really need a rework :/ * Revert "fix: Do not append query params `quality=medium` to videos that are about to premiere" This reverts commit 5cfc8dace82590ea6a41c849c9c39b1b7ba95fe7. * chore: build premiere_timestamp using video_type and published time --- src/invidious/routes/watch.cr | 25 ++++++++++++++----------- src/invidious/videos.cr | 7 ++++--- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 8f244e92..7a68a145 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -129,17 +129,20 @@ module Invidious::Routes::Watch video_streams = video.video_streams audio_streams = video.audio_streams - # Older videos may not have audio sources available. - # We redirect here so they're not unplayable - if audio_streams.empty? && !video.live_now - if params.quality == "dash" - env.params.query.delete_all("quality") - env.params.query["quality"] = "medium" - return env.redirect "/watch?#{env.params.query}" - elsif params.listen - env.params.query.delete_all("listen") - env.params.query["listen"] = "0" - return env.redirect "/watch?#{env.params.query}" + # Videos that are a premiere do not have audio streams. + if video.premiere_timestamp.nil? + # Older videos may not have audio sources available. + # We redirect here so they're not unplayable + if audio_streams.empty? && !video.live_now + if params.quality == "dash" + env.params.query.delete_all("quality") + env.params.query["quality"] = "medium" + return env.redirect "/watch?#{env.params.query}" + elsif params.listen + env.params.query.delete_all("listen") + env.params.query["listen"] = "0" + return env.redirect "/watch?#{env.params.query}" + end end end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 5b778725..99713d18 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -81,9 +81,10 @@ struct Video end def premiere_timestamp : Time? - info - .dig?("microformat", "playerMicroformatRenderer", "liveBroadcastDetails", "startTimestamp") - .try { |t| Time.parse_rfc3339(t.as_s) } + if self.video_type == VideoType::Scheduled + return info["published"]? + .try { |t| Time.parse_rfc3339(t.as_s) } + end end def related_videos From 0e0ee40cb6e5c88679666544dc8f19cb956b028b Mon Sep 17 00:00:00 2001 From: Fijxu Date: Sat, 30 May 2026 17:58:49 -0400 Subject: [PATCH 10/16] Dockerfile: Switch to 84codes crystal compiler container image (#5473) Closes https://github.com/iv-org/invidious/issues/5456 The 84codes Crystal container image builds libgc (bdwgc) with `--enable-large-config`: https://github.com/84codes/crystal-packages/blob/b321bb4358b0140a63573d9d05ccf2f06c5ae040/alpine/Dockerfile#L13-L22 --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 0822aafe..f837dae9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,7 +2,7 @@ ARG OPENSSL_VERSION='3.6.2' ARG OPENSSL_SHA256='aaf51a1fe064384f811daeaeb4ec4dce7340ec8bd893027eee676af31e83a04f' -FROM crystallang/crystal:1.20.2-alpine AS dependabot-crystal +FROM 84codes/crystal:1.20.2-alpine AS dependabot-crystal # We compile openssl ourselves due to a memory leak in how crystal interacts # with openssl From 85534a988d98f083107444c67b70537431812265 Mon Sep 17 00:00:00 2001 From: Fijxu Date: Wed, 3 Jun 2026 18:18:57 -0400 Subject: [PATCH 11/16] Only include '&' if params are present (#5646) --- src/invidious/routes/feeds.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index a8247d78..b214c991 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -320,7 +320,7 @@ module Invidious::Routes::Feeds case attribute.name when "url", "href" request_target = URI.parse(node[attribute.name]).request_target - query_string_opt = request_target.starts_with?("/watch?v=") ? "&#{params}" : "" + query_string_opt = request_target.starts_with?("/watch?v=") ? ("&#{params}" if !params.empty?) : "" node[attribute.name] = "#{HOST_URL}#{request_target}#{query_string_opt}" else nil # Skip end From 98f4f118b2e4cfeb77bd65a76df9b88ee55c33a6 Mon Sep 17 00:00:00 2001 From: Fijxu Date: Thu, 4 Jun 2026 19:59:32 -0400 Subject: [PATCH 12/16] Add support for alternative domains for Invidious cookies (#5647) * Add support for alternative domains for Invidious cookies * check if @@secure is already true before changing it's value * Add alternative_domains config option example --- config/config.example.yml | 20 ++++++++++++++++++++ src/invidious/config.cr | 2 ++ src/invidious/routes/before_all.cr | 2 ++ src/invidious/routes/login.cr | 13 +++++++++++-- src/invidious/routes/preferences.cr | 14 ++++++++++++-- src/invidious/user/cookies.cr | 20 +++++++++++++++++--- 6 files changed, 64 insertions(+), 7 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index 08005a12..5d76acc1 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -151,6 +151,26 @@ db: ## domain: +## +## List of alternative domains where the invidious instance is being served. +## This needs to be set in order to be able to login and update user preferences +## when using a domain that is not the same as the `domain` configuration, +## like a .`onion` address, `.i2p` address, `.b32.i2p` address, etc. +## +## It will detect the alternative domain trough the `X-Forwarded-Host` header. +## https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-Host +## +## Accepted values: a list of fully qualified domain names (FQDN) +## Default: +## +## Example: +## alternative_domains: +## - invidious.example.com +## - inv.example.com +## - videos.example.com +## +alternative_domains: + ## ## Tell Invidious that it is behind a proxy that provides only ## HTTPS, so all links must use the https:// scheme. This diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 914d828c..af76bb8e 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -121,6 +121,8 @@ class Config property hmac_key : String = "" # Domain to be used for links to resources on the site where an absolute URL is required property domain : String? + # Additional domain list that is going to be used for cookie domain validation + property alternative_domains : Array(String) = [] of String # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) property use_pubsub_feeds : Bool | Int32 = false property popular_enabled : Bool = true diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr index 45b8c91c..b75574b6 100644 --- a/src/invidious/routes/before_all.cr +++ b/src/invidious/routes/before_all.cr @@ -32,6 +32,8 @@ module Invidious::Routes::BeforeAll env.response.headers["X-XSS-Protection"] = "1; mode=block" env.response.headers["X-Content-Type-Options"] = "nosniff" + env.set "header_x-forwarded-host", env.request.headers["X-Forwarded-Host"]? + # Only allow the pages at /embed/* to be embedded if env.request.resource.starts_with?("/embed") frame_ancestors = "'self' file: http: https:" diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 01a199d3..7d7da487 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -26,6 +26,7 @@ module Invidious::Routes::Login def self.login(env) locale = env.get("preferences").as(Preferences).locale + host = env.get("header_x-forwarded-host") referer = get_referer(env, "/feed/subscriptions") @@ -57,7 +58,11 @@ module Invidious::Routes::Login sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) Invidious::Database::SessionIDs.insert(sid, email) - env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid) + if alt = CONFIG.alternative_domains.index(host) + env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid) + else + env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid) + end else return error_template(401, "Wrong username or password") end @@ -123,7 +128,11 @@ module Invidious::Routes::Login view_name = "subscriptions_#{sha256(user.email)}" PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") - env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid) + if alt = CONFIG.alternative_domains.index(host) + env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid) + else + env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid) + end if env.request.cookies["PREFS"]? user.preferences = env.get("preferences").as(Preferences) diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index a2108b9b..77ea02e9 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -231,7 +231,12 @@ module Invidious::Routes::PreferencesRoute File.write("config/config.yml", CONFIG.to_yaml) end else - env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences) + host = env.get("header_x-forwarded-host") + if alt = CONFIG.alternative_domains.index(host) + env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences) + else + env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences) + end end env.redirect referer @@ -266,7 +271,12 @@ module Invidious::Routes::PreferencesRoute preferences.dark_mode = "dark" end - env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences) + host = env.get("header_x-forwarded-host") + if alt = CONFIG.alternative_domains.index(host) + env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences) + else + env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences) + end end if redirect diff --git a/src/invidious/user/cookies.cr b/src/invidious/user/cookies.cr index 654efc15..ed7665fd 100644 --- a/src/invidious/user/cookies.cr +++ b/src/invidious/user/cookies.cr @@ -6,17 +6,24 @@ struct Invidious::User # Note: we use ternary operator because the two variables # used in here are not booleans. - SECURE = (Kemal.config.ssl || CONFIG.https_only) ? true : false + @@secure = (Kemal.config.ssl || CONFIG.https_only) ? true : false # Session ID (SID) cookie # Parameter "domain" comes from the global config def sid(domain : String?, sid) : HTTP::Cookie + # Not secure if it's being accessed from I2P + # Browsers expect the domain to include https. On I2P there is no HTTPS + # Tor browser works fine with secure being true + if (domain.try &.split(".").last == "i2p") && @@secure + @@secure = false + end + return HTTP::Cookie.new( name: "SID", domain: domain, value: sid, expires: Time.utc + 2.years, - secure: SECURE, + secure: @@secure, http_only: true, samesite: HTTP::Cookie::SameSite::Lax ) @@ -25,12 +32,19 @@ struct Invidious::User # Preferences (PREFS) cookie # Parameter "domain" comes from the global config def prefs(domain : String?, preferences : Preferences) : HTTP::Cookie + # Not secure if it's being accessed from I2P + # Browsers expect the domain to include https. On I2P there is no HTTPS + # Tor browser works fine with secure being true + if (domain.try &.split(".").last == "i2p") && @@secure + @@secure = false + end + return HTTP::Cookie.new( name: "PREFS", domain: domain, value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years, - secure: SECURE, + secure: @@secure, http_only: false, samesite: HTTP::Cookie::SameSite::Lax ) From 8f279745db982959984e5953044ea4fa5f521ff3 Mon Sep 17 00:00:00 2001 From: Fijxu Date: Thu, 4 Jun 2026 20:20:14 -0400 Subject: [PATCH 13/16] Fix: Restore whitespace formatting while keeping disable_abusable_api configuration --- config/config.example.yml | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index 5d76acc1..97361658 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -205,7 +205,6 @@ https_only: false # path: /tmp/invidious.sock # permissions: 777 - # ----------------------------- # Network (outbound) # ----------------------------- @@ -228,7 +227,6 @@ https_only: false ## #pool_size: 100 - ## ## Additional cookies to be sent when requesting the youtube API. ## @@ -263,7 +261,6 @@ https_only: false # host: # port: - ## ## Use Innertube's transcripts API instead of timedtext for closed captions ## @@ -344,7 +341,6 @@ https_only: false ## #statistics_enabled: false - # ----------------------------- # Users and accounts # ----------------------------- @@ -456,6 +452,21 @@ full_refresh: false ## feed_threads: 1 +## +## Setting to disable easy to abuse API endpoints that can +## be spammed and therefore blocking your Invidious instance. +## +## Useful for public instance maintainers. +## +## Notes: The following API endpoints will be disabled: +## - /api/v1/videos +## - /api/v1/clips +## - /api/v1/transcripts +## +## Accepted values: true, false +## Default: false +## +disable_abusable_api: false jobs: @@ -489,7 +500,6 @@ jobs: ## enable: true - # ----------------------------- # Miscellaneous # ----------------------------- @@ -688,7 +698,6 @@ default_user_preferences: ## #captions: ["", "", ""] - # ----------------------------- # Interface # ----------------------------- @@ -790,7 +799,6 @@ default_user_preferences: ## #related_videos: true - # ----------------------------- # Video player behavior # ----------------------------- @@ -854,7 +862,6 @@ default_user_preferences: ## #video_loop: false - # ----------------------------- # Video playback settings # ----------------------------- @@ -966,7 +973,6 @@ default_user_preferences: ## #sort: published - # ----------------------------- # Miscellaneous # ----------------------------- @@ -1002,14 +1008,12 @@ default_user_preferences: ## Accepted values: true, false ## Default: false ## - #automatic_instance_redirect: false + #redirect_feed: false ## - ## Show the entire video description by default (when set to 'false', - ## only the first few lines of the description are shown and a - ## "show more" button allows to expand it). + ## Show Youtube links in the top menu bar. ## ## Accepted values: true, false ## Default: false ## - #extend_desc: false + #youtube_trending: false From 6b21daab568c2161b79ae4886f2ad992f06c326b Mon Sep 17 00:00:00 2001 From: Fijxu Date: Thu, 4 Jun 2026 20:22:00 -0400 Subject: [PATCH 14/16] Revert "Fix: Restore whitespace formatting while keeping disable_abusable_api configuration" This reverts commit 8f279745db982959984e5953044ea4fa5f521ff3. --- config/config.example.yml | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index 97361658..5d76acc1 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -205,6 +205,7 @@ https_only: false # path: /tmp/invidious.sock # permissions: 777 + # ----------------------------- # Network (outbound) # ----------------------------- @@ -227,6 +228,7 @@ https_only: false ## #pool_size: 100 + ## ## Additional cookies to be sent when requesting the youtube API. ## @@ -261,6 +263,7 @@ https_only: false # host: # port: + ## ## Use Innertube's transcripts API instead of timedtext for closed captions ## @@ -341,6 +344,7 @@ https_only: false ## #statistics_enabled: false + # ----------------------------- # Users and accounts # ----------------------------- @@ -452,21 +456,6 @@ full_refresh: false ## feed_threads: 1 -## -## Setting to disable easy to abuse API endpoints that can -## be spammed and therefore blocking your Invidious instance. -## -## Useful for public instance maintainers. -## -## Notes: The following API endpoints will be disabled: -## - /api/v1/videos -## - /api/v1/clips -## - /api/v1/transcripts -## -## Accepted values: true, false -## Default: false -## -disable_abusable_api: false jobs: @@ -500,6 +489,7 @@ jobs: ## enable: true + # ----------------------------- # Miscellaneous # ----------------------------- @@ -698,6 +688,7 @@ default_user_preferences: ## #captions: ["", "", ""] + # ----------------------------- # Interface # ----------------------------- @@ -799,6 +790,7 @@ default_user_preferences: ## #related_videos: true + # ----------------------------- # Video player behavior # ----------------------------- @@ -862,6 +854,7 @@ default_user_preferences: ## #video_loop: false + # ----------------------------- # Video playback settings # ----------------------------- @@ -973,6 +966,7 @@ default_user_preferences: ## #sort: published + # ----------------------------- # Miscellaneous # ----------------------------- @@ -1008,12 +1002,14 @@ default_user_preferences: ## Accepted values: true, false ## Default: false ## - #redirect_feed: false + #automatic_instance_redirect: false ## - ## Show Youtube links in the top menu bar. + ## Show the entire video description by default (when set to 'false', + ## only the first few lines of the description are shown and a + ## "show more" button allows to expand it). ## ## Accepted values: true, false ## Default: false ## - #youtube_trending: false + #extend_desc: false From 067260a4ab574167531bc53868b38fd7d059dbfd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:29:59 -0400 Subject: [PATCH 15/16] chore(deps): bump int128/docker-manifest-create-action (#5766) Bumps [int128/docker-manifest-create-action](https://github.com/int128/docker-manifest-create-action) from 2.21.0 to 2.22.0. - [Release notes](https://github.com/int128/docker-manifest-create-action/releases) - [Commits](https://github.com/int128/docker-manifest-create-action/compare/v2.21.0...v2.22.0) --- updated-dependencies: - dependency-name: int128/docker-manifest-create-action dependency-version: 2.22.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-nightly-container.yml | 2 +- .github/workflows/build-stable-container.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-nightly-container.yml b/.github/workflows/build-nightly-container.yml index e37b9c96..20bbe3d5 100644 --- a/.github/workflows/build-nightly-container.yml +++ b/.github/workflows/build-nightly-container.yml @@ -86,7 +86,7 @@ jobs: # https://github.com/marketplace/actions/docker-manifest-create-action - name: Create and push manifest - uses: int128/docker-manifest-create-action@v2.21.0 + uses: int128/docker-manifest-create-action@v2.22.0 with: push: true tags: quay.io/invidious/invidious:master diff --git a/.github/workflows/build-stable-container.yml b/.github/workflows/build-stable-container.yml index 9058d914..befdf2a1 100644 --- a/.github/workflows/build-stable-container.yml +++ b/.github/workflows/build-stable-container.yml @@ -78,7 +78,7 @@ jobs: # https://github.com/marketplace/actions/docker-manifest-create-action - name: Create and push manifest - uses: int128/docker-manifest-create-action@v2.21.0 + uses: int128/docker-manifest-create-action@v2.22.0 with: push: true tags: quay.io/invidious/invidious:latest From 6dec63a3e57c3f14ef6c615a96350d49a61a3d77 Mon Sep 17 00:00:00 2001 From: Jan Moesen <488144+janmoesen@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:25:27 +0200 Subject: [PATCH 16/16] =?UTF-8?q?Use=20=E2=80=9Cwww.youtube.com=E2=80=9D?= =?UTF-8?q?=20consistently=20(#5768)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saves an unnecessary redirect by using `www.youtube.com` on some parts of Invidious where `youtube.com` was used instead. youtube.com will redirect to www.youtube.com anyways. Co-authored-by: janmoesen <@> --- src/invidious/helpers/errors.cr | 2 +- src/invidious/routes/channels.cr | 2 +- src/invidious/routes/errors.cr | 2 +- src/invidious/yt_backend/youtube_api.cr | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index 97e28022..cee6eb2c 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -201,7 +201,7 @@ def error_redirect_helper(env : HTTP::Server::Context) #{switch_instance}
  • - #{go_to_youtube} + #{go_to_youtube}
  • END_HTML diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index a9b476bc..0477802a 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -351,7 +351,7 @@ module Invidious::Routes::Channels invidious_url_params.delete_all("user") begin - resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}") + resolved_url = YoutubeAPI.resolve_url("https://www.youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}") ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"] rescue ex : InfoException | KeyError return error_template(404, I18n.translate(locale, "This channel does not exist.")) diff --git a/src/invidious/routes/errors.cr b/src/invidious/routes/errors.cr index 1e9ab44e..e8e7e287 100644 --- a/src/invidious/routes/errors.cr +++ b/src/invidious/routes/errors.cr @@ -8,7 +8,7 @@ module Invidious::Routes::ErrorRoutes if md = env.request.path.match(/^\/(?([a-zA-Z0-9_-]{11})|(\w+))$/) item = md["id"] - # Check if item is branding URL e.g. https://youtube.com/gaming + # Check if item is branding URL e.g. https://www.youtube.com/gaming response = YT_POOL.client &.get("/#{item}") if response.status_code == 301 diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index dd709920..1783a542 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -480,7 +480,7 @@ module YoutubeAPI # # ``` # # Valid channel "brand URL" gives the related UCID and browse ID - # channel_a = YoutubeAPI.resolve_url("https://youtube.com/c/google") + # channel_a = YoutubeAPI.resolve_url("https://www.youtube.com/c/google") # channel_a # => { # "endpoint": { # "browseEndpoint": { @@ -492,7 +492,7 @@ module YoutubeAPI # } # # # Invalid URL returns throws an InfoException - # channel_b = YoutubeAPI.resolve_url("https://youtube.com/c/invalid") + # channel_b = YoutubeAPI.resolve_url("https://www.youtube.com/c/invalid") # ``` # def resolve_url(url : String, client_config : ClientConfig | Nil = nil)