mirror of
https://github.com/iv-org/invidious.git
synced 2026-07-08 14:46:46 -05:00
Merge fc935d5175d76c876968c5ead4d864c0af16c720 into 99390d065d69bb451dea8aedf9a1bbaa52cddf2a
This commit is contained in:
commit
65f28e210e
@ -296,12 +296,13 @@ https_only: false
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
|
||||||
##
|
##
|
||||||
## Enable/Disable the "Popular" tab on the main page.
|
## Enable/Disable specific pages on the main page.
|
||||||
##
|
##
|
||||||
## Accepted values: true, false
|
#pages_enabled:
|
||||||
## Default: true
|
# trending: false
|
||||||
|
# popular: true
|
||||||
|
# search: true
|
||||||
##
|
##
|
||||||
#popular_enabled: true
|
|
||||||
|
|
||||||
##
|
##
|
||||||
## Enable/Disable statstics (available at /api/v1/stats).
|
## Enable/Disable statstics (available at /api/v1/stats).
|
||||||
|
|||||||
@ -14,10 +14,6 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
depends_on:
|
|
||||||
invidious-db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: true
|
|
||||||
environment:
|
environment:
|
||||||
# Please read the following file for a comprehensive list of all available
|
# Please read the following file for a comprehensive list of all available
|
||||||
# configuration options and their associated syntax:
|
# configuration options and their associated syntax:
|
||||||
|
|||||||
1026
locales/en-US.json
1026
locales/en-US.json
File diff suppressed because it is too large
Load Diff
50
spec/invidious/config_spec.cr
Normal file
50
spec/invidious/config_spec.cr
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
require "../spec_helper"
|
||||||
|
require "../../src/invidious/jobs.cr"
|
||||||
|
require "../../src/invidious/jobs/*"
|
||||||
|
require "../../src/invidious/config.cr"
|
||||||
|
require "../../src/invidious/user/preferences.cr"
|
||||||
|
|
||||||
|
# Allow this file to be executed independently of other specs
|
||||||
|
{% if !@type.has_constant?("CONFIG") %}
|
||||||
|
CONFIG = Config.from_yaml("")
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
private def construct_config(yaml)
|
||||||
|
config = Config.from_yaml(yaml)
|
||||||
|
File.open(File::NULL, "w") { |io| config.process_deprecation(io) }
|
||||||
|
return config
|
||||||
|
end
|
||||||
|
|
||||||
|
Spectator.describe Config do
|
||||||
|
context "page_enabled" do
|
||||||
|
it "Can disable pages" do
|
||||||
|
config = construct_config <<-YAML
|
||||||
|
pages_enabled:
|
||||||
|
popular: false
|
||||||
|
search: false
|
||||||
|
YAML
|
||||||
|
|
||||||
|
expect(config.page_enabled?("trending")).to eq(false)
|
||||||
|
expect(config.page_enabled?("popular")).to eq(false)
|
||||||
|
expect(config.page_enabled?("search")).to eq(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "Takes precedence over popular_enabled" do
|
||||||
|
config = construct_config <<-YAML
|
||||||
|
popular_enabled: false
|
||||||
|
pages_enabled:
|
||||||
|
popular: true
|
||||||
|
YAML
|
||||||
|
|
||||||
|
expect(config.page_enabled?("popular")).to eq(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "Deprecated popular_enabled still works" do
|
||||||
|
config = construct_config <<-YAML
|
||||||
|
popular_enabled: false
|
||||||
|
YAML
|
||||||
|
|
||||||
|
expect(config.page_enabled?("popular")).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -185,7 +185,7 @@ module Kemal
|
|||||||
if is_dir
|
if is_dir
|
||||||
if config.is_a?(Hash) && config["dir_listing"] == true
|
if config.is_a?(Hash) && config["dir_listing"] == true
|
||||||
context.response.content_type = "text/html"
|
context.response.content_type = "text/html"
|
||||||
directory_listing(context.response, request_path, file_path)
|
directory_listing(context.response, Path[request_path], Path[file_path])
|
||||||
else
|
else
|
||||||
call_next(context)
|
call_next(context)
|
||||||
end
|
end
|
||||||
|
|||||||
@ -178,7 +178,7 @@ if (CONFIG.use_pubsub_feeds.is_a?(Bool) && CONFIG.use_pubsub_feeds.as(Bool)) ||
|
|||||||
Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, HMAC_KEY)
|
Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, HMAC_KEY)
|
||||||
end
|
end
|
||||||
|
|
||||||
if CONFIG.popular_enabled
|
if CONFIG.page_enabled?("popular")
|
||||||
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
|
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -200,6 +200,12 @@ end
|
|||||||
|
|
||||||
before_all do |env|
|
before_all do |env|
|
||||||
Invidious::Routes::BeforeAll.handle(env)
|
Invidious::Routes::BeforeAll.handle(env)
|
||||||
|
|
||||||
|
# If before_all flagged a halt (e.g. disabled page), stop the route handler.
|
||||||
|
if halted_status = env.get?("halted_status")
|
||||||
|
body = env.get?("halted_body").try(&.as(String)) || ""
|
||||||
|
halt env, status_code: halted_status.as(Int32), response: body
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Invidious::Routing.register_all
|
Invidious::Routing.register_all
|
||||||
|
|||||||
@ -73,6 +73,31 @@ struct HTTPProxyConfig
|
|||||||
property port : Int32
|
property port : Int32
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Structure used for global per-page feature toggles
|
||||||
|
record PagesEnabled,
|
||||||
|
trending : Bool = false,
|
||||||
|
popular : Bool = true,
|
||||||
|
search : Bool = true do
|
||||||
|
include YAML::Serializable
|
||||||
|
|
||||||
|
def [](key : String) : Bool
|
||||||
|
fetch(key) { raise KeyError.new("Unknown page '#{key}'") }
|
||||||
|
end
|
||||||
|
|
||||||
|
def []?(key : String) : Bool
|
||||||
|
fetch(key) { nil }
|
||||||
|
end
|
||||||
|
|
||||||
|
private def fetch(key : String, &)
|
||||||
|
case key
|
||||||
|
when "trending" then @trending
|
||||||
|
when "popular" then @popular
|
||||||
|
when "search" then @search
|
||||||
|
else yield
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class Config
|
class Config
|
||||||
include YAML::Serializable
|
include YAML::Serializable
|
||||||
|
|
||||||
@ -116,13 +141,37 @@ class Config
|
|||||||
|
|
||||||
# Used to tell Invidious it is behind a proxy, so links to resources should be https://
|
# Used to tell Invidious it is behind a proxy, so links to resources should be https://
|
||||||
property https_only : Bool?
|
property https_only : Bool?
|
||||||
|
|
||||||
# HMAC signing key for CSRF tokens and verifying pubsub subscriptions
|
# HMAC signing key for CSRF tokens and verifying pubsub subscriptions
|
||||||
property hmac_key : String = ""
|
property hmac_key : String = ""
|
||||||
# Domain to be used for links to resources on the site where an absolute URL is required
|
# Domain to be used for links to resources on the site where an absolute URL is required
|
||||||
property domain : String?
|
property domain : String?
|
||||||
# Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
|
# Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
|
||||||
property use_pubsub_feeds : Bool | Int32 = false
|
property use_pubsub_feeds : Bool | Int32 = false
|
||||||
|
|
||||||
|
# —————————————————————————————————————————————————————————————————————————————————————
|
||||||
|
|
||||||
|
# A @{{key}}_present variable is required for both fields in order to handle the precedence for
|
||||||
|
# the deprecated `popular_enabled` in relations to `pages_enabled`
|
||||||
|
|
||||||
|
# DEPRECATED: use `pages_enabled["popular"]` instead.
|
||||||
|
@[Deprecated("`popular_enabled` will be removed in a future release; use pages_enabled[\"popular\"] instead")]
|
||||||
|
@[YAML::Field(presence: true)]
|
||||||
property popular_enabled : Bool = true
|
property popular_enabled : Bool = true
|
||||||
|
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
property popular_enabled_present : Bool
|
||||||
|
|
||||||
|
# Global per-page feature toggles.
|
||||||
|
# Valid keys: "trending", "popular", "search"
|
||||||
|
# If someone sets both `popular_enabled` and `pages_enabled["popular"]`, the latter takes precedence.
|
||||||
|
@[YAML::Field(presence: true)]
|
||||||
|
property pages_enabled : PagesEnabled = PagesEnabled.from_yaml("")
|
||||||
|
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
property pages_enabled_present : Bool
|
||||||
|
# —————————————————————————————————————————————————————————————————————————————————————
|
||||||
|
|
||||||
property captcha_enabled : Bool = true
|
property captcha_enabled : Bool = true
|
||||||
property login_enabled : Bool = true
|
property login_enabled : Bool = true
|
||||||
property registration_enabled : Bool = true
|
property registration_enabled : Bool = true
|
||||||
@ -185,16 +234,17 @@ class Config
|
|||||||
when Bool
|
when Bool
|
||||||
return disabled
|
return disabled
|
||||||
when Array
|
when Array
|
||||||
if disabled.includes? option
|
disabled.includes?(option)
|
||||||
return true
|
|
||||||
else
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
return false
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Centralized page toggle with legacy fallback for `popular_enabled`
|
||||||
|
def page_enabled?(page : String) : Bool
|
||||||
|
return @pages_enabled[page]
|
||||||
|
end
|
||||||
|
|
||||||
def self.load
|
def self.load
|
||||||
# Load config from file or YAML string env var
|
# Load config from file or YAML string env var
|
||||||
env_config_file = "INVIDIOUS_CONFIG_FILE"
|
env_config_file = "INVIDIOUS_CONFIG_FILE"
|
||||||
@ -232,6 +282,12 @@ class Config
|
|||||||
begin
|
begin
|
||||||
config.{{ivar.id}} = ivar_type.from_yaml(env_value)
|
config.{{ivar.id}} = ivar_type.from_yaml(env_value)
|
||||||
success = true
|
success = true
|
||||||
|
|
||||||
|
# Update associated _present key if any
|
||||||
|
{% other_ivar = @type.instance_vars.find { |other_ivar| other_ivar.name == ivar.name + "_present" } %}
|
||||||
|
{% if other_ivar && (ann = other_ivar.annotation(YAML::Field)) && ann[:ignore] == true %}
|
||||||
|
config.{{other_ivar.name.id}} = true
|
||||||
|
{% end %}
|
||||||
rescue
|
rescue
|
||||||
# nop
|
# nop
|
||||||
end
|
end
|
||||||
@ -283,6 +339,8 @@ class Config
|
|||||||
exit(1)
|
exit(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
config.process_deprecation
|
||||||
|
|
||||||
# Build database_url from db.* if it's not set directly
|
# Build database_url from db.* if it's not set directly
|
||||||
if config.database_url.to_s.empty?
|
if config.database_url.to_s.empty?
|
||||||
if db = config.db
|
if db = config.db
|
||||||
@ -320,4 +378,24 @@ class Config
|
|||||||
|
|
||||||
return config
|
return config
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Processes deprecated values
|
||||||
|
#
|
||||||
|
# Warns when they are set and handles any precedence issue that may arise when present alongside a successor attribute
|
||||||
|
#
|
||||||
|
# This method is public as to allow specs to test the behavior without going through #load
|
||||||
|
#
|
||||||
|
# :nodoc:
|
||||||
|
def process_deprecation(log_io : IO = STDOUT)
|
||||||
|
# Handle deprecated popular_enabled config and warn if it is set
|
||||||
|
if self.popular_enabled_present
|
||||||
|
log_io.puts "Warning: `popular_enabled` has been deprecated and replaced by the `pages_enabled` config"
|
||||||
|
log_io.puts "If both are set `pages_enabled` will take precedence over `popular_enabled`"
|
||||||
|
|
||||||
|
# Only use popular_enabled value when pages_enabled is unset
|
||||||
|
if !self.pages_enabled_present
|
||||||
|
self.pages_enabled = self.pages_enabled.copy_with(popular: self.popular_enabled)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -345,36 +345,20 @@ def fetch_playlist(plid : String)
|
|||||||
initial_data = YoutubeAPI.browse("VL" + plid, params: "")
|
initial_data = YoutubeAPI.browse("VL" + plid, params: "")
|
||||||
|
|
||||||
playlist_sidebar_renderer = initial_data.dig?("sidebar", "playlistSidebarRenderer", "items")
|
playlist_sidebar_renderer = initial_data.dig?("sidebar", "playlistSidebarRenderer", "items")
|
||||||
raise InfoException.new("Could not extract playlistSidebarRenderer.") if !playlist_sidebar_renderer
|
header_renderer = initial_data.dig?("header", "playlistHeaderRenderer")
|
||||||
|
|
||||||
playlist_info = playlist_sidebar_renderer.dig?(0, "playlistSidebarPrimaryInfoRenderer")
|
raise InfoException.new("Could not extract playlist data.") if playlist_sidebar_renderer.nil? && header_renderer.nil?
|
||||||
raise InfoException.new("Could not extract playlist info") if !playlist_info
|
|
||||||
|
|
||||||
title = playlist_info.dig?("title", "runs", 0, "text").try &.as_s || ""
|
|
||||||
|
|
||||||
desc_item = playlist_info["description"]?
|
|
||||||
|
|
||||||
description_txt = desc_item.try &.["runs"]?.try &.as_a
|
|
||||||
.map(&.["text"].as_s).join("") || desc_item.try &.["simpleText"]?.try &.as_s || ""
|
|
||||||
|
|
||||||
description_html = desc_item.try &.["runs"]?.try &.as_a
|
|
||||||
.try { |run| content_to_comment_html(run).try &.to_s } || "<p></p>"
|
|
||||||
|
|
||||||
thumbnail = playlist_info.dig?(
|
|
||||||
"thumbnailRenderer", "playlistVideoThumbnailRenderer",
|
|
||||||
"thumbnail", "thumbnails", 0, "url"
|
|
||||||
).try &.as_s || playlist_info.dig?(
|
|
||||||
"thumbnailRenderer", "playlistCustomThumbnailRenderer",
|
|
||||||
"thumbnail", "thumbnails", 0, "url"
|
|
||||||
).try &.as_s
|
|
||||||
|
|
||||||
views = 0_i64
|
views = 0_i64
|
||||||
updated = Time.utc
|
updated = Time.utc
|
||||||
video_count = 0
|
video_count = 0
|
||||||
|
subtitle = extract_text(header_renderer.try &.["subtitle"]?)
|
||||||
|
|
||||||
subtitle = extract_text(initial_data.dig?("header", "playlistHeaderRenderer", "subtitle"))
|
# stats parsing is the same format in both sidebar and header renderers
|
||||||
|
stats_source = playlist_sidebar_renderer.try(&.dig?(0, "playlistSidebarPrimaryInfoRenderer", "stats")) ||
|
||||||
|
header_renderer.try(&.["stats"]?)
|
||||||
|
|
||||||
playlist_info["stats"]?.try &.as_a.each do |stat|
|
stats_source.try &.as_a.each do |stat|
|
||||||
text = stat["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || stat["simpleText"]?.try &.as_s
|
text = stat["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || stat["simpleText"]?.try &.as_s
|
||||||
next if !text
|
next if !text
|
||||||
|
|
||||||
@ -389,20 +373,63 @@ def fetch_playlist(plid : String)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if playlist_sidebar_renderer.size < 2
|
if playlist_sidebar_renderer
|
||||||
author = ""
|
playlist_info = playlist_sidebar_renderer.dig?(0, "playlistSidebarPrimaryInfoRenderer")
|
||||||
author_thumbnail = ""
|
raise InfoException.new("Could not extract playlist info") if !playlist_info
|
||||||
ucid = ""
|
|
||||||
|
title = playlist_info.dig?("title", "runs", 0, "text").try &.as_s || ""
|
||||||
|
|
||||||
|
desc_item = playlist_info["description"]?
|
||||||
|
|
||||||
|
description_txt = desc_item.try &.["runs"]?.try &.as_a
|
||||||
|
.map(&.["text"].as_s).join("") || desc_item.try &.["simpleText"]?.try &.as_s || ""
|
||||||
|
|
||||||
|
description_html = desc_item.try &.["runs"]?.try &.as_a
|
||||||
|
.try { |run| content_to_comment_html(run).try &.to_s } || "<p></p>"
|
||||||
|
|
||||||
|
thumbnail = playlist_info.dig?(
|
||||||
|
"thumbnailRenderer", "playlistVideoThumbnailRenderer",
|
||||||
|
"thumbnail", "thumbnails", 0, "url"
|
||||||
|
).try &.as_s || playlist_info.dig?(
|
||||||
|
"thumbnailRenderer", "playlistCustomThumbnailRenderer",
|
||||||
|
"thumbnail", "thumbnails", 0, "url"
|
||||||
|
).try &.as_s
|
||||||
|
|
||||||
|
if playlist_sidebar_renderer.size < 2
|
||||||
|
author = ""
|
||||||
|
author_thumbnail = ""
|
||||||
|
ucid = ""
|
||||||
|
else
|
||||||
|
author_info = playlist_sidebar_renderer[1].dig?(
|
||||||
|
"playlistSidebarSecondaryInfoRenderer", "videoOwner", "videoOwnerRenderer"
|
||||||
|
)
|
||||||
|
|
||||||
|
raise InfoException.new("Could not extract author info") if !author_info
|
||||||
|
|
||||||
|
author = author_info.dig?("title", "runs", 0, "text").try &.as_s || ""
|
||||||
|
author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url").try &.as_s || ""
|
||||||
|
ucid = author_info.dig?("title", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || ""
|
||||||
|
end
|
||||||
else
|
else
|
||||||
author_info = playlist_sidebar_renderer[1].dig?(
|
# Sidebar gone — fall back to playlistHeaderRenderer (YouTube changed response format)
|
||||||
"playlistSidebarSecondaryInfoRenderer", "videoOwner", "videoOwnerRenderer"
|
hr = header_renderer.not_nil!
|
||||||
)
|
|
||||||
|
|
||||||
raise InfoException.new("Could not extract author info") if !author_info
|
title = extract_text(hr["title"]?) || ""
|
||||||
|
|
||||||
author = author_info.dig?("title", "runs", 0, "text").try &.as_s || ""
|
desc_item = hr["descriptionText"]? || hr["description"]?
|
||||||
author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url").try &.as_s || ""
|
description_txt = desc_item.try &.["runs"]?.try &.as_a
|
||||||
ucid = author_info.dig?("title", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || ""
|
.map(&.["text"].as_s).join("") || desc_item.try &.["simpleText"]?.try &.as_s || ""
|
||||||
|
description_html = desc_item.try &.["runs"]?.try &.as_a
|
||||||
|
.try { |run| content_to_comment_html(run).try &.to_s } || "<p></p>"
|
||||||
|
|
||||||
|
thumbnail = hr.dig?("thumbnail", "thumbnails", 0, "url").try &.as_s
|
||||||
|
|
||||||
|
owner_run = hr.dig?("ownerText", "runs", 0)
|
||||||
|
author = owner_run.try &.["text"]?.try &.as_s || ""
|
||||||
|
ucid = owner_run.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || ""
|
||||||
|
author_thumbnail = hr.dig?("channelAvatar", "decoratedAvatarViewModel", "avatar",
|
||||||
|
"avatarViewModel", "image", "sources", 0, "url").try &.as_s ||
|
||||||
|
hr.dig?("avatar", "thumbnails", 0, "url").try &.as_s || ""
|
||||||
end
|
end
|
||||||
|
|
||||||
return Playlist.new({
|
return Playlist.new({
|
||||||
|
|||||||
@ -313,6 +313,11 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
return error_json(403, "Invalid videoId")
|
return error_json(403, "Invalid videoId")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Prevent duplicate videos in the same playlist
|
||||||
|
if Invidious::Database::PlaylistVideos.select_index(plid, video_id)
|
||||||
|
return error_json(409, "Video already exists in this playlist")
|
||||||
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
video = get_video(video_id)
|
video = get_video(video_id)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
|
|||||||
@ -29,11 +29,6 @@ module Invidious::Routes::API::V1::Feeds
|
|||||||
|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
if !CONFIG.popular_enabled
|
|
||||||
error_message = {"error" => "Administrator has disabled this endpoint."}.to_json
|
|
||||||
haltf env, 403, error_message
|
|
||||||
end
|
|
||||||
|
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.array do
|
json.array do
|
||||||
popular_videos.each do |video|
|
popular_videos.each do |video|
|
||||||
|
|||||||
@ -115,6 +115,8 @@ module Invidious::Routes::BeforeAll
|
|||||||
preferences.locale = locale
|
preferences.locale = locale
|
||||||
env.set "preferences", preferences
|
env.set "preferences", preferences
|
||||||
|
|
||||||
|
path = env.request.path
|
||||||
|
|
||||||
# Allow media resources to be loaded from google servers
|
# Allow media resources to be loaded from google servers
|
||||||
# TODO: check if *.youtube.com can be removed
|
# TODO: check if *.youtube.com can be removed
|
||||||
#
|
#
|
||||||
@ -130,7 +132,7 @@ module Invidious::Routes::BeforeAll
|
|||||||
env.response.headers["Content-Security-Policy"] = env.response.headers["Content-Security-Policy"].gsub("media-src", "media-src https://*.googlevideo.com:443 https://*.youtube.com:443")
|
env.response.headers["Content-Security-Policy"] = env.response.headers["Content-Security-Policy"].gsub("media-src", "media-src https://*.googlevideo.com:443 https://*.youtube.com:443")
|
||||||
end
|
end
|
||||||
|
|
||||||
current_page = env.request.path
|
current_page = path
|
||||||
if env.request.query
|
if env.request.query
|
||||||
query = HTTP::Params.parse(env.request.query.not_nil!)
|
query = HTTP::Params.parse(env.request.query.not_nil!)
|
||||||
|
|
||||||
@ -142,5 +144,70 @@ module Invidious::Routes::BeforeAll
|
|||||||
end
|
end
|
||||||
|
|
||||||
env.set "current_page", URI.encode_www_form(current_page)
|
env.set "current_page", URI.encode_www_form(current_page)
|
||||||
|
|
||||||
|
page_key = case path
|
||||||
|
when "/feed/popular", "/api/v1/popular"
|
||||||
|
"popular"
|
||||||
|
when "/feed/trending", "/api/v1/trending"
|
||||||
|
"trending"
|
||||||
|
when "/api/v1/search", "/api/v1/search/suggestions"
|
||||||
|
"search"
|
||||||
|
when .starts_with?("/api/v1/hashtag/")
|
||||||
|
"search"
|
||||||
|
when "/search", "/results"
|
||||||
|
# Handled by the search route (subscription-only mode when search disabled)
|
||||||
|
nil
|
||||||
|
when .starts_with?("/hashtag/")
|
||||||
|
"search"
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if page_key && !CONFIG.page_enabled?(page_key)
|
||||||
|
if path.starts_with?("/api/")
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
env.set "halted_body", {error: "Administrator has disabled this endpoint."}.to_json
|
||||||
|
else
|
||||||
|
preferences = env.get("preferences").as(Preferences)
|
||||||
|
locale = preferences.locale
|
||||||
|
dark_mode = preferences.dark_mode
|
||||||
|
theme_class = dark_mode.blank? ? "no" : dark_mode
|
||||||
|
error_message = I18n.translate(locale, "#{page_key}_page_disabled")
|
||||||
|
|
||||||
|
env.response.content_type = "text/html"
|
||||||
|
env.set "halted_body", <<-HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="#{locale}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Error - Invidious</title>
|
||||||
|
<link rel="stylesheet" href="/css/pure-min.css">
|
||||||
|
<link rel="stylesheet" href="/css/grids-responsive-min.css">
|
||||||
|
<link rel="stylesheet" href="/css/ionicons.min.css">
|
||||||
|
<link rel="stylesheet" href="/css/default.css">
|
||||||
|
</head>
|
||||||
|
<body class="#{theme_class}-theme">
|
||||||
|
<div class="pure-g">
|
||||||
|
<div class="pure-u-1 pure-u-xl-20-24" id="contents">
|
||||||
|
<div class="pure-g navbar h-box">
|
||||||
|
<div class="pure-u-1 pure-u-md-16-24">
|
||||||
|
<a href="/" class="index-link pure-menu-heading">Invidious</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-box" style="margin-top: 2em;">
|
||||||
|
<p>#{error_message}</p>
|
||||||
|
<p><a href="/">← #{I18n.translate(locale, "Back")}</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML
|
||||||
|
end
|
||||||
|
|
||||||
|
env.set "halted_status", 403
|
||||||
|
return
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
114
src/invidious/routes/debug_config.cr
Normal file
114
src/invidious/routes/debug_config.cr
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# Debug route to verify configuration is loaded correctly
|
||||||
|
# This can help diagnose issues with pages_enabled configuration
|
||||||
|
|
||||||
|
module Invidious::Routes::DebugConfig
|
||||||
|
def self.show(env)
|
||||||
|
# Only allow access to admins or in development mode
|
||||||
|
if CONFIG.admins.empty? || (user = env.get? "user")
|
||||||
|
admin_user = user.try &.as(User)
|
||||||
|
if !admin_user || !CONFIG.admins.includes?(admin_user.email)
|
||||||
|
return error_template(403, "Administrator privileges required")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# If no user is logged in and admins are configured, deny access
|
||||||
|
return error_template(403, "Administrator privileges required")
|
||||||
|
end
|
||||||
|
|
||||||
|
html = <<-HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Configuration Debug - Invidious</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: monospace; padding: 20px; }
|
||||||
|
.enabled { color: green; }
|
||||||
|
.disabled { color: red; }
|
||||||
|
table { border-collapse: collapse; margin: 20px 0; }
|
||||||
|
td, th { border: 1px solid #ccc; padding: 8px; text-align: left; }
|
||||||
|
th { background: #f0f0f0; }
|
||||||
|
pre { background: #f5f5f5; padding: 10px; overflow-x: auto; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Invidious Configuration Debug</h1>
|
||||||
|
|
||||||
|
<h2>Pages Configuration</h2>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Page</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Configuration Value</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Popular</td>
|
||||||
|
<td class="#{CONFIG.page_enabled?("popular") ? "enabled" : "disabled"}">
|
||||||
|
#{CONFIG.page_enabled?("popular") ? "ENABLED" : "DISABLED"}
|
||||||
|
</td>
|
||||||
|
<td>#{CONFIG.pages_enabled.popular}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Trending</td>
|
||||||
|
<td class="#{CONFIG.page_enabled?("trending") ? "enabled" : "disabled"}">
|
||||||
|
#{CONFIG.page_enabled?("trending") ? "ENABLED" : "DISABLED"}
|
||||||
|
</td>
|
||||||
|
<td>#{CONFIG.pages_enabled.trending}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Search</td>
|
||||||
|
<td class="#{CONFIG.page_enabled?("search") ? "enabled" : "disabled"}">
|
||||||
|
#{CONFIG.page_enabled?("search") ? "ENABLED" : "DISABLED"}
|
||||||
|
</td>
|
||||||
|
<td>#{CONFIG.pages_enabled.search}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Configuration Flags</h2>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Flag</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>pages_enabled_present</td>
|
||||||
|
<td>#{CONFIG.pages_enabled_present}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>popular_enabled_present (deprecated)</td>
|
||||||
|
<td>#{CONFIG.popular_enabled_present}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>popular_enabled (deprecated)</td>
|
||||||
|
<td>#{CONFIG.popular_enabled}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Blocked Routes</h2>
|
||||||
|
<p>The following routes should be blocked based on current configuration:</p>
|
||||||
|
<ul>
|
||||||
|
#{!CONFIG.page_enabled?("popular") ? "<li>/feed/popular</li><li>/api/v1/popular</li>" : ""}
|
||||||
|
#{!CONFIG.page_enabled?("trending") ? "<li>/feed/trending</li><li>/api/v1/trending</li>" : ""}
|
||||||
|
#{!CONFIG.page_enabled?("search") ? "<li>/search</li><li>/api/v1/search</li>" : ""}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Test Links</h2>
|
||||||
|
<p>Click these links to verify they are properly blocked:</p>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/feed/popular">/feed/popular</a> - #{CONFIG.page_enabled?("popular") ? "Should work" : "Should be blocked"}</li>
|
||||||
|
<li><a href="/feed/trending">/feed/trending</a> - #{CONFIG.page_enabled?("trending") ? "Should work" : "Should be blocked"}</li>
|
||||||
|
<li><a href="/search">/search</a> - #{CONFIG.page_enabled?("search") ? "Should work" : "Should be blocked"}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Raw Configuration</h2>
|
||||||
|
<pre>pages_enabled: #{CONFIG.pages_enabled.inspect}</pre>
|
||||||
|
|
||||||
|
<h2>Environment Check</h2>
|
||||||
|
<pre>INVIDIOUS_CONFIG present: #{ENV.has_key?("INVIDIOUS_CONFIG")}</pre>
|
||||||
|
<pre>INVIDIOUS_PAGES_ENABLED present: #{ENV.has_key?("INVIDIOUS_PAGES_ENABLED")}</pre>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML
|
||||||
|
|
||||||
|
env.response.content_type = "text/html"
|
||||||
|
env.response.print html
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -330,6 +330,15 @@ module Invidious::Routes::Playlists
|
|||||||
|
|
||||||
video_id = env.params.query["video_id"]
|
video_id = env.params.query["video_id"]
|
||||||
|
|
||||||
|
# Prevent duplicate videos in the same playlist
|
||||||
|
if Invidious::Database::PlaylistVideos.select_index(playlist_id, video_id)
|
||||||
|
if redirect
|
||||||
|
return env.redirect referer
|
||||||
|
else
|
||||||
|
return error_json(409, "Video already exists in this playlist")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
video = get_video(video_id)
|
video = get_video(video_id)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
|
|||||||
@ -201,9 +201,11 @@ module Invidious::Routes::PreferencesRoute
|
|||||||
end
|
end
|
||||||
CONFIG.default_user_preferences.feed_menu = admin_feed_menu
|
CONFIG.default_user_preferences.feed_menu = admin_feed_menu
|
||||||
|
|
||||||
popular_enabled = env.params.body["popular_enabled"]?.try &.as(String)
|
CONFIG.pages_enabled = PagesEnabled.new(
|
||||||
popular_enabled ||= "off"
|
popular: (env.params.body["popular_enabled"]?.try &.as(String) || "off") == "on",
|
||||||
CONFIG.popular_enabled = popular_enabled == "on"
|
trending: (env.params.body["trending_enabled"]?.try &.as(String) || "off") == "on",
|
||||||
|
search: (env.params.body["search_enabled"]?.try &.as(String) || "off") == "on",
|
||||||
|
)
|
||||||
|
|
||||||
captcha_enabled = env.params.body["captcha_enabled"]?.try &.as(String)
|
captcha_enabled = env.params.body["captcha_enabled"]?.try &.as(String)
|
||||||
captcha_enabled ||= "off"
|
captcha_enabled ||= "off"
|
||||||
|
|||||||
@ -40,52 +40,78 @@ module Invidious::Routes::Search
|
|||||||
preferences = env.get("preferences").as(Preferences)
|
preferences = env.get("preferences").as(Preferences)
|
||||||
locale = preferences.locale
|
locale = preferences.locale
|
||||||
|
|
||||||
|
search_disabled = !CONFIG.page_enabled?("search")
|
||||||
|
|
||||||
region = env.params.query["region"]? || preferences.region
|
region = env.params.query["region"]? || preferences.region
|
||||||
|
|
||||||
query = Invidious::Search::Query.new(env.params.query, :regular, region)
|
query = Invidious::Search::Query.new(env.params.query, :regular, region)
|
||||||
|
|
||||||
|
# empty query → show homepage
|
||||||
if query.empty?
|
if query.empty?
|
||||||
# Display the full page search box implemented in #1977
|
|
||||||
env.set "search", ""
|
env.set "search", ""
|
||||||
templated "search_homepage", navbar_search: false
|
return templated "search_homepage", navbar_search: false
|
||||||
else
|
end
|
||||||
user = env.get? "user"
|
|
||||||
|
|
||||||
# An URL was copy/pasted in the search box.
|
# When YouTube search is disabled, force subscription-only mode
|
||||||
# Redirect the user to the appropriate page.
|
if search_disabled
|
||||||
if query.url?
|
user = env.get?("user")
|
||||||
return env.redirect UrlSanitizer.process(query.text).to_s
|
if !user
|
||||||
|
return error_template(403, I18n.translate(locale, "search_subscriptions_login_required"))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
user = user.as(User)
|
||||||
|
|
||||||
begin
|
begin
|
||||||
if user
|
items = Invidious::Search::Processors.subscriptions_and_playlists(query, user)
|
||||||
items = query.process(user.as(User))
|
|
||||||
else
|
|
||||||
items = query.process
|
|
||||||
end
|
|
||||||
rescue ex : ChannelSearchException
|
|
||||||
return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.")
|
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(500, ex)
|
return error_template 500, ex
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_url = Invidious::Frontend::Misc.redirect_url(env)
|
redirect_url = Invidious::Frontend::Misc.redirect_url(env)
|
||||||
|
|
||||||
# Pagination
|
|
||||||
page_nav_html = Frontend::Pagination.nav_numeric(locale,
|
page_nav_html = Frontend::Pagination.nav_numeric(locale,
|
||||||
base_url: "/search?#{query.to_http_params}",
|
base_url: "/search?#{query.to_http_params}",
|
||||||
current_page: query.page,
|
current_page: query.page,
|
||||||
show_next: (items.size >= 20)
|
show_next: (items.size >= 20)
|
||||||
)
|
)
|
||||||
|
|
||||||
if query.type == Invidious::Search::Query::Type::Channel
|
env.set "search", query.text
|
||||||
env.set "search", "channel:#{query.channel} #{query.text}"
|
env.set "subscription_only_search", true
|
||||||
else
|
|
||||||
env.set "search", query.text
|
|
||||||
end
|
|
||||||
|
|
||||||
templated "search"
|
return templated "search"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# non‐empty query → process it
|
||||||
|
user = env.get?("user")
|
||||||
|
if query.url?
|
||||||
|
return env.redirect UrlSanitizer.process(query.text).to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
items = user ? query.process(user.as(User)) : query.process
|
||||||
|
rescue ex : ChannelSearchException
|
||||||
|
return error_template 404, "Unable to find channel with id "#{HTML.escape(ex.channel)}"…"
|
||||||
|
rescue ex
|
||||||
|
return error_template 500, ex
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_url = Invidious::Frontend::Misc.redirect_url(env)
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
page_nav_html = Frontend::Pagination.nav_numeric(locale,
|
||||||
|
base_url: "/search?#{query.to_http_params}",
|
||||||
|
current_page: query.page,
|
||||||
|
show_next: (items.size >= 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
# If it's a channel search, prefix the box; otherwise just show the text
|
||||||
|
if query.type == Invidious::Search::Query::Type::Channel
|
||||||
|
env.set "search", "channel:#{query.channel} #{query.text}"
|
||||||
|
else
|
||||||
|
env.set "search", query.text
|
||||||
|
end
|
||||||
|
|
||||||
|
templated "search"
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.hashtag(env : HTTP::Server::Context)
|
def self.hashtag(env : HTTP::Server::Context)
|
||||||
|
|||||||
@ -52,5 +52,57 @@ module Invidious::Search
|
|||||||
as: ChannelVideo
|
as: ChannelVideo
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Search subscriptions AND saved playlists (used when YouTube search is disabled)
|
||||||
|
def subscriptions_and_playlists(query : Query, user : Invidious::User) : Array(ChannelVideo)
|
||||||
|
view_name = "subscriptions_#{sha256(user.email)}"
|
||||||
|
offset = (query.page - 1) * 20
|
||||||
|
|
||||||
|
# Search subscription videos via the materialized view
|
||||||
|
sub_results = PG_DB.query_all("
|
||||||
|
SELECT id, title, published, updated, ucid, author, length_seconds
|
||||||
|
FROM (
|
||||||
|
SELECT *,
|
||||||
|
to_tsvector(#{view_name}.title) ||
|
||||||
|
to_tsvector(#{view_name}.author)
|
||||||
|
as document
|
||||||
|
FROM #{view_name}
|
||||||
|
) v_search WHERE v_search.document @@ plainto_tsquery($1)
|
||||||
|
ORDER BY published DESC
|
||||||
|
LIMIT 20 OFFSET $2;",
|
||||||
|
query.text, offset,
|
||||||
|
as: ChannelVideo
|
||||||
|
)
|
||||||
|
|
||||||
|
# Search playlist videos from user's playlists (both created and saved)
|
||||||
|
playlist_results = PG_DB.query_all("
|
||||||
|
SELECT pv.id, pv.title, pv.published, pv.published AS updated,
|
||||||
|
pv.ucid, pv.author, pv.length_seconds
|
||||||
|
FROM playlist_videos pv
|
||||||
|
INNER JOIN playlists p ON p.id = pv.plid
|
||||||
|
WHERE p.author = $1
|
||||||
|
AND (to_tsvector(pv.title) || to_tsvector(pv.author)) @@ plainto_tsquery($2)
|
||||||
|
ORDER BY pv.published DESC
|
||||||
|
LIMIT 20 OFFSET $3;",
|
||||||
|
user.email, query.text, offset,
|
||||||
|
as: ChannelVideo
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge, deduplicate by video ID, sort by published date, limit to 20
|
||||||
|
seen = Set(String).new
|
||||||
|
combined = [] of ChannelVideo
|
||||||
|
|
||||||
|
(sub_results + playlist_results)
|
||||||
|
.sort_by { |v| v.published }
|
||||||
|
.reverse!
|
||||||
|
.each do |video|
|
||||||
|
next if seen.includes?(video.id)
|
||||||
|
seen.add(video.id)
|
||||||
|
combined << video
|
||||||
|
break if combined.size >= 20
|
||||||
|
end
|
||||||
|
|
||||||
|
return combined
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -3,6 +3,18 @@
|
|||||||
<% if !env.get?("user") %>
|
<% if !env.get?("user") %>
|
||||||
<% feed_menu.reject! {|item| {"Subscriptions", "Playlists"}.includes? item} %>
|
<% feed_menu.reject! {|item| {"Subscriptions", "Playlists"}.includes? item} %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% feed_menu.reject! do |feed|
|
||||||
|
case feed
|
||||||
|
when "Popular"
|
||||||
|
!CONFIG.page_enabled?("popular")
|
||||||
|
when "Trending"
|
||||||
|
!CONFIG.page_enabled?("trending")
|
||||||
|
when ""
|
||||||
|
!CONFIG.page_enabled?("search")
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end %>
|
||||||
<% feed_menu.each do |feed| %>
|
<% feed_menu.each do |feed| %>
|
||||||
<a href="/feed/<%= feed.downcase %>" class="feed-menu-item pure-menu-heading">
|
<a href="/feed/<%= feed.downcase %>" class="feed-menu-item pure-menu-heading">
|
||||||
<%= I18n.translate(locale, feed) %>
|
<%= I18n.translate(locale, feed) %>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<form class="pure-form" action="/search" method="get">
|
<form class="pure-form" action="/search" method="get">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
<% search_placeholder = CONFIG.page_enabled?("search") ? I18n.translate(locale, "search") : I18n.translate(locale, "search_subscriptions_placeholder") %>
|
||||||
<input type="search" id="searchbox" autocorrect="off"
|
<input type="search" id="searchbox" autocorrect="off"
|
||||||
autocapitalize="none" spellcheck="false" <% if autofocus %>autofocus<% end %>
|
autocapitalize="none" spellcheck="false" <% if autofocus %>autofocus<% end %>
|
||||||
name="q" placeholder="<%= I18n.translate(locale, "search") %>"
|
name="q" placeholder="<%= I18n.translate(locale, "search") %>"
|
||||||
|
|||||||
@ -3,9 +3,18 @@
|
|||||||
<link rel="stylesheet" href="/css/search.css?v=<%= ASSET_COMMIT %>">
|
<link rel="stylesheet" href="/css/search.css?v=<%= ASSET_COMMIT %>">
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% subscription_only = env.get?("subscription_only_search") %>
|
||||||
|
|
||||||
|
<% if subscription_only %>
|
||||||
|
<div class="h-box" style="margin-bottom: 0.5em;">
|
||||||
|
<p style="color: var(--text-color-muted, #888);"><i class="icon ion-ios-lock"></i> <%= I18n.translate(locale, "search_subscriptions_mode_notice") %></p>
|
||||||
|
</div>
|
||||||
|
<hr/>
|
||||||
|
<% else %>
|
||||||
<!-- Search redirection and filtering UI -->
|
<!-- Search redirection and filtering UI -->
|
||||||
<%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
|
<%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
|
||||||
<hr/>
|
<hr/>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
|
||||||
<%- if items.empty? -%>
|
<%- if items.empty? -%>
|
||||||
|
|||||||
@ -17,4 +17,11 @@
|
|||||||
<% autofocus = true %><%= rendered "components/search_box" %>
|
<% autofocus = true %><%= rendered "components/search_box" %>
|
||||||
</div>
|
</div>
|
||||||
<div class="pure-u-1-4"></div>
|
<div class="pure-u-1-4"></div>
|
||||||
|
<% if !CONFIG.page_enabled?("search") %>
|
||||||
|
<div class="pure-u-1-4"></div>
|
||||||
|
<div class="pure-u-1 pure-u-md-12-24" style="text-align: center; margin-top: 0.5em;">
|
||||||
|
<p style="color: #888;"><%= I18n.translate(locale, "search_subscriptions_hint") %></p>
|
||||||
|
</div>
|
||||||
|
<div class="pure-u-1-4"></div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -182,11 +182,20 @@
|
|||||||
<input name="thin_mode" id="thin_mode" type="checkbox" <% if preferences.thin_mode %>checked<% end %>>
|
<input name="thin_mode" id="thin_mode" type="checkbox" <% if preferences.thin_mode %>checked<% end %>>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if env.get?("user") %>
|
<%
|
||||||
<% feed_options = {"", "Popular", "Trending", "Subscriptions", "Playlists"} %>
|
# Build feed options based on enabled pages
|
||||||
<% else %>
|
feed_options = [] of String
|
||||||
<% feed_options = {"", "Popular", "Trending"} %>
|
# Empty string represents Search page
|
||||||
<% end %>
|
feed_options << "" if CONFIG.page_enabled?("search")
|
||||||
|
feed_options << "Popular" if CONFIG.page_enabled?("popular")
|
||||||
|
feed_options << "Trending" if CONFIG.page_enabled?("trending")
|
||||||
|
if env.get?("user")
|
||||||
|
feed_options << "Subscriptions"
|
||||||
|
feed_options << "Playlists"
|
||||||
|
end
|
||||||
|
# Always add "none" option as fallback
|
||||||
|
feed_options << "<none>" if !feed_options.includes?("<none>")
|
||||||
|
%>
|
||||||
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="default_home"><%= I18n.translate(locale, "preferences_default_home_label") %></label>
|
<label for="default_home"><%= I18n.translate(locale, "preferences_default_home_label") %></label>
|
||||||
@ -305,6 +314,10 @@
|
|||||||
<input name="popular_enabled" id="popular_enabled" type="checkbox" <% if CONFIG.popular_enabled %>checked<% end %>>
|
<input name="popular_enabled" id="popular_enabled" type="checkbox" <% if CONFIG.popular_enabled %>checked<% end %>>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="pure-control-group">
|
||||||
|
<label for="search_enabled"><%= I18n.translate(locale, "preferences_search_enabled_label") %></label>
|
||||||
|
<input name="search_enabled" id="search_enabled" type="checkbox" <% if CONFIG.page_enabled?("search") %>checked<% end %>>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="captcha_enabled"><%= I18n.translate(locale, "CAPTCHA enabled: ") %></label>
|
<label for="captcha_enabled"><%= I18n.translate(locale, "CAPTCHA enabled: ") %></label>
|
||||||
|
|||||||
@ -630,11 +630,11 @@ private module Parsers
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Parses an InnerTube lockupViewModel into a SearchPlaylist.
|
# Parses an InnerTube lockupViewModel into a SearchPlaylist or SearchVideo.
|
||||||
# Returns nil when the given object is not a lockupViewModel.
|
# Returns nil when the given object is not a lockupViewModel.
|
||||||
#
|
#
|
||||||
# This structure is present since November 2024 on the "podcasts" and
|
# This structure is present since November 2024 on the "podcasts", "playlists",
|
||||||
# "playlists" tabs of the channel page. It is usually encapsulated in either
|
# and "videos" tabs of the channel page. It is usually encapsulated in either
|
||||||
# a richItemRenderer or a richGridRenderer.
|
# a richItemRenderer or a richGridRenderer.
|
||||||
#
|
#
|
||||||
module LockupViewModelParser
|
module LockupViewModelParser
|
||||||
@ -648,12 +648,26 @@ private module Parsers
|
|||||||
end
|
end
|
||||||
|
|
||||||
private def parse_internal(item_contents, author_fallback)
|
private def parse_internal(item_contents, author_fallback)
|
||||||
playlist_id = item_contents["contentId"].as_s
|
content_id = item_contents["contentId"].as_s
|
||||||
|
|
||||||
thumbnail_view_model = item_contents.dig(
|
# A watchEndpoint with a playlistId means this item is a playlist.
|
||||||
|
# If it only has a videoId (no playlistId), it's a regular video.
|
||||||
|
# Fall back to content_id length: video IDs are always exactly 11 chars.
|
||||||
|
watch_endpoint = item_contents.dig?(
|
||||||
|
"rendererContext", "commandContext", "onTap",
|
||||||
|
"innertubeCommand", "watchEndpoint"
|
||||||
|
)
|
||||||
|
is_playlist = watch_endpoint.try(&.["playlistId"]?) || content_id.size != 11
|
||||||
|
|
||||||
|
thumbnail_view_model = item_contents.dig?(
|
||||||
|
"contentImage", "collectionThumbnailViewModel",
|
||||||
|
"primaryThumbnail", "thumbnailViewModel"
|
||||||
|
) || item_contents.dig?(
|
||||||
"contentImage", "thumbnailViewModel"
|
"contentImage", "thumbnailViewModel"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return nil if thumbnail_view_model.nil?
|
||||||
|
|
||||||
thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s
|
thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s
|
||||||
|
|
||||||
# This complicated sequences tries to extract the following data structure:
|
# This complicated sequences tries to extract the following data structure:
|
||||||
@ -667,16 +681,10 @@ private module Parsers
|
|||||||
# }]
|
# }]
|
||||||
# }
|
# }
|
||||||
# }]
|
# }]
|
||||||
#
|
all_badge_texts = thumbnail_view_model.dig("overlays").as_a
|
||||||
# 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)
|
.compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a)
|
||||||
.flatten
|
.flatten
|
||||||
.find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node|
|
.compact_map(&.dig?("thumbnailBadgeViewModel", "text").try &.as_s)
|
||||||
{"episodes", "videos"}.any? { |str| node.as_s.ends_with?(str) }
|
|
||||||
})
|
|
||||||
.try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false)
|
|
||||||
|
|
||||||
metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
|
metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
|
||||||
title = metadata.dig("title", "content").as_s
|
title = metadata.dig("title", "content").as_s
|
||||||
@ -686,20 +694,51 @@ private module Parsers
|
|||||||
# parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s)
|
# parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s)
|
||||||
# One of these parts should contain a string like: "Updated 2 days ago"
|
# 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?
|
if is_playlist
|
||||||
# item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint")
|
# NOTE: this simplistic `.to_i` conversion might not work on larger
|
||||||
# Available fields: "videoId", "playlistId", "params"
|
# playlists and hasn't been tested.
|
||||||
|
|
||||||
return SearchPlaylist.new({
|
# Prefer badges explicitly labeled "videos" or "episodes" (case-insensitive)
|
||||||
title: title,
|
video_count = all_badge_texts
|
||||||
id: playlist_id,
|
.find { |t| {"episodes", "videos"}.any? { |s| t.downcase.ends_with?(s) } }
|
||||||
author: author_fallback.name,
|
.try &.to_i(strict: false)
|
||||||
ucid: author_fallback.id,
|
|
||||||
video_count: video_count || -1,
|
# Fallback: any badge whose text starts with a digit
|
||||||
videos: [] of SearchPlaylistVideo,
|
video_count ||= all_badge_texts
|
||||||
thumbnail: thumbnail,
|
.find { |t| t[0]?.try &.ascii_number? }
|
||||||
author_verified: false,
|
.try &.to_i(strict: false)
|
||||||
})
|
|
||||||
|
return SearchPlaylist.new({
|
||||||
|
title: title,
|
||||||
|
id: content_id,
|
||||||
|
author: author_fallback.name,
|
||||||
|
ucid: author_fallback.id,
|
||||||
|
video_count: video_count || -1,
|
||||||
|
videos: [] of SearchPlaylistVideo,
|
||||||
|
thumbnail: thumbnail,
|
||||||
|
author_verified: false,
|
||||||
|
})
|
||||||
|
else
|
||||||
|
# Extract duration from a time-format badge (e.g. "5:30", "1:23:45")
|
||||||
|
length_seconds = all_badge_texts
|
||||||
|
.find { |t| /^\d+:\d+/.match(t) }
|
||||||
|
.try { |t| decode_length_seconds(t) } || 0
|
||||||
|
|
||||||
|
return SearchVideo.new({
|
||||||
|
title: title,
|
||||||
|
id: content_id,
|
||||||
|
author: author_fallback.name,
|
||||||
|
ucid: author_fallback.id,
|
||||||
|
published: Time.unix(0),
|
||||||
|
views: 0_i64,
|
||||||
|
description_html: "",
|
||||||
|
length_seconds: length_seconds,
|
||||||
|
premiere_timestamp: Time.unix(0),
|
||||||
|
author_verified: false,
|
||||||
|
author_thumbnail: nil,
|
||||||
|
badges: VideoBadges::None,
|
||||||
|
})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.parser_name
|
def self.parser_name
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user