diff --git a/src/invidious.cr b/src/invidious.cr index 9229c9d1..a017ce42 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -26,11 +26,13 @@ require "xml" require "yaml" require "compress/zip" require "protodec/utils" + require "./invidious/helpers/*" require "./invidious/*" require "./invidious/channels/*" require "./invidious/routes/**" require "./invidious/jobs/**" +require "./invidious/user/*" CONFIG = Config.load HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) @@ -288,12 +290,14 @@ before_all do |env| end end - dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s + theme = env.params.query["dark_mode"]? + theme = Settings::Converters::Theme.from_s(theme) if theme + preferences.dark_mode = theme if theme + thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode.to_s thin_mode = thin_mode == "true" locale = env.params.query["hl"]? || preferences.locale - preferences.dark_mode = dark_mode preferences.thin_mode = thin_mode preferences.locale = locale env.set "preferences", preferences diff --git a/src/invidious/config.cr b/src/invidious/config.cr new file mode 100644 index 00000000..4c3e853c --- /dev/null +++ b/src/invidious/config.cr @@ -0,0 +1,217 @@ +# +# This file contains the server config data structures and +# all the associated validation/parsing routines. +# + +struct DBConfig + include YAML::Serializable + + property user : String + property password : String + + property host : String + property port : Int32 + + property dbname : String +end + +class Config + include YAML::Serializable + + property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions) + property feed_threads : Int32 = 1 # Number of threads to use for updating feeds + property output : String = "STDOUT" # Log file path or STDOUT + property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr + property db : DBConfig? = nil # Database configuration with separate parameters (username, hostname, etc) + + @[YAML::Field(converter: Config::URIConverter)] + property database_url : URI = URI.parse("") # Database configuration using 12-Factor "Database URL" syntax + property decrypt_polling : Bool = true # Use polling to keep decryption function up to date + property full_refresh : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel + property https_only : Bool? # Used to tell Invidious it is behind a proxy, so links to resources should be https:// + property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions + property domain : String? # Domain to be used for links to resources on the site where an absolute URL is required + property use_pubsub_feeds : Bool | Int32 = false # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) + property popular_enabled : Bool = true + property captcha_enabled : Bool = true + property login_enabled : Bool = true + property registration_enabled : Bool = true + property statistics_enabled : Bool = false + property admins : Array(String) = [] of String + property external_port : Int32? = nil + + property default_user_preferences : Preferences = Preferences.new + + property dmca_content : Array(String) = [] of String # For compliance with DMCA, disables download widget using list of video IDs + property check_tables : Bool = false # Check table integrity, automatically try to add any missing columns, create tables, etc. + property cache_annotations : Bool = false # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards + property banner : String? = nil # Optional banner to be displayed along top of page for announcements, etc. + property hsts : Bool? = true # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely + property disable_proxy : Bool? | Array(String)? = false # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' + + @[YAML::Field(converter: Config::FamilyConverter)] + property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) + property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument) + property host_binding : String = "0.0.0.0" # Host to bind (overrided by command line argument) + property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) + property use_quic : Bool = true # Use quic transport for youtube api + + @[YAML::Field(converter: Config::StringToCookies)] + property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format + property captcha_key : String? = nil # Key for Anti-Captcha + property captcha_api_url : String = "https://api.anti-captcha.com" # API URL for Anti-Captcha + + def disabled?(option) + case disabled = CONFIG.disable_proxy + when Bool + return disabled + when Array + if disabled.includes? option + return true + else + return false + end + else + return false + end + end + + def self.load + # Load config from file or YAML string env var + env_config_file = "INVIDIOUS_CONFIG_FILE" + env_config_yaml = "INVIDIOUS_CONFIG" + + config_file = ENV.has_key?(env_config_file) ? ENV.fetch(env_config_file) : "config/config.yml" + config_yaml = ENV.has_key?(env_config_yaml) ? ENV.fetch(env_config_yaml) : File.read(config_file) + + config = Config.from_yaml(config_yaml) + + # Update config from env vars (upcased and prefixed with "INVIDIOUS_") + {% for ivar in Config.instance_vars %} + {% env_id = "INVIDIOUS_#{ivar.id.upcase}" %} + + if ENV.has_key?({{env_id}}) + # puts %(Config.{{ivar.id}} : Loading from env var {{env_id}}) + env_value = ENV.fetch({{env_id}}) + success = false + + # Use YAML converter if specified + {% ann = ivar.annotation(::YAML::Field) %} + {% if ann && ann[:converter] %} + puts %(Config.{{ivar.id}} : Parsing "#{env_value}" as {{ivar.type}} with {{ann[:converter]}} converter) + config.{{ivar.id}} = {{ann[:converter]}}.from_yaml(YAML::ParseContext.new, YAML::Nodes.parse(ENV.fetch({{env_id}})).nodes[0]) + puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}}) + success = true + + # Use regular YAML parser otherwise + {% else %} + {% ivar_types = ivar.type.union? ? ivar.type.union_types : [ivar.type] %} + # Sort types to avoid parsing nulls and numbers as strings + {% ivar_types = ivar_types.sort_by { |ivar_type| ivar_type == Nil ? 0 : ivar_type == Int32 ? 1 : 2 } %} + {{ivar_types}}.each do |ivar_type| + if !success + begin + # puts %(Config.{{ivar.id}} : Trying to parse "#{env_value}" as #{ivar_type}) + config.{{ivar.id}} = ivar_type.from_yaml(env_value) + puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}} (#{ivar_type})) + success = true + rescue + # nop + end + end + end + {% end %} + + # Exit on fail + if !success + puts %(Config.{{ivar.id}} failed to parse #{env_value} as {{ivar.type}}) + exit(1) + end + end + {% end %} + + # Build database_url from db.* if it's not set directly + if config.database_url.to_s.empty? + if db = config.db + config.database_url = URI.new( + scheme: "postgres", + user: db.user, + password: db.password, + host: db.host, + port: db.port, + path: db.dbname, + ) + else + puts "Config : Either database_url or db.* is required" + exit(1) + end + end + + return config + end + + module URIConverter + def self.to_yaml(value : URI, yaml : YAML::Nodes::Builder) + yaml.scalar value.normalize! + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : URI + if node.is_a?(YAML::Nodes::Scalar) + URI.parse node.value + else + node.raise "Expected scalar, not #{node.class}" + end + end + end + + module FamilyConverter + def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder) + case value + when Socket::Family::UNSPEC + yaml.scalar nil + when Socket::Family::INET + yaml.scalar "ipv4" + when Socket::Family::INET6 + yaml.scalar "ipv6" + when Socket::Family::UNIX + raise "Invalid socket family #{value}" + end + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family + if node.is_a?(YAML::Nodes::Scalar) + case node.value.downcase + when "ipv4" + Socket::Family::INET + when "ipv6" + Socket::Family::INET6 + else + Socket::Family::UNSPEC + end + else + node.raise "Expected scalar, not #{node.class}" + end + end + end + + module StringToCookies + def self.to_yaml(value : HTTP::Cookies, yaml : YAML::Nodes::Builder) + (value.map { |c| "#{c.name}=#{c.value}" }).join("; ").to_yaml(yaml) + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : HTTP::Cookies + unless node.is_a?(YAML::Nodes::Scalar) + node.raise "Expected scalar, not #{node.class}" + end + + cookies = HTTP::Cookies.new + node.value.split(";").each do |cookie| + next if cookie.strip.empty? + name, value = cookie.split("=", 2) + cookies << HTTP::Cookie.new(name.strip, value.strip) + end + + cookies + end + end +end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index fb33df1c..24ff97c3 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -22,193 +22,6 @@ struct Annotation property annotations : String end -struct ConfigPreferences - include YAML::Serializable - - property annotations : Bool = false - property annotations_subscribed : Bool = false - property autoplay : Bool = false - property captions : Array(String) = ["", "", ""] - property comments : Array(String) = ["youtube", ""] - property continue : Bool = false - property continue_autoplay : Bool = true - property dark_mode : String = "" - property latest_only : Bool = false - property listen : Bool = false - property local : Bool = false - property locale : String = "en-US" - property max_results : Int32 = 40 - property notifications_only : Bool = false - property player_style : String = "invidious" - property quality : String = "hd720" - property quality_dash : String = "auto" - property default_home : String? = "Popular" - property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"] - property automatic_instance_redirect : Bool = false - property related_videos : Bool = true - property sort : String = "published" - property speed : Float32 = 1.0_f32 - property thin_mode : Bool = false - property unseen_only : Bool = false - property video_loop : Bool = false - property extend_desc : Bool = false - property volume : Int32 = 100 - property vr_mode : Bool = true - property show_nick : Bool = true - - def to_tuple - {% begin %} - { - {{*@type.instance_vars.map { |var| "#{var.name}: #{var.name}".id }}} - } - {% end %} - end -end - -class Config - include YAML::Serializable - - property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions) - property feed_threads : Int32 = 1 # Number of threads to use for updating feeds - property output : String = "STDOUT" # Log file path or STDOUT - property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr - property db : DBConfig? = nil # Database configuration with separate parameters (username, hostname, etc) - - @[YAML::Field(converter: Preferences::URIConverter)] - property database_url : URI = URI.parse("") # Database configuration using 12-Factor "Database URL" syntax - property decrypt_polling : Bool = true # Use polling to keep decryption function up to date - property full_refresh : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel - property https_only : Bool? # Used to tell Invidious it is behind a proxy, so links to resources should be https:// - property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions - property domain : String? # Domain to be used for links to resources on the site where an absolute URL is required - property use_pubsub_feeds : Bool | Int32 = false # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) - property popular_enabled : Bool = true - property captcha_enabled : Bool = true - property login_enabled : Bool = true - property registration_enabled : Bool = true - property statistics_enabled : Bool = false - property admins : Array(String) = [] of String - property external_port : Int32? = nil - property default_user_preferences : ConfigPreferences = ConfigPreferences.from_yaml("") - property dmca_content : Array(String) = [] of String # For compliance with DMCA, disables download widget using list of video IDs - property check_tables : Bool = false # Check table integrity, automatically try to add any missing columns, create tables, etc. - property cache_annotations : Bool = false # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards - property banner : String? = nil # Optional banner to be displayed along top of page for announcements, etc. - property hsts : Bool? = true # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely - property disable_proxy : Bool? | Array(String)? = false # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' - - @[YAML::Field(converter: Preferences::FamilyConverter)] - property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) - property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument) - property host_binding : String = "0.0.0.0" # Host to bind (overrided by command line argument) - property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) - property use_quic : Bool = true # Use quic transport for youtube api - - @[YAML::Field(converter: Preferences::StringToCookies)] - property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format - property captcha_key : String? = nil # Key for Anti-Captcha - property captcha_api_url : String = "https://api.anti-captcha.com" # API URL for Anti-Captcha - - def disabled?(option) - case disabled = CONFIG.disable_proxy - when Bool - return disabled - when Array - if disabled.includes? option - return true - else - return false - end - else - return false - end - end - - def self.load - # Load config from file or YAML string env var - env_config_file = "INVIDIOUS_CONFIG_FILE" - env_config_yaml = "INVIDIOUS_CONFIG" - - config_file = ENV.has_key?(env_config_file) ? ENV.fetch(env_config_file) : "config/config.yml" - config_yaml = ENV.has_key?(env_config_yaml) ? ENV.fetch(env_config_yaml) : File.read(config_file) - - config = Config.from_yaml(config_yaml) - - # Update config from env vars (upcased and prefixed with "INVIDIOUS_") - {% for ivar in Config.instance_vars %} - {% env_id = "INVIDIOUS_#{ivar.id.upcase}" %} - - if ENV.has_key?({{env_id}}) - # puts %(Config.{{ivar.id}} : Loading from env var {{env_id}}) - env_value = ENV.fetch({{env_id}}) - success = false - - # Use YAML converter if specified - {% ann = ivar.annotation(::YAML::Field) %} - {% if ann && ann[:converter] %} - puts %(Config.{{ivar.id}} : Parsing "#{env_value}" as {{ivar.type}} with {{ann[:converter]}} converter) - config.{{ivar.id}} = {{ann[:converter]}}.from_yaml(YAML::ParseContext.new, YAML::Nodes.parse(ENV.fetch({{env_id}})).nodes[0]) - puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}}) - success = true - - # Use regular YAML parser otherwise - {% else %} - {% ivar_types = ivar.type.union? ? ivar.type.union_types : [ivar.type] %} - # Sort types to avoid parsing nulls and numbers as strings - {% ivar_types = ivar_types.sort_by { |ivar_type| ivar_type == Nil ? 0 : ivar_type == Int32 ? 1 : 2 } %} - {{ivar_types}}.each do |ivar_type| - if !success - begin - # puts %(Config.{{ivar.id}} : Trying to parse "#{env_value}" as #{ivar_type}) - config.{{ivar.id}} = ivar_type.from_yaml(env_value) - puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}} (#{ivar_type})) - success = true - rescue - # nop - end - end - end - {% end %} - - # Exit on fail - if !success - puts %(Config.{{ivar.id}} failed to parse #{env_value} as {{ivar.type}}) - exit(1) - end - end - {% end %} - - # Build database_url from db.* if it's not set directly - if config.database_url.to_s.empty? - if db = config.db - config.database_url = URI.new( - scheme: "postgres", - user: db.user, - password: db.password, - host: db.host, - port: db.port, - path: db.dbname, - ) - else - puts "Config : Either database_url or db.* is required" - exit(1) - end - end - - return config - end -end - -struct DBConfig - include YAML::Serializable - - property user : String - property password : String - property host : String - property port : Int32 - property dbname : String -end - def login_req(f_req) data = { # Unfortunately there's not much information available on `bgRequest`; part of Google's BotGuard diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 6ee07d7a..1c8f4e34 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -397,19 +397,6 @@ def parse_range(range) return 0_i64, nil end -def convert_theme(theme) - case theme - when "true" - "dark" - when "false" - "light" - when "", nil - nil - else - theme - end -end - def fetch_random_instance begin instance_api_client = HTTP::Client.new(URI.parse("https://api.invidious.io")) diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr index 82c40a95..cb95e98e 100644 --- a/src/invidious/routes/misc.cr +++ b/src/invidious/routes/misc.cr @@ -5,24 +5,22 @@ module Invidious::Routes::Misc user = env.get? "user" case preferences.default_home - when "Popular" - env.redirect "/feed/popular" - when "Trending" - env.redirect "/feed/trending" - when "Subscriptions" + when Settings::HomePages::Popular ; env.redirect "/feed/popular" + when Settings::HomePages::Trending; env.redirect "/feed/trending" + when Settings::UserHomePages::Subscriptions if user env.redirect "/feed/subscriptions" else env.redirect "/feed/popular" end - when "Playlists" + when Settings::UserHomePages::Playlists if user env.redirect "/feed/playlists" else env.redirect "/feed/popular" end - else - templated "search_homepage", navbar_search: false + else # Settings::HomePages::Search + env.redirect "/search" end end diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 0f26ec15..edc5f993 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -48,8 +48,8 @@ module Invidious::Routes::PreferencesRoute speed = env.params.body["speed"]?.try &.as(String).to_f32? speed ||= CONFIG.default_user_preferences.speed - player_style = env.params.body["player_style"]?.try &.as(String) - player_style ||= CONFIG.default_user_preferences.player_style + player_style = env.params.body["player_style"]?.try { |x| Settings::PlayerStyles.parse?(x) } + player_style = CONFIG.default_user_preferences.player_style if !player_style quality = env.params.body["quality"]?.try &.as(String) quality ||= CONFIG.default_user_preferences.quality @@ -86,7 +86,8 @@ module Invidious::Routes::PreferencesRoute related_videos ||= "off" related_videos = related_videos == "on" - default_home = env.params.body["default_home"]?.try &.as(String) || CONFIG.default_user_preferences.default_home + default_home = env.params.body["default_home"]?.try { |x| Settings::HomePages.parse?(x) || Settings::UserHomePages.parse?(x) } + default_home = CONFIG.default_user_preferences.default_home.to_s if !default_home feed_menu = [] of String 4.times do |index| @@ -103,8 +104,8 @@ module Invidious::Routes::PreferencesRoute locale = env.params.body["locale"]?.try &.as(String) locale ||= CONFIG.default_user_preferences.locale - dark_mode = env.params.body["dark_mode"]?.try &.as(String) - dark_mode ||= CONFIG.default_user_preferences.dark_mode + theme = env.params.body["dark_mode"]?.try { |x| Settings::Themes.parse?(x) } + theme = CONFIG.default_user_preferences.dark_mode if !theme thin_mode = env.params.body["thin_mode"]?.try &.as(String) thin_mode ||= "off" @@ -113,8 +114,8 @@ module Invidious::Routes::PreferencesRoute max_results = env.params.body["max_results"]?.try &.as(String).to_i? max_results ||= CONFIG.default_user_preferences.max_results - sort = env.params.body["sort"]?.try &.as(String) - sort ||= CONFIG.default_user_preferences.sort + sort = env.params.body["sort"]?.try { |x| Settings::SortOptions.parse?(x) } + sort = CONFIG.default_user_preferences.sort if !sort latest_only = env.params.body["latest_only"]?.try &.as(String) latest_only ||= "off" @@ -137,7 +138,7 @@ module Invidious::Routes::PreferencesRoute comments: comments, continue: continue, continue_autoplay: continue_autoplay, - dark_mode: dark_mode, + dark_mode: theme, latest_only: latest_only, listen: listen, local: local, @@ -167,14 +168,13 @@ module Invidious::Routes::PreferencesRoute PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email) if CONFIG.admins.includes? user.email - CONFIG.default_user_preferences.default_home = env.params.body["admin_default_home"]?.try &.as(String) || CONFIG.default_user_preferences.default_home + default_home = env.params.body["admin_default_home"]?.try { |x| Settings::HomePages.parse?(x) || Settings::UserHomePages.parse?(x) } + CONFIG.default_user_preferences.default_home = default_home if default_home - admin_feed_menu = [] of String + admin_feed_menu = [] of Settings::AnyHomePages 4.times do |index| - option = env.params.body["admin_feed_menu[#{index}]"]?.try &.as(String) || "" - if !option.empty? - admin_feed_menu << option - end + option = env.params.body["admin_feed_menu[#{index}]"]?.try { |x| Settings::HomePages.parse?(x) || Settings::UserHomePages.parse?(x) } + admin_feed_menu << option if option end CONFIG.default_user_preferences.feed_menu = admin_feed_menu @@ -229,28 +229,15 @@ module Invidious::Routes::PreferencesRoute if user = env.get? "user" user = user.as(User) + preferences = user.preferences - - case preferences.dark_mode - when "dark" - preferences.dark_mode = "light" - else - preferences.dark_mode = "dark" - end - + preferences.toggle_theme preferences = preferences.to_json PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email) else preferences = env.get("preferences").as(Preferences) - - case preferences.dark_mode - when "dark" - preferences.dark_mode = "light" - else - preferences.dark_mode = "dark" - end - + preferences.toggle_theme preferences = preferences.to_json if Kemal.config.ssl || CONFIG.https_only diff --git a/src/invidious/user/preferences.cr b/src/invidious/user/preferences.cr new file mode 100644 index 00000000..bc6a13c5 --- /dev/null +++ b/src/invidious/user/preferences.cr @@ -0,0 +1,757 @@ +# +# This file contains the user preferences data structures and +# all the associated validation/parsing routines. +# + +require "json" +require "yaml" +require "html" +require "uri/params" + +# +# Enumerates types and constants +# + +module Settings + ALLOWED_SPEED_VALUES = { + 2.00_f32, # Double speed + 1.75_f32, + 1.50_f32, + 1.25_f32, + 1.00_f32, # Normal + 0.75_f32, + 0.50_f32, # Half speed + 0.25_f32, + } + + enum Themes + Auto # I.e use the system's settings with media queries + Light + Dark + end + + enum PlayerStyles + Invidious + Youtube + end + + # General + enum HomePages + Search + Popular + Trending + end + + # Authenticated + enum UserHomePages + Subscriptions + Playlists + end + + alias AnyHomePages = HomePages | UserHomePages + + enum SortOptions + Alphabetically + Alphabetically_Reverse + Channel_Name + Channel_Name_Reverse + Publication_Date + Publication_Date_Reverse + end + + # Unused for now, reuires merging + enum VideoQualities + # Normal + HD720 + Medium + Small + # Dash + DASH_Auto + DASH_Best + DASH_4320p + DASH_2160p + DASH_1440p + DASH_1080p + DASH_720p + DASH_480p + DASH_360p + DASH_240p + DASH_144p + DASH_Worst + end +end + +# +# Data structure that stores a user's preferences. +# + +class Preferences + # Annotation for URL parameters metadata storage + # All the properties below can have one, or more associated URL parameter(s): + # - If no "short" nor "long" parameter are specified, the URL parameter + # will be the property name stringified. + # - Otherwise, a short and a long parameter must be specified + # - An optional "compat" parameter can be provided. + annotation AssociatedURLParams; end + + include JSON::Serializable + include YAML::Serializable + + property annotations : Bool = false + property annotations_subscribed : Bool = false + + @[AssociatedURLParams(short: "ap", long: "autoplay")] + property autoplay : Bool = false + + @[AssociatedURLParams(short: "autoredir", long: "auto_redirect")] + property automatic_instance_redirect : Bool = false + + @[JSON::Field(converter: Preferences::StringToArray)] + @[YAML::Field(converter: Preferences::StringToArray)] + @[AssociatedURLParams(short: "cap", long: "captions")] + property captions : Array(String) = ["", "", ""] + + @[JSON::Field(converter: Preferences::StringToArray)] + @[YAML::Field(converter: Preferences::StringToArray)] + @[AssociatedURLParams(short: "coms", long: "comments")] + property comments : Array(String) = ["youtube", ""] + + property continue : Bool = false + property continue_autoplay : Bool = true + + @[JSON::Field(converter: Settings::Converters::Generic(Settings::Themes))] + @[YAML::Field(converter: Settings::Converters::Generic(Settings::Themes))] + @[AssociatedURLParams(long: "theme", compat: "dark_mode")] + property dark_mode : Settings::Themes = Settings::Themes::Dark + + property latest_only : Bool = false + property listen : Bool = false + property local : Bool = false + + @[AssociatedURLParams(short: "vr", long: "vr_mode")] + property vr_mode : Bool = true + + @[AssociatedURLParams(short: "nick", long: "show_nick")] + property show_nick : Bool = true + + @[JSON::Field(converter: Preferences::ProcessString)] + @[AssociatedURLParams(short: "hl", long: "locale")] + property locale : String = "en-US" + + @[JSON::Field(converter: Preferences::ClampInt)] + property max_results : Int32 = 40 + + @[AssociatedURLParams(short: "notifs_only", long: "notifications_only")] + property notifications_only : Bool = false + + @[JSON::Field(converter: Settings::Converters::Generic(Settings::PlayerStyles))] + @[YAML::Field(converter: Settings::Converters::Generic(Settings::PlayerStyles))] + @[AssociatedURLParams(short: "ps", long: "player_style")] + property player_style : Settings::PlayerStyles = Settings::PlayerStyles::Invidious + + @[JSON::Field(converter: Preferences::ProcessString)] + property quality : String = "hd720" + @[JSON::Field(converter: Preferences::ProcessString)] + property quality_dash : String = "auto" + + @[JSON::Field(converter: Settings::Converters::Generic(Settings::AnyHomePages))] + @[YAML::Field(converter: Settings::Converters::Generic(Settings::AnyHomePages))] + @[AssociatedURLParams(short: "home", long: "default_home")] + property default_home : Settings::AnyHomePages = Settings::HomePages::Popular + + @[JSON::Field(converter: Settings::Converters::Generic(Array(Settings::AnyHomePages)))] + @[YAML::Field(converter: Settings::Converters::Generic(Array(Settings::AnyHomePages)))] + @[AssociatedURLParams(short: "menu", long: "feed_menu")] + property feed_menu : Array(Settings::AnyHomePages) = [ + Settings::HomePages::Popular, + Settings::HomePages::Trending, + Settings::UserHomePages::Subscriptions, + Settings::UserHomePages::Playlists, + ] + + property related_videos : Bool = true + + @[JSON::Field(converter: Settings::Converters::Generic(Settings::SortOptions))] + @[YAML::Field(converter: Settings::Converters::Generic(Settings::SortOptions))] + property sort : Settings::SortOptions = Settings::SortOptions::Publication_Date + + property speed : Float32 = 1.0_f32 + + property thin_mode : Bool = false + property unseen_only : Bool = false + + @[AssociatedURLParams(short: "loop", long: "video_loop")] + property video_loop : Bool = false + + @[AssociatedURLParams(short: "xd", long: "extend_desc")] + property extend_desc : Bool = false + + @[AssociatedURLParams(short: "vol", long: "volume")] + property volume : Int32 = 100 + + def initialize + end + + # Duplicate (make a perfect copy of) the current object + def dup + other = Preferences.new + + {% for ivar in @type.instance_vars %} + other.{{ ivar.id }} = {{ ivar.id }} + {% end %} + + return other + end + + # :nodoc: + def text_dump(include_defaults = false) + {% for ivar in @type.instance_vars %} + name = {{ ivar.id.stringify }} + value = {{ ivar.id }} + default = {{ ivar.default_value }} + + puts "#{name} = #{value}" if (include_defaults || value != default) + {% end %} + end + + # :nodoc: + def text_dump(other : Preferences) + # Macro that dumps, as text, all the contents of that struct + {% for ivar in @type.instance_vars %} + name = {{ ivar.id.stringify }} + val1 = self.{{ ivar.id }} + val2 = other.{{ ivar.id }} + + puts "#{name} = #{val1}" if val1 != val2 + {% end %} + end + + def toggle_theme + old = @dark_mode + @dark_mode = (old.dark?) ? Settings::Themes::Light : Settings::Themes::Dark + end + + # From/To URI parameters + + def self.from_uri(uri : String) : Preferences + return from_uri(URI::Params.parse(uri)) + end + + def self.from_uri(uri : URI::Params) : Preferences + prefs = Preferences.new + + {% begin %} + + {% for ivar in @type.instance_vars %} + {% + uri_ann = ivar.annotation(::Preferences::AssociatedURLParams) + default = ivar.default_value + max_idx = (ivar.type < Array) ? default.size - 1 : nil + + has_long = (uri_ann && uri_ann[:long]) + has_short = (uri_ann && uri_ann[:short]) + has_compat = (uri_ann && uri_ann[:compat]) + + param_long = has_long ? uri_ann[:long] : ivar.id.stringify + param_short = has_short ? uri_ann[:short] : ivar.id.stringify + %} + + # Arrays in HTTP forms are not standardized, so we have to + # support those manually here + {% if max_idx %} + temp = [] of String | Nil + + (0..{{max_idx}}).each do |i| + temp_val = uri[{{param_long}}+"[#{i}]"]? || uri[{{param_short}}+"[#{i}]"]? + + # If "compat" is available and nothing was found above, also check that + {% if has_compat %} + temp_val = uri[{{uri_ann[:compat]}}+"[#{i}]"]? if !temp_val + {% end %} + + temp << temp_val + end + {% else %} + temp = uri[{{param_long}}]? || uri[{{param_short}}]? + + # If "compat" is available and nothing was found above, also check that + {% if has_compat %} + temp = uri[{{uri_ann[:compat]}}]? if !temp + {% end %} + {% end %} + + # Then, use the right converter and assign the variable + # We use a bunch of if/elsif/else statements because case/when can't + # be used in macros. + {% if ivar.type == Settings::Themes %} + val = Settings::Converters::Theme.from_s(temp) + {% elsif ivar.type == Enum %} + val = {{ivar.type}}.parse?(temp) + {% elsif ivar.type == Array(Enum) %} + val = [] of {{ivar.type}} + temp.compact.each { |item| val << {{ivar.type}}.parse?(item) } + {% elsif ivar.type == Bool %} + val = Settings::Converters::Boolean.from_s(temp) + {% elsif ivar.type == String || ivar.type == Array(String) %} + val = temp + {% elsif ivar.type == Float32 %} + val = temp.try &.as(String).to_f32? + {% else %} + val = nil + {% end %} + + {% if max_idx %} + prefs.{{ivar.id}} = val.compact if val + {% else %} + prefs.{{ivar.id}} = val if val + {% end %} + {% end %} + + {% end %} + + return prefs + end + + def to_uri(*, use_short = false, include_defaults = false) : URI::Params + params_raw = {} of String => Array(String) + # Hash(String, Array(String))) + + {% for ivar in @type.instance_vars %} + {% + uri_ann = ivar.annotation(::Preferences::AssociatedURLParams) + + has_long = (uri_ann && uri_ann[:long]) + has_short = (uri_ann && uri_ann[:short]) + + param_long = has_long ? uri_ann[:long] : ivar.id.stringify + param_short = has_short ? uri_ann[:short] : param_long + %} + + # If 'include_defaults' is false, check that the current + # value differs from the default + if include_defaults || @{{ivar.id}} != {{ivar.default_value}} + param_name = use_short ? {{param_short}} : {{param_long}} + + {% if ivar.type == Bool %} + # Boolean is kinda special. We want to use 'true/false' for the + # long form and '0/1' for the short form + temp = Settings::Converters::Boolean.to_s(@{{ivar.id}}, numeric: use_short) + params_raw[param_name] = [temp] + {% elsif ivar.type < Array %} + # Arrays. Use `#to_s` if not already a string. + @{{ivar.id}}.each_with_index do |value, idx| + {% if ivar.type == Array(String) %} + params_raw[param_name + "[#{idx}]"] = [value] + {% else %} + params_raw[param_name + "[#{idx}]"] = [value.to_s] + {% end %} + end + {% else %} + # Same as aboce, but with scalars + {% if ivar.type == String %} + params_raw[param_name] = [@{{ivar.id}}] + {% else %} + params_raw[param_name] = [@{{ivar.id}}.to_s] + {% end %} + {% end %} + end + + {% end %} + + return URI::Params.new(params_raw) + end + + # volume = env.params.body["volume"]?.try &.as(String).to_i? + # volume ||= CONFIG.default_user_preferences.volume + + # comments = [] of String + # 2.times do |i| + # comments << (env.params.body["comments[#{i}]"]?.try &.as(String) || CONFIG.default_user_preferences.comments[i]) + # end + + # captions = [] of String + # 3.times do |i| + # captions << (env.params.body["captions[#{i}]"]?.try &.as(String) || CONFIG.default_user_preferences.captions[i]) + # end + + # default_home = env.params.body["default_home"]?.try { |x| Settings::HomePages.parse?(x) || Settings::UserHomePages.parse?(x) } + # default_home = CONFIG.default_user_preferences.default_home.to_s if !default_home + + # feed_menu = [] of String + # 4.times do |index| + # option = env.params.body["feed_menu[#{index}]"]?.try &.as(String) || "" + # if !option.empty? + # feed_menu << option + # end + # end + + # Old converters (TODO: remove or refactor them) + + module ClampInt + def self.to_json(value : Int32, json : JSON::Builder) + json.number value + end + + def self.from_json(value : JSON::PullParser) : Int32 + value.read_int.clamp(0, MAX_ITEMS_PER_PAGE).to_i32 + end + + def self.to_yaml(value : Int32, yaml : YAML::Nodes::Builder) + yaml.scalar value + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Int32 + node.value.clamp(0, MAX_ITEMS_PER_PAGE) + end + end + + module ProcessString + def self.to_json(value : String, json : JSON::Builder) + json.string value + end + + def self.from_json(value : JSON::PullParser) : String + HTML.escape(value.read_string[0, 100]) + end + + def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) + yaml.scalar value + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String + HTML.escape(node.value[0, 100]) + end + end + + module StringToArray + def self.to_json(value : Array(String), json : JSON::Builder) + json.array do + value.each do |element| + json.string element + end + end + end + + def self.from_json(value : JSON::PullParser) : Array(String) + begin + result = [] of String + value.read_array do + result << HTML.escape(value.read_string[0, 100]) + end + rescue ex + result = [HTML.escape(value.read_string[0, 100]), ""] + end + + result + end + + def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder) + yaml.sequence do + value.each do |element| + yaml.scalar element + end + end + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String) + begin + unless node.is_a?(YAML::Nodes::Sequence) + node.raise "Expected sequence, not #{node.class}" + end + + result = [] of String + node.nodes.each do |item| + unless item.is_a?(YAML::Nodes::Scalar) + node.raise "Expected scalar, not #{item.class}" + end + + result << HTML.escape(item.value[0, 100]) + end + rescue ex + if node.is_a?(YAML::Nodes::Scalar) + result = [HTML.escape(node.value[0, 100]), ""] + else + result = ["", ""] + end + end + + result + end + end + + def to_tuple + {% begin %} + { + {{*@type.instance_vars.map { |var| "#{var.name}: #{var.name}".id }}} + } + {% end %} + end +end # class Data + +# +# Datatype converters (also act as data validators) +# + +module Settings::Converters + # + # Generic enum conversion + # + module Generic(T) + extend self + + # From/To JSON + + def to_json(value : T, json : JSON::Builder) + value.to_json json + end + + def from_json(value : JSON::PullParser) : T? + begin + T.new value + rescue e : JSON::ParseException + # Be silent on invalid data and return nil + # This will fallback to the default value + end + end + + # From/To YAML + + def to_yaml(value : T, yaml : YAML::Nodes::Builder) + value.to_yaml yaml + end + + def from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : T? + begin + T.new ctx, node + rescue e : YAML::ParseException + # Be silent on invalid data and return nil + # This will fallback to the default value + end + end + end # module Generic + + # + # Themes enum conversion + # + module Theme + extend self + + # From String (.to_s is native of Enum type) + + def from_s(input : String?) : Themes? + return if !input + + case input.downcase + when "auto" ; Themes::Auto + when "light"; Themes::Light + when "dark" ; Themes::Dark + # Compatibility with old 'dark_mode' values + when "false"; Themes::Light + when "true" ; Themes::Dark + else + # Nothing, use default from initialization + end + end + + # From/To JSON + + def to_json(value : Themes, json : JSON::Builder) + json.string value.to_s + end + + def from_json(value : JSON::PullParser) : Themes? + return self.from_s(value.read_string) + end + + # From/To YAML + + def to_yaml(value : Themes, yaml : YAML::Nodes::Builder) + yaml.scalar value.to_s + end + + def from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Themes? + unless node.is_a?(YAML::Nodes::Scalar) + node.raise "Expected scalar, not #{node.class}" + end + + return self.from_s(node.value) + end + end # module theme + + # + # String to Boolean conversion + # + module Boolean + extend self + + def from_s(input : String?) : Bool? + return if !input + + case input.downcase + when "0", "off", "false"; false + when "1", "on", "true" ; true + else + nil # invalid input, do nothing. + end + end + + def to_s(input : Bool, numeric = false) : String + if numeric + return input ? "1" : "0" + else + return input ? "true" : "false" + end + end + end +end # module Settings::Converters + +struct VideoPreferences + include JSON::Serializable + + property annotations : 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 +end + +def process_video_params(query, preferences) + annotations = query["iv_load_policy"]?.try &.to_i? + autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } + comments = query["comments"]?.try &.split(",").map { |a| a.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 { |a| a.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 } + + if preferences + # region ||= preferences.region + annotations ||= preferences.annotations.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.to_s + 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 + end + + annotations ||= CONFIG.default_user_preferences.annotations.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.to_s + 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 + + annotations = annotations == 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 + + 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, + 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, + }) + + return params +end diff --git a/src/invidious/users.cr b/src/invidious/users.cr index aff76b53..4f46548f 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -29,264 +29,6 @@ struct User end end -struct Preferences - include JSON::Serializable - include YAML::Serializable - - property annotations : Bool = CONFIG.default_user_preferences.annotations - property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed - property autoplay : Bool = CONFIG.default_user_preferences.autoplay - property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect - - @[JSON::Field(converter: Preferences::StringToArray)] - @[YAML::Field(converter: Preferences::StringToArray)] - property captions : Array(String) = CONFIG.default_user_preferences.captions - - @[JSON::Field(converter: Preferences::StringToArray)] - @[YAML::Field(converter: Preferences::StringToArray)] - property comments : Array(String) = CONFIG.default_user_preferences.comments - property continue : Bool = CONFIG.default_user_preferences.continue - property continue_autoplay : Bool = CONFIG.default_user_preferences.continue_autoplay - - @[JSON::Field(converter: Preferences::BoolToString)] - @[YAML::Field(converter: Preferences::BoolToString)] - property dark_mode : String = CONFIG.default_user_preferences.dark_mode - property latest_only : Bool = CONFIG.default_user_preferences.latest_only - property listen : Bool = CONFIG.default_user_preferences.listen - property local : Bool = CONFIG.default_user_preferences.local - property vr_mode : Bool = CONFIG.default_user_preferences.vr_mode - property show_nick : Bool = CONFIG.default_user_preferences.show_nick - - @[JSON::Field(converter: Preferences::ProcessString)] - property locale : String = CONFIG.default_user_preferences.locale - - @[JSON::Field(converter: Preferences::ClampInt)] - property max_results : Int32 = CONFIG.default_user_preferences.max_results - property notifications_only : Bool = CONFIG.default_user_preferences.notifications_only - - @[JSON::Field(converter: Preferences::ProcessString)] - property player_style : String = CONFIG.default_user_preferences.player_style - - @[JSON::Field(converter: Preferences::ProcessString)] - property quality : String = CONFIG.default_user_preferences.quality - @[JSON::Field(converter: Preferences::ProcessString)] - property quality_dash : String = CONFIG.default_user_preferences.quality_dash - property default_home : String? = CONFIG.default_user_preferences.default_home - property feed_menu : Array(String) = CONFIG.default_user_preferences.feed_menu - property related_videos : Bool = CONFIG.default_user_preferences.related_videos - - @[JSON::Field(converter: Preferences::ProcessString)] - property sort : String = CONFIG.default_user_preferences.sort - property speed : Float32 = CONFIG.default_user_preferences.speed - property thin_mode : Bool = CONFIG.default_user_preferences.thin_mode - property unseen_only : Bool = CONFIG.default_user_preferences.unseen_only - property video_loop : Bool = CONFIG.default_user_preferences.video_loop - property extend_desc : Bool = CONFIG.default_user_preferences.extend_desc - property volume : Int32 = CONFIG.default_user_preferences.volume - - module BoolToString - def self.to_json(value : String, json : JSON::Builder) - json.string value - end - - def self.from_json(value : JSON::PullParser) : String - begin - result = value.read_string - - if result.empty? - CONFIG.default_user_preferences.dark_mode - else - result - end - rescue ex - if value.read_bool - "dark" - else - "light" - end - end - end - - def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) - yaml.scalar value - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String - unless node.is_a?(YAML::Nodes::Scalar) - node.raise "Expected scalar, not #{node.class}" - end - - case node.value - when "true" - "dark" - when "false" - "light" - when "" - CONFIG.default_user_preferences.dark_mode - else - node.value - end - end - end - - module ClampInt - def self.to_json(value : Int32, json : JSON::Builder) - json.number value - end - - def self.from_json(value : JSON::PullParser) : Int32 - value.read_int.clamp(0, MAX_ITEMS_PER_PAGE).to_i32 - end - - def self.to_yaml(value : Int32, yaml : YAML::Nodes::Builder) - yaml.scalar value - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Int32 - node.value.clamp(0, MAX_ITEMS_PER_PAGE) - end - end - - module FamilyConverter - def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder) - case value - when Socket::Family::UNSPEC - yaml.scalar nil - when Socket::Family::INET - yaml.scalar "ipv4" - when Socket::Family::INET6 - yaml.scalar "ipv6" - when Socket::Family::UNIX - raise "Invalid socket family #{value}" - end - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family - if node.is_a?(YAML::Nodes::Scalar) - case node.value.downcase - when "ipv4" - Socket::Family::INET - when "ipv6" - Socket::Family::INET6 - else - Socket::Family::UNSPEC - end - else - node.raise "Expected scalar, not #{node.class}" - end - end - end - - module URIConverter - def self.to_yaml(value : URI, yaml : YAML::Nodes::Builder) - yaml.scalar value.normalize! - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : URI - if node.is_a?(YAML::Nodes::Scalar) - URI.parse node.value - else - node.raise "Expected scalar, not #{node.class}" - end - end - end - - module ProcessString - def self.to_json(value : String, json : JSON::Builder) - json.string value - end - - def self.from_json(value : JSON::PullParser) : String - HTML.escape(value.read_string[0, 100]) - end - - def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) - yaml.scalar value - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String - HTML.escape(node.value[0, 100]) - end - end - - module StringToArray - def self.to_json(value : Array(String), json : JSON::Builder) - json.array do - value.each do |element| - json.string element - end - end - end - - def self.from_json(value : JSON::PullParser) : Array(String) - begin - result = [] of String - value.read_array do - result << HTML.escape(value.read_string[0, 100]) - end - rescue ex - result = [HTML.escape(value.read_string[0, 100]), ""] - end - - result - end - - def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder) - yaml.sequence do - value.each do |element| - yaml.scalar element - end - end - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String) - begin - unless node.is_a?(YAML::Nodes::Sequence) - node.raise "Expected sequence, not #{node.class}" - end - - result = [] of String - node.nodes.each do |item| - unless item.is_a?(YAML::Nodes::Scalar) - node.raise "Expected scalar, not #{item.class}" - end - - result << HTML.escape(item.value[0, 100]) - end - rescue ex - if node.is_a?(YAML::Nodes::Scalar) - result = [HTML.escape(node.value[0, 100]), ""] - else - result = ["", ""] - end - end - - result - end - end - - module StringToCookies - def self.to_yaml(value : HTTP::Cookies, yaml : YAML::Nodes::Builder) - (value.map { |c| "#{c.name}=#{c.value}" }).join("; ").to_yaml(yaml) - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : HTTP::Cookies - unless node.is_a?(YAML::Nodes::Scalar) - node.raise "Expected scalar, not #{node.class}" - end - - cookies = HTTP::Cookies.new - node.value.split(";").each do |cookie| - next if cookie.strip.empty? - name, value = cookie.split("=", 2) - cookies << HTTP::Cookie.new(name.strip, value.strip) - end - - cookies - end - end -end - def get_user(sid, headers, db, refresh = true) if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User) @@ -509,15 +251,12 @@ def get_subscription_feed(db, user, max_results = 40, page = 1) notifications.sort_by! { |video| video.published }.reverse! case user.preferences.sort - when "alphabetically" - notifications.sort_by! { |video| video.title } - when "alphabetically - reverse" - notifications.sort_by! { |video| video.title }.reverse! - when "channel name" - notifications.sort_by! { |video| video.author } - when "channel name - reverse" - notifications.sort_by! { |video| video.author }.reverse! - else nil # Ignore + when .alphabetically? ; notifications.sort_by! { |video| video.title } + when .alphabetically_reverse?; notifications.sort_by! { |video| video.title }.reverse! + when .channel_name? ; notifications.sort_by! { |video| video.author } + when .channel_name_reverse? ; notifications.sort_by! { |video| video.author }.reverse! + else + nil # Ignore end else if user.preferences.latest_only @@ -556,16 +295,13 @@ def get_subscription_feed(db, user, max_results = 40, page = 1) end case user.preferences.sort - when "published - reverse" - videos.sort_by! { |video| video.published } - when "alphabetically" - videos.sort_by! { |video| video.title } - when "alphabetically - reverse" - videos.sort_by! { |video| video.title }.reverse! - when "channel name" - videos.sort_by! { |video| video.author } - when "channel name - reverse" - videos.sort_by! { |video| video.author }.reverse! + when .alphabetically? ; videos.sort_by! { |video| video.title } + when .alphabetically_reverse?; videos.sort_by! { |video| video.title }.reverse! + when .channel_name? ; videos.sort_by! { |video| video.author } + when .channel_name_reverse? ; videos.sort_by! { |video| video.author }.reverse! + when .publication_date? ; videos.sort_by! { |video| video.published } + # when .publication_date_reverse?; videos.sort_by! { |video| video.published }.reverse! + # "Date reverse" wasn't here originally (why????) else nil # Ignore end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index d9c07142..02f8aa48 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -221,33 +221,6 @@ VIDEO_FORMATS = { "397" => {"ext" => "mp4", "height" => 480, "vcodec" => "av01.0.05M.08"}, } -struct VideoPreferences - include JSON::Serializable - - property annotations : 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 -end - struct Video include DB::Serializable @@ -1020,130 +993,6 @@ def process_continuation(db, query, plid, id) continuation end -def process_video_params(query, preferences) - annotations = query["iv_load_policy"]?.try &.to_i? - autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } - comments = query["comments"]?.try &.split(",").map { |a| a.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 { |a| a.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 } - - if preferences - # region ||= preferences.region - annotations ||= preferences.annotations.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 - end - - annotations ||= CONFIG.default_user_preferences.annotations.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 - - annotations = annotations == 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 - - 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, - 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, - }) - - return params -end - def build_thumbnails(id) return { {host: HOST_URL, height: 720, width: 1280, name: "maxres", url: "maxres"}, diff --git a/src/invidious/views/components/feed_menu.ecr b/src/invidious/views/components/feed_menu.ecr index 3dbeaf37..55c123ec 100644 --- a/src/invidious/views/components/feed_menu.ecr +++ b/src/invidious/views/components/feed_menu.ecr @@ -1,11 +1,11 @@
<% feed_menu = env.get("preferences").as(Preferences).feed_menu.dup %> <% if !env.get?("user") %> - <% feed_menu.reject! {|item| {"Subscriptions", "Playlists"}.includes? item} %> + <% feed_menu.reject! {|item| Settings::UserHomePages.names.includes? item} %> <% end %> <% feed_menu.each do |feed| %> - - <%= translate(locale, feed) %> + + <%= translate(locale, feed.to_s) %> <% end %>
diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index be021c59..27c87465 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -40,7 +40,7 @@
@@ -130,8 +130,8 @@
@@ -139,8 +139,8 @@
@@ -150,17 +150,16 @@ checked<% end %>>
- <% if env.get?("user") %> - <% feed_options = {"", "Popular", "Trending", "Subscriptions", "Playlists"} %> - <% else %> - <% feed_options = {"", "Popular", "Trending"} %> - <% end %> + <% + feed_options = Settings::HomePages.names + feed_options.concat(Settings::UserHomePages.names) if env.get?("user") + %>
@@ -170,11 +169,12 @@ <% (feed_options.size - 1).times do |index| %> <% end %> + <% if env.get? "user" %>
diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 7be95959..3269f40f 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -20,10 +20,10 @@ <% locale = LOCALES[env.get("preferences").as(Preferences).locale]? %> -<% dark_mode = env.get("preferences").as(Preferences).dark_mode %> +<% theme = env.get("preferences").as(Preferences).dark_mode %> --theme"> - +-theme"> +