Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,21 @@ default_user_preferences:
##
#sort: published

##
## In the "Subscription" feed, hide shorts.
##
## Accepted values: true, false
## Default: false
##
#hide_shorts: false

##
## In the "Subscription" feed, hide livestreams.
##
## Accepted values: true, false
## Default: false
##
#hide_livestreams: false

# -----------------------------
# Miscellaneous
Expand Down
9 changes: 9 additions & 0 deletions config/sql/channel_videos.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

-- DROP TABLE public.channel_videos;

CREATE TYPE public.video_type AS ENUM
(
'Video',
'Short',
'Livestream',
'Scheduled'
);
Comment on lines +5 to +11

Copilot AI Jan 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds a new enum type and column to the database schema, but does not provide a migration script for existing databases. Users upgrading to this version will need to manually run this SQL to add the enum type and column. A migration script should be added to config/migrate-scripts/ (following the pattern of existing migration scripts like migrate-db-1eca969.sh) that:

  1. Creates the enum type if it doesn't exist
  2. Adds the video_type column to the channel_videos table
  3. Sets a default value (e.g., 'Video') for existing rows
    This is critical for production deployments where the database already contains data.

Copilot uses AI. Check for mistakes.

CREATE TABLE IF NOT EXISTS public.channel_videos
(
id text NOT NULL,
Expand All @@ -14,6 +22,7 @@ CREATE TABLE IF NOT EXISTS public.channel_videos
live_now boolean,
premiere_timestamp timestamp with time zone,
views bigint,
video_type video_type,

Copilot AI Jan 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The database column is added to the table schema, but it's not marked as NOT NULL and doesn't have a default value. This could cause issues when inserting videos without explicitly setting video_type. Consider adding a default value in the SQL schema (e.g., DEFAULT 'Video') or ensuring that all code paths that create ChannelVideo objects always set video_type explicitly. The struct has a default value (VideoType::Video), but this may not be respected at the database level.

Suggested change
video_type video_type,
video_type video_type NOT NULL DEFAULT 'Video',

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discuss

CONSTRAINT channel_videos_id_key UNIQUE (id)
);

Expand Down
2 changes: 2 additions & 0 deletions locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@
"Only show latest video from channel: ": "Only show latest video from channel: ",
"Only show latest unwatched video from channel: ": "Only show latest unwatched video from channel: ",
"preferences_unseen_only_label": "Only show unwatched: ",
"preferences_hide_shorts_label": "Hide shorts: ",
"preferences_hide_livestreams_label": "Hide livestreams: ",
"preferences_notifications_only_label": "Only show notifications (if there are any): ",
"Enable web notifications": "Enable web notifications",
"`x` uploaded a video": "`x` uploaded a video",
Expand Down
73 changes: 50 additions & 23 deletions src/invidious/channels/channels.cr
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ struct ChannelVideo
property live_now : Bool = false
property premiere_timestamp : Time? = nil
property views : Int64? = nil
@[DB::Field(converter: ChannelVideo::VideoTypeConverter)]
property video_type : VideoType = VideoType::Video

module VideoTypeConverter
def self.from_rs(rs)
return VideoType.parse(String.new(rs.read(Slice(UInt8))))
end
end

def to_json(locale, json : JSON::Builder)
json.object do
Expand Down Expand Up @@ -200,6 +208,8 @@ def fetch_channel(ucid, pull_all_videos : Bool)
LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed")
rss.xpath_nodes("//default:feed/default:entry", namespaces).each do |entry|
video_id = entry.xpath_node("yt:videoId", namespaces).not_nil!.content
database_video = Invidious::Database::ChannelVideos.select([video_id])

title = entry.xpath_node("default:title", namespaces).not_nil!.content

published = Time.parse_rfc3339(
Expand All @@ -216,30 +226,46 @@ def fetch_channel(ucid, pull_all_videos : Bool)
.xpath_node("media:group/media:community/media:statistics", namespaces)
.try &.["views"]?.try &.to_i64? || 0_i64

channel_video = videos
.select(SearchVideo)
.select(&.id.== video_id)[0]?

length_seconds = channel_video.try &.length_seconds
length_seconds ||= 0

live_now = channel_video.try &.badges.live_now?
live_now ||= false

premiere_timestamp = channel_video.try &.premiere_timestamp
# If there is no update for the video, only update the views
if database_video.size > 0 && updated == database_video[0].updated
video = database_video[0]
video.views = views
Comment thread
Harm133 marked this conversation as resolved.
Outdated
Comment thread
Harm133 marked this conversation as resolved.
Outdated
else
channel_video = videos
.select(SearchVideo)
.select(&.id.== video_id)[0]?

# Not a video, either a short of a livestream
Comment thread
Harm133 marked this conversation as resolved.
Outdated
# Fetch invididual for info
Comment thread
Harm133 marked this conversation as resolved.
Outdated
if channel_video.nil?
short_or_live = fetch_video(video_id, "")

Copilot AI Jan 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a video is not found in the channel_video list (which happens for shorts and livestreams), fetch_video is called to retrieve full video information. This makes an additional API call per short/livestream which can significantly impact performance when channels have many shorts or livestreams. Consider whether this approach scales well, especially for channels that post many shorts. The performance impact should be documented, and consider adding caching or batching strategies if this becomes a bottleneck.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RSS feed queried only gives back 10 videos.
The extra API will only trigger on new or updated shorts/livestreams.

video_type = short_or_live.video_type
length_seconds = short_or_live.length_seconds
live_now = short_or_live.live_now
premiere_timestamp = short_or_live.premiere_timestamp

Copilot AI Jan 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetch_video call can raise exceptions (such as NotFoundException or InfoException) if the video is unavailable, deleted, or encounters other errors. However, there's no error handling around this call, which means a single failing short or livestream would cause the entire channel feed update to abort. Consider wrapping this in a begin-rescue block and either skipping the video or falling back to minimal information (e.g., treating it as a regular video with length_seconds: 0).

Suggested change
# Fetch invididual for info
if channel_video.nil?
short_or_live = fetch_video(video_id, "")
video_type = short_or_live.video_type
length_seconds = short_or_live.length_seconds
live_now = short_or_live.live_now
premiere_timestamp = short_or_live.premiere_timestamp
# Fetch individual info
premiere_timestamp = nil
if channel_video.nil?
begin
short_or_live = fetch_video(video_id, "")
video_type = short_or_live.video_type
length_seconds = short_or_live.length_seconds
live_now = short_or_live.live_now
premiere_timestamp = short_or_live.premiere_timestamp
rescue ex : NotFoundException | InfoException
LOGGER.warn("fetch_channel: #{ucid} : video #{video_id} : failed to fetch video details (#{ex.class.name}): #{ex.message}")
# Fall back to treating this as a regular video with minimal information
video_type = VideoType::Video
rescue ex
LOGGER.error("fetch_channel: #{ucid} : video #{video_id} : unexpected error while fetching video details: #{ex}")
# Fall back to treating this as a regular video with minimal information
video_type = VideoType::Video
end

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The video would not show in the the RSS feed.

else
video_type = VideoType::Video
length_seconds = channel_video.try &.length_seconds
live_now = channel_video.try &.badges.live_now?
end
Comment on lines +246 to +250

Copilot AI Jan 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When processing regular videos (the else branch starting at line 246), the premiere_timestamp variable is not set. This means it will be uninitialized when used at line 264, which could lead to a compilation error or unexpected behavior. The premiere_timestamp should be set to nil or retrieved from channel_video.premiere_timestamp in the else branch.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsure how default nullability works in crystal.


video = ChannelVideo.new({
id: video_id,
title: title,
published: published,
updated: updated,
ucid: ucid,
author: author,
length_seconds: length_seconds,
live_now: live_now,
premiere_timestamp: premiere_timestamp,
views: views,
})
length_seconds ||= 0
live_now ||= false

video = ChannelVideo.new({
id: video_id,
title: title,
published: published,
updated: updated,
ucid: ucid,
author: author,
length_seconds: length_seconds,
live_now: live_now,
premiere_timestamp: premiere_timestamp,
views: views,
video_type: video_type,
})
end

LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video")

Expand Down Expand Up @@ -274,6 +300,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
live_now: video.badges.live_now?,
premiere_timestamp: video.premiere_timestamp,
views: video.views,
video_type: VideoType::Video
})

# We are notified of Red videos elsewhere (PubSub), which includes a correct published date,
Expand Down
2 changes: 2 additions & 0 deletions src/invidious/config.cr
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ struct ConfigPreferences
property thin_mode : Bool = false
property unseen_only : Bool = false
property video_loop : Bool = false
property hide_shorts : Bool = false
property hide_livestreams : Bool = false
property extend_desc : Bool = false
property volume : Int32 = 100
property vr_mode : Bool = true
Expand Down
6 changes: 3 additions & 3 deletions src/invidious/database/channels.cr
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,14 @@ module Invidious::Database::ChannelVideos
# This function returns the status of the query (i.e: success?)
def insert(video : ChannelVideo, with_premiere_timestamp : Bool = false) : Bool
if with_premiere_timestamp
last_items = "premiere_timestamp = $9, views = $10"
last_items = "premiere_timestamp = $9, views = $10, video_type = $11"
else
last_items = "views = $10"
last_items = "views = $10, video_type = $11"
end

request = <<-SQL
INSERT INTO channel_videos
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (id) DO UPDATE
SET title = $2, published = $3, updated = $4, ucid = $5,
author = $6, length_seconds = $7, live_now = $8, #{last_items}
Expand Down
1 change: 1 addition & 0 deletions src/invidious/routes/feeds.cr
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ module Invidious::Routes::Feeds
live_now: video.live_now,
premiere_timestamp: video.premiere_timestamp,
views: video.views,
video_type: VideoType::Video
Comment thread
Harm133 marked this conversation as resolved.
Outdated
})

was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true)
Expand Down
10 changes: 10 additions & 0 deletions src/invidious/routes/preferences.cr
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ module Invidious::Routes::PreferencesRoute
notifications_only ||= "off"
notifications_only = notifications_only == "on"

hide_shorts = env.params.body["hide_shorts"]?.try &.as(String)
hide_shorts ||= "off"
hide_shorts = hide_shorts == "on"

hide_livestreams = env.params.body["hide_livestreams"]?.try &.as(String)
hide_livestreams ||= "off"
hide_livestreams = hide_livestreams == "on"

default_playlist = env.params.body["default_playlist"]?.try &.as(String)

# Convert to JSON and back again to take advantage of converters used for compatibility
Expand Down Expand Up @@ -182,6 +190,8 @@ module Invidious::Routes::PreferencesRoute
show_nick: show_nick,
save_player_pos: save_player_pos,
default_playlist: default_playlist,
hide_shorts: hide_shorts,
hide_livestreams: hide_livestreams,
}.to_json)

if user = env.get? "user"
Expand Down
2 changes: 2 additions & 0 deletions src/invidious/user/preferences.cr
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ 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 hide_shorts : Bool = CONFIG.default_user_preferences.hide_shorts
property hide_livestreams : Bool = CONFIG.default_user_preferences.hide_livestreams

module BoolToString
def self.to_json(value : String, json : JSON::Builder)
Expand Down
21 changes: 15 additions & 6 deletions src/invidious/users.cr
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ def get_subscription_feed(user, max_results = 40, page = 1)
notifications = Invidious::Database::Users.select_notifications(user)
view_name = "subscriptions_#{sha256(user.email)}"

types_to_fetch = [VideoType::Video, VideoType::Short, VideoType::Livestream, VideoType::Scheduled]
if user.preferences.hide_shorts
types_to_fetch.delete(VideoType::Short)
end
Comment thread
Harm133 marked this conversation as resolved.
if user.preferences.hide_livestreams
[VideoType::Livestream, VideoType::Scheduled].each { |v| types_to_fetch.delete(v) }
end

types_to_fetch = types_to_fetch.map { |type| "'#{type}'" }.join(", ")

Copilot AI Jan 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code constructs SQL by interpolating enum values into the query string. While the values come from a controlled enum (VideoType), this pattern is vulnerable if the enum implementation changes. A safer approach would be to use parameterized queries with an IN clause using an array parameter, or to convert the enum values to their PostgreSQL enum representation properly. Consider using a parameterized query like: "WHERE video_type = ANY($1)" with types_to_fetch as an array parameter.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following examples from other queries in the codebase.

LOGGER.trace("Types to fetch: #{types_to_fetch}")
Comment thread
Harm133 marked this conversation as resolved.

if user.preferences.notifications_only && !notifications.empty?
# Only show notifications
notifications = Invidious::Database::ChannelVideos.select(notifications)
Expand Down Expand Up @@ -58,11 +69,10 @@ def get_subscription_feed(user, max_results = 40, page = 1)
else
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
end
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY ucid, published DESC", as: ChannelVideo)
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} WHERE NOT id = ANY (#{values}) AND video_type IN (#{types_to_fetch}) ORDER BY ucid, published DESC", as: ChannelVideo)
else
# Show latest video from each channel

videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} ORDER BY ucid, published DESC", as: ChannelVideo)
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} WHERE video_type IN (#{types_to_fetch}) ORDER BY ucid, published DESC", as: ChannelVideo)

Copilot AI Jan 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an inconsistent indentation here. The line has extra indentation that doesn't match the surrounding code. This should be aligned with the if statement at the same level (2 spaces from the left margin based on the context).

Copilot uses AI. Check for mistakes.
end

videos.sort_by!(&.published).reverse!
Expand All @@ -75,11 +85,10 @@ def get_subscription_feed(user, max_results = 40, page = 1)
else
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
end
videos = PG_DB.query_all("SELECT * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
videos = PG_DB.query_all("SELECT * FROM #{view_name} WHERE NOT id = ANY (#{values}) AND video_type IN (#{types_to_fetch}) ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)

Copilot AI Jan 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an inconsistent indentation here. The line has extra indentation that doesn't match the surrounding code. This should be aligned with the if statement at the same level (2 spaces from the left margin based on the context).

Copilot uses AI. Check for mistakes.
else
# Sort subscriptions as normal

videos = PG_DB.query_all("SELECT * FROM #{view_name} ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
videos = PG_DB.query_all("SELECT * FROM #{view_name} WHERE video_type IN (#{types_to_fetch}) ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
Comment thread
Harm133 marked this conversation as resolved.
Outdated
end
end

Expand Down
1 change: 1 addition & 0 deletions src/invidious/videos.cr
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
enum VideoType
Video
Short
Livestream
Scheduled
end
Expand Down
7 changes: 5 additions & 2 deletions src/invidious/videos/parser.cr
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
post_live_dvr = video_details.dig?("isPostLiveDvr")
.try &.as_bool || false

is_short = microformat["isShortsEligible"].try &.as_bool || false

# Extra video infos

allowed_regions = microformat["availableCountries"]?
Expand Down Expand Up @@ -394,8 +396,9 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
end

# Return data

if live_now
if is_short
video_type = VideoType::Short
elsif live_now
video_type = VideoType::Livestream
elsif !premiere_timestamp.nil?
video_type = VideoType::Scheduled
Expand Down
10 changes: 10 additions & 0 deletions src/invidious/views/user/preferences.ecr
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,16 @@
<input name="watch_history" id="watch_history" type="checkbox" <% if preferences.watch_history %>checked<% end %>>
</div>

<div class="pure-control-group">
<label for="hide_shorts"><%= translate(locale, "preferences_hide_shorts_label") %></label>
<input name="hide_shorts" id="hide_shorts" type="checkbox" <% if preferences.hide_shorts %>checked<% end %>>
</div>

<div class="pure-control-group">
<label for="hide_livestreams"><%= translate(locale, "preferences_hide_livestreams_label") %></label>
<input name="hide_livestreams" id="hide_livestreams" type="checkbox" <% if preferences.hide_livestreams %>checked<% end %>>
</div>

<div class="pure-control-group">
<label for="annotations_subscribed"><%= translate(locale, "preferences_annotations_subscribed_label") %></label>
<input name="annotations_subscribed" id="annotations_subscribed" type="checkbox" <% if preferences.annotations_subscribed %>checked<% end %>>
Expand Down