mirror of
https://github.com/iv-org/invidious.git
synced 2024-11-09 15:02:14 +05:30
Compare commits
46 Commits
98d86a893d
...
c3e48a4717
Author | SHA1 | Date | |
---|---|---|---|
|
c3e48a4717 | ||
|
c5fdd9ea65 | ||
|
2876ee0f9f | ||
|
0699e5fc27 | ||
|
15669acccf | ||
|
cd2daf4adb | ||
|
ccecc6d318 | ||
|
3c6a662aaf | ||
|
9e55799269 | ||
|
da70c9b7b0 | ||
|
828da3c6ce | ||
|
febf18cbf7 | ||
|
21ab5dc668 | ||
|
b200ebfb6b | ||
|
ecbea0b67b | ||
|
d1cd790388 | ||
|
f66068976e | ||
|
22b35c453e | ||
|
c606465708 | ||
|
85deea5aca | ||
|
78c5ba93c7 | ||
|
31a80420ec | ||
|
4c0b5c314d | ||
|
eb0f651812 | ||
|
764965c441 | ||
|
b795bdf2a4 | ||
|
5b05f3bd14 | ||
|
a335bc0814 | ||
|
7b50388eaf | ||
|
da3d58f03c | ||
|
8327862697 | ||
|
6878822c4d | ||
|
0b28054f8a | ||
|
cc33d3f074 | ||
|
acbb625866 | ||
|
466bfbb306 | ||
|
e31053e812 | ||
|
96ade642fa | ||
|
1ce2d10c50 | ||
|
5abafb8296 | ||
|
9cd2e93a2e | ||
|
d3e6d7b6c5 | ||
|
f5244055a2 | ||
|
af86cdbd38 | ||
|
b3637f20a9 | ||
|
c676272604 |
@ -278,7 +278,14 @@ div.thumbnail > .bottom-right-overlay {
|
|||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchbar .pure-form fieldset { padding: 0; }
|
.searchbar .pure-form {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbar .pure-form fieldset {
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.searchbar input[type="search"] {
|
.searchbar input[type="search"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -310,6 +317,16 @@ input[type="search"]::-webkit-search-cancel-button {
|
|||||||
background-size: 14px;
|
background-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.searchbar #searchbutton {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbar #searchbutton:hover {
|
||||||
|
color: rgb(0, 182, 240);
|
||||||
|
}
|
||||||
|
|
||||||
.user-field {
|
.user-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
@ -15,7 +15,8 @@ record AboutChannel,
|
|||||||
allowed_regions : Array(String),
|
allowed_regions : Array(String),
|
||||||
tabs : Array(String),
|
tabs : Array(String),
|
||||||
tags : Array(String),
|
tags : Array(String),
|
||||||
verified : Bool
|
verified : Bool,
|
||||||
|
is_age_gated : Bool
|
||||||
|
|
||||||
def get_about_info(ucid, locale) : AboutChannel
|
def get_about_info(ucid, locale) : AboutChannel
|
||||||
begin
|
begin
|
||||||
@ -45,7 +46,22 @@ def get_about_info(ucid, locale) : AboutChannel
|
|||||||
end
|
end
|
||||||
|
|
||||||
tags = [] of String
|
tags = [] of String
|
||||||
|
tab_names = [] of String
|
||||||
|
total_views = 0_i64
|
||||||
|
joined = Time.unix(0)
|
||||||
|
|
||||||
|
if age_gate_renderer = initdata.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs", 0, "tabRenderer", "content", "sectionListRenderer", "contents", 0, "channelAgeGateRenderer")
|
||||||
|
description_node = nil
|
||||||
|
author = age_gate_renderer["channelTitle"].as_s
|
||||||
|
ucid = initdata.dig("responseContext", "serviceTrackingParams", 0, "params", 0, "value").as_s
|
||||||
|
author_url = "https://www.youtube.com/channel/#{ucid}"
|
||||||
|
author_thumbnail = age_gate_renderer.dig("avatar", "thumbnails", 0, "url").as_s
|
||||||
|
banner = nil
|
||||||
|
is_family_friendly = false
|
||||||
|
is_age_gated = true
|
||||||
|
tab_names = ["videos", "shorts", "streams"]
|
||||||
|
auto_generated = false
|
||||||
|
else
|
||||||
if auto_generated
|
if auto_generated
|
||||||
author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s
|
author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s
|
||||||
author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s
|
author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s
|
||||||
@ -84,29 +100,6 @@ def get_about_info(ucid, locale) : AboutChannel
|
|||||||
end
|
end
|
||||||
|
|
||||||
is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
|
is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
|
||||||
|
|
||||||
allowed_regions = initdata
|
|
||||||
.dig?("microformat", "microformatDataRenderer", "availableCountries")
|
|
||||||
.try &.as_a.map(&.as_s) || [] of String
|
|
||||||
|
|
||||||
description = !description_node.nil? ? description_node.as_s : ""
|
|
||||||
description_html = HTML.escape(description)
|
|
||||||
|
|
||||||
if !description_node.nil?
|
|
||||||
if description_node.as_h?.nil?
|
|
||||||
description_node = text_to_parsed_content(description_node.as_s)
|
|
||||||
end
|
|
||||||
description_html = parse_content(description_node)
|
|
||||||
if description_html == "" && description != ""
|
|
||||||
description_html = HTML.escape(description)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
total_views = 0_i64
|
|
||||||
joined = Time.unix(0)
|
|
||||||
|
|
||||||
tab_names = [] of String
|
|
||||||
|
|
||||||
if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?
|
if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?
|
||||||
# Get the name of the tabs available on this channel
|
# Get the name of the tabs available on this channel
|
||||||
tab_names = tabs_json.as_a.compact_map do |entry|
|
tab_names = tabs_json.as_a.compact_map do |entry|
|
||||||
@ -147,6 +140,24 @@ def get_about_info(ucid, locale) : AboutChannel
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
allowed_regions = initdata
|
||||||
|
.dig?("microformat", "microformatDataRenderer", "availableCountries")
|
||||||
|
.try &.as_a.map(&.as_s) || [] of String
|
||||||
|
|
||||||
|
description = !description_node.nil? ? description_node.as_s : ""
|
||||||
|
description_html = HTML.escape(description)
|
||||||
|
|
||||||
|
if !description_node.nil?
|
||||||
|
if description_node.as_h?.nil?
|
||||||
|
description_node = text_to_parsed_content(description_node.as_s)
|
||||||
|
end
|
||||||
|
description_html = parse_content(description_node)
|
||||||
|
if description_html == "" && description != ""
|
||||||
|
description_html = HTML.escape(description)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
sub_count = 0
|
sub_count = 0
|
||||||
|
|
||||||
@ -177,6 +188,7 @@ def get_about_info(ucid, locale) : AboutChannel
|
|||||||
tabs: tab_names,
|
tabs: tab_names,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
verified: author_verified || false,
|
verified: author_verified || false,
|
||||||
|
is_age_gated: is_age_gated || false,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -82,6 +82,7 @@ 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?
|
||||||
|
property login_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
|
||||||
|
@ -140,6 +140,7 @@ module Invidious::Database::Playlists
|
|||||||
request = <<-SQL
|
request = <<-SQL
|
||||||
SELECT id,title FROM playlists
|
SELECT id,title FROM playlists
|
||||||
WHERE author = $1 AND id LIKE 'IV%'
|
WHERE author = $1 AND id LIKE 'IV%'
|
||||||
|
ORDER BY title
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
PG_DB.query_all(request, email, as: {String, String})
|
PG_DB.query_all(request, email, as: {String, String})
|
||||||
|
@ -90,7 +90,7 @@ struct SearchVideo
|
|||||||
json.field "lengthSeconds", self.length_seconds
|
json.field "lengthSeconds", self.length_seconds
|
||||||
json.field "liveNow", self.live_now
|
json.field "liveNow", self.live_now
|
||||||
json.field "premium", self.premium
|
json.field "premium", self.premium
|
||||||
json.field "isUpcoming", self.is_upcoming
|
json.field "isUpcoming", self.upcoming?
|
||||||
|
|
||||||
if self.premiere_timestamp
|
if self.premiere_timestamp
|
||||||
json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix
|
json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix
|
||||||
@ -109,7 +109,7 @@ struct SearchVideo
|
|||||||
to_json(nil, json)
|
to_json(nil, json)
|
||||||
end
|
end
|
||||||
|
|
||||||
def is_upcoming
|
def upcoming?
|
||||||
premiere_timestamp ? true : false
|
premiere_timestamp ? true : false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -10,10 +10,8 @@ class Invidious::DecryptFunction
|
|||||||
end
|
end
|
||||||
|
|
||||||
def check_update
|
def check_update
|
||||||
now = Time.utc
|
|
||||||
|
|
||||||
# If we have updated in the last 5 minutes, do nothing
|
# If we have updated in the last 5 minutes, do nothing
|
||||||
return if (now - @last_update) > 5.minutes
|
return if (Time.utc - @last_update) < 5.minutes
|
||||||
|
|
||||||
# Get the amount of time elapsed since when the player was updated, in the
|
# Get the amount of time elapsed since when the player was updated, in the
|
||||||
# event where multiple invidious processes are run in parallel.
|
# event where multiple invidious processes are run in parallel.
|
||||||
|
@ -63,7 +63,7 @@ module Invidious::JSONify::APIv1
|
|||||||
json.field "isListed", video.is_listed
|
json.field "isListed", video.is_listed
|
||||||
json.field "liveNow", video.live_now
|
json.field "liveNow", video.live_now
|
||||||
json.field "isPostLiveDvr", video.post_live_dvr
|
json.field "isPostLiveDvr", video.post_live_dvr
|
||||||
json.field "isUpcoming", video.is_upcoming
|
json.field "isUpcoming", video.upcoming?
|
||||||
|
|
||||||
if video.premiere_timestamp
|
if video.premiere_timestamp
|
||||||
json.field "premiereTimestamp", video.premiere_timestamp.try &.to_unix
|
json.field "premiereTimestamp", video.premiere_timestamp.try &.to_unix
|
||||||
@ -109,7 +109,7 @@ module Invidious::JSONify::APIv1
|
|||||||
# On livestreams, it's not present, so always fall back to the
|
# On livestreams, it's not present, so always fall back to the
|
||||||
# current unix timestamp (up to mS precision) for compatibility.
|
# current unix timestamp (up to mS precision) for compatibility.
|
||||||
last_modified = fmt["lastModified"]?
|
last_modified = fmt["lastModified"]?
|
||||||
last_modified ||= "#{Time.utc.to_unix_ms.to_s}000"
|
last_modified ||= "#{Time.utc.to_unix_ms}000"
|
||||||
json.field "lmt", last_modified
|
json.field "lmt", last_modified
|
||||||
|
|
||||||
json.field "projectionType", fmt["projectionType"]
|
json.field "projectionType", fmt["projectionType"]
|
||||||
@ -162,7 +162,13 @@ module Invidious::JSONify::APIv1
|
|||||||
json.array do
|
json.array do
|
||||||
video.fmt_stream.each do |fmt|
|
video.fmt_stream.each do |fmt|
|
||||||
json.object do
|
json.object do
|
||||||
|
if proxy
|
||||||
|
json.field "url", Invidious::HttpServer::Utils.proxy_video_url(
|
||||||
|
fmt["url"].to_s, absolute: true
|
||||||
|
)
|
||||||
|
else
|
||||||
json.field "url", fmt["url"]
|
json.field "url", fmt["url"]
|
||||||
|
end
|
||||||
json.field "itag", fmt["itag"].as_i.to_s
|
json.field "itag", fmt["itag"].as_i.to_s
|
||||||
json.field "type", fmt["mimeType"]
|
json.field "type", fmt["mimeType"]
|
||||||
json.field "quality", fmt["quality"]
|
json.field "quality", fmt["quality"]
|
||||||
@ -271,17 +277,17 @@ module Invidious::JSONify::APIv1
|
|||||||
|
|
||||||
def storyboards(json, id, storyboards)
|
def storyboards(json, id, storyboards)
|
||||||
json.array do
|
json.array do
|
||||||
storyboards.each do |storyboard|
|
storyboards.each do |sb|
|
||||||
json.object do
|
json.object do
|
||||||
json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}"
|
json.field "url", "/api/v1/storyboards/#{id}?width=#{sb.width}&height=#{sb.height}"
|
||||||
json.field "templateUrl", storyboard[:url]
|
json.field "templateUrl", sb.url.to_s
|
||||||
json.field "width", storyboard[:width]
|
json.field "width", sb.width
|
||||||
json.field "height", storyboard[:height]
|
json.field "height", sb.height
|
||||||
json.field "count", storyboard[:count]
|
json.field "count", sb.count
|
||||||
json.field "interval", storyboard[:interval]
|
json.field "interval", sb.interval
|
||||||
json.field "storyboardWidth", storyboard[:storyboard_width]
|
json.field "storyboardWidth", sb.columns
|
||||||
json.field "storyboardHeight", storyboard[:storyboard_height]
|
json.field "storyboardHeight", sb.rows
|
||||||
json.field "storyboardCount", storyboard[:storyboard_count]
|
json.field "storyboardCount", sb.images_count
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -46,8 +46,14 @@ struct PlaylistVideo
|
|||||||
XML.build { |xml| to_xml(xml) }
|
XML.build { |xml| to_xml(xml) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_json(locale : String?, json : JSON::Builder)
|
||||||
|
to_json(json)
|
||||||
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder, index : Int32? = nil)
|
def to_json(json : JSON::Builder, index : Int32? = nil)
|
||||||
json.object do
|
json.object do
|
||||||
|
json.field "type", "video"
|
||||||
|
|
||||||
json.field "title", self.title
|
json.field "title", self.title
|
||||||
json.field "videoId", self.id
|
json.field "videoId", self.id
|
||||||
|
|
||||||
@ -67,6 +73,7 @@ struct PlaylistVideo
|
|||||||
end
|
end
|
||||||
|
|
||||||
json.field "lengthSeconds", self.length_seconds
|
json.field "lengthSeconds", self.length_seconds
|
||||||
|
json.field "liveNow", self.live_now
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -27,11 +27,22 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
# Retrieve "sort by" setting from URL parameters
|
# Retrieve "sort by" setting from URL parameters
|
||||||
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
|
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
|
||||||
|
|
||||||
|
if channel.is_age_gated
|
||||||
|
begin
|
||||||
|
playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
|
||||||
|
videos = get_playlist_videos(playlist, offset: 0)
|
||||||
|
rescue ex : InfoException
|
||||||
|
# playlist doesnt exist.
|
||||||
|
videos = [] of PlaylistVideo
|
||||||
|
end
|
||||||
|
next_continuation = nil
|
||||||
|
else
|
||||||
begin
|
begin
|
||||||
videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by)
|
videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return error_json(500, ex)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
# TODO: Refactor into `to_json` for InvidiousChannel
|
# TODO: Refactor into `to_json` for InvidiousChannel
|
||||||
@ -84,6 +95,7 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
json.field "joined", channel.joined.to_unix
|
json.field "joined", channel.joined.to_unix
|
||||||
|
|
||||||
json.field "autoGenerated", channel.auto_generated
|
json.field "autoGenerated", channel.auto_generated
|
||||||
|
json.field "ageGated", channel.is_age_gated
|
||||||
json.field "isFamilyFriendly", channel.is_family_friendly
|
json.field "isFamilyFriendly", channel.is_family_friendly
|
||||||
json.field "description", html_to_content(channel.description_html)
|
json.field "description", html_to_content(channel.description_html)
|
||||||
json.field "descriptionHtml", channel.description_html
|
json.field "descriptionHtml", channel.description_html
|
||||||
@ -142,6 +154,16 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
|
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
|
||||||
continuation = env.params.query["continuation"]?
|
continuation = env.params.query["continuation"]?
|
||||||
|
|
||||||
|
if channel.is_age_gated
|
||||||
|
begin
|
||||||
|
playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
|
||||||
|
videos = get_playlist_videos(playlist, offset: 0)
|
||||||
|
rescue ex : InfoException
|
||||||
|
# playlist doesnt exist.
|
||||||
|
videos = [] of PlaylistVideo
|
||||||
|
end
|
||||||
|
next_continuation = nil
|
||||||
|
else
|
||||||
begin
|
begin
|
||||||
videos, next_continuation = Channel::Tabs.get_60_videos(
|
videos, next_continuation = Channel::Tabs.get_60_videos(
|
||||||
channel, continuation: continuation, sort_by: sort_by
|
channel, continuation: continuation, sort_by: sort_by
|
||||||
@ -149,6 +171,7 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return error_json(500, ex)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return JSON.build do |json|
|
return JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
@ -176,6 +199,16 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
# Retrieve continuation from URL parameters
|
# Retrieve continuation from URL parameters
|
||||||
continuation = env.params.query["continuation"]?
|
continuation = env.params.query["continuation"]?
|
||||||
|
|
||||||
|
if channel.is_age_gated
|
||||||
|
begin
|
||||||
|
playlist = get_playlist(channel.ucid.sub("UC", "UUSH"))
|
||||||
|
videos = get_playlist_videos(playlist, offset: 0)
|
||||||
|
rescue ex : InfoException
|
||||||
|
# playlist doesnt exist.
|
||||||
|
videos = [] of PlaylistVideo
|
||||||
|
end
|
||||||
|
next_continuation = nil
|
||||||
|
else
|
||||||
begin
|
begin
|
||||||
videos, next_continuation = Channel::Tabs.get_shorts(
|
videos, next_continuation = Channel::Tabs.get_shorts(
|
||||||
channel, continuation: continuation
|
channel, continuation: continuation
|
||||||
@ -183,6 +216,7 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return error_json(500, ex)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return JSON.build do |json|
|
return JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
@ -211,6 +245,16 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
|
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
|
||||||
continuation = env.params.query["continuation"]?
|
continuation = env.params.query["continuation"]?
|
||||||
|
|
||||||
|
if channel.is_age_gated
|
||||||
|
begin
|
||||||
|
playlist = get_playlist(channel.ucid.sub("UC", "UULV"))
|
||||||
|
videos = get_playlist_videos(playlist, offset: 0)
|
||||||
|
rescue ex : InfoException
|
||||||
|
# playlist doesnt exist.
|
||||||
|
videos = [] of PlaylistVideo
|
||||||
|
end
|
||||||
|
next_continuation = nil
|
||||||
|
else
|
||||||
begin
|
begin
|
||||||
videos, next_continuation = Channel::Tabs.get_60_livestreams(
|
videos, next_continuation = Channel::Tabs.get_60_livestreams(
|
||||||
channel, continuation: continuation, sort_by: sort_by
|
channel, continuation: continuation, sort_by: sort_by
|
||||||
@ -218,6 +262,7 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return error_json(500, ex)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return JSON.build do |json|
|
return JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
require "html"
|
||||||
|
|
||||||
module Invidious::Routes::API::V1::Videos
|
module Invidious::Routes::API::V1::Videos
|
||||||
def self.videos(env)
|
def self.videos(env)
|
||||||
locale = env.get("preferences").as(Preferences).locale
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
@ -116,7 +118,7 @@ module Invidious::Routes::API::V1::Videos
|
|||||||
else
|
else
|
||||||
caption_xml = XML.parse(caption_xml)
|
caption_xml = XML.parse(caption_xml)
|
||||||
|
|
||||||
webvtt = WebVTT.build(settings_field) do |webvtt|
|
webvtt = WebVTT.build(settings_field) do |builder|
|
||||||
caption_nodes = caption_xml.xpath_nodes("//transcript/text")
|
caption_nodes = caption_xml.xpath_nodes("//transcript/text")
|
||||||
caption_nodes.each_with_index do |node, i|
|
caption_nodes.each_with_index do |node, i|
|
||||||
start_time = node["start"].to_f.seconds
|
start_time = node["start"].to_f.seconds
|
||||||
@ -136,7 +138,7 @@ module Invidious::Routes::API::V1::Videos
|
|||||||
text = "<v #{md["name"]}>#{md["text"]}</v>"
|
text = "<v #{md["name"]}>#{md["text"]}</v>"
|
||||||
end
|
end
|
||||||
|
|
||||||
webvtt.cue(start_time, end_time, text)
|
builder.cue(start_time, end_time, text)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -187,15 +189,14 @@ module Invidious::Routes::API::V1::Videos
|
|||||||
haltf env, 500
|
haltf env, 500
|
||||||
end
|
end
|
||||||
|
|
||||||
storyboards = video.storyboards
|
width = env.params.query["width"]?.try &.to_i
|
||||||
width = env.params.query["width"]?
|
height = env.params.query["height"]?.try &.to_i
|
||||||
height = env.params.query["height"]?
|
|
||||||
|
|
||||||
if !width && !height
|
if !width && !height
|
||||||
response = JSON.build do |json|
|
response = JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
json.field "storyboards" do
|
json.field "storyboards" do
|
||||||
Invidious::JSONify::APIv1.storyboards(json, id, storyboards)
|
Invidious::JSONify::APIv1.storyboards(json, id, video.storyboards)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -205,35 +206,48 @@ module Invidious::Routes::API::V1::Videos
|
|||||||
|
|
||||||
env.response.content_type = "text/vtt"
|
env.response.content_type = "text/vtt"
|
||||||
|
|
||||||
storyboard = storyboards.select { |sb| width == "#{sb[:width]}" || height == "#{sb[:height]}" }
|
# Select a storyboard matching the user's provided width/height
|
||||||
|
storyboard = video.storyboards.select { |x| x.width == width || x.height == height }
|
||||||
|
haltf env, 404 if storyboard.empty?
|
||||||
|
|
||||||
if storyboard.empty?
|
# Alias variable, to make the code below esaier to read
|
||||||
haltf env, 404
|
sb = storyboard[0]
|
||||||
else
|
|
||||||
storyboard = storyboard[0]
|
|
||||||
end
|
|
||||||
|
|
||||||
WebVTT.build do |vtt|
|
# Some base URL segments that we'll use to craft the final URLs
|
||||||
|
work_url = sb.proxied_url.dup
|
||||||
|
template_path = sb.proxied_url.path
|
||||||
|
|
||||||
|
# Initialize cue timing variables
|
||||||
|
# NOTE: videojs-vtt-thumbnails gets lost when the cue times don't overlap
|
||||||
|
# (i.e: if cue[n] end time is 1:06:25.000, cue[n+1] start time should be 1:06:25.000)
|
||||||
|
time_delta = sb.interval.milliseconds
|
||||||
start_time = 0.milliseconds
|
start_time = 0.milliseconds
|
||||||
end_time = storyboard[:interval].milliseconds
|
end_time = time_delta
|
||||||
|
|
||||||
storyboard[:storyboard_count].times do |i|
|
# Build a VTT file for VideoJS-vtt plugin
|
||||||
url = storyboard[:url]
|
vtt_file = WebVTT.build do |vtt|
|
||||||
authority = /(i\d?).ytimg.com/.match!(url)[1]?
|
sb.images_count.times do |i|
|
||||||
url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "")
|
# Replace the variable component part of the path
|
||||||
url = "#{HOST_URL}/sb/#{authority}/#{url}"
|
work_url.path = template_path.sub("$M", i)
|
||||||
|
|
||||||
storyboard[:storyboard_height].times do |j|
|
sb.rows.times do |j|
|
||||||
storyboard[:storyboard_width].times do |k|
|
sb.columns.times do |k|
|
||||||
current_cue_url = "#{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]}"
|
# The URL fragment represents the offset of the thumbnail inside the storyboard image
|
||||||
vtt.cue(start_time, end_time, current_cue_url)
|
work_url.fragment = "xywh=#{sb.width * k},#{sb.height * j},#{sb.width - 2},#{sb.height}"
|
||||||
|
|
||||||
start_time += storyboard[:interval].milliseconds
|
vtt.cue(start_time, end_time, work_url.to_s)
|
||||||
end_time += storyboard[:interval].milliseconds
|
|
||||||
|
start_time += time_delta
|
||||||
|
end_time += time_delta
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# videojs-vtt-thumbnails is not compliant to the VTT specification, it
|
||||||
|
# doesn't unescape the HTML entities, so we have to do it here:
|
||||||
|
# TODO: remove this when we migrate to VideoJS 8
|
||||||
|
return HTML.unescape(vtt_file)
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.annotations(env)
|
def self.annotations(env)
|
||||||
|
@ -61,18 +61,6 @@ module Invidious::Routes::BeforeAll
|
|||||||
env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
|
env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
|
||||||
end
|
end
|
||||||
|
|
||||||
return if {
|
|
||||||
"/sb/",
|
|
||||||
"/vi/",
|
|
||||||
"/s_p/",
|
|
||||||
"/yts/",
|
|
||||||
"/ggpht/",
|
|
||||||
"/api/manifest/",
|
|
||||||
"/videoplayback",
|
|
||||||
"/latest_version",
|
|
||||||
"/download",
|
|
||||||
}.any? { |r| env.request.resource.starts_with? r }
|
|
||||||
|
|
||||||
if env.request.cookies.has_key? "SID"
|
if env.request.cookies.has_key? "SID"
|
||||||
sid = env.request.cookies["SID"].value
|
sid = env.request.cookies["SID"].value
|
||||||
|
|
||||||
@ -100,6 +88,24 @@ module Invidious::Routes::BeforeAll
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
unregistered_path_whitelist = {"/", "/login", "/licenses", "/privacy"}
|
||||||
|
if CONFIG.login_only && !env.get?("user") && !unregistered_path_whitelist.includes?(env.request.path)
|
||||||
|
env.response.headers["Location"] = "/login"
|
||||||
|
haltf env, status_code: 302
|
||||||
|
end
|
||||||
|
|
||||||
|
return if {
|
||||||
|
"/sb/",
|
||||||
|
"/vi/",
|
||||||
|
"/s_p/",
|
||||||
|
"/yts/",
|
||||||
|
"/ggpht/",
|
||||||
|
"/api/manifest/",
|
||||||
|
"/videoplayback",
|
||||||
|
"/latest_version",
|
||||||
|
"/download",
|
||||||
|
}.any? { |r| env.request.resource.starts_with? r }
|
||||||
|
|
||||||
dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s
|
dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s
|
||||||
thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode.to_s
|
thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode.to_s
|
||||||
thin_mode = thin_mode == "true"
|
thin_mode = thin_mode == "true"
|
||||||
|
@ -36,13 +36,25 @@ module Invidious::Routes::Channels
|
|||||||
items = items.select(SearchPlaylist)
|
items = items.select(SearchPlaylist)
|
||||||
items.each(&.author = "")
|
items.each(&.author = "")
|
||||||
else
|
else
|
||||||
sort_options = {"newest", "oldest", "popular"}
|
|
||||||
|
|
||||||
# Fetch items and continuation token
|
# Fetch items and continuation token
|
||||||
|
if channel.is_age_gated
|
||||||
|
sort_by = ""
|
||||||
|
sort_options = [] of String
|
||||||
|
begin
|
||||||
|
playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
|
||||||
|
items = get_playlist_videos(playlist, offset: 0)
|
||||||
|
rescue ex : InfoException
|
||||||
|
# playlist doesnt exist.
|
||||||
|
items = [] of PlaylistVideo
|
||||||
|
end
|
||||||
|
next_continuation = nil
|
||||||
|
else
|
||||||
|
sort_options = {"newest", "oldest", "popular"}
|
||||||
items, next_continuation = Channel::Tabs.get_videos(
|
items, next_continuation = Channel::Tabs.get_videos(
|
||||||
channel, continuation: continuation, sort_by: (sort_by || "newest")
|
channel, continuation: continuation, sort_by: (sort_by || "newest")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
selected_tab = Frontend::ChannelPage::TabsAvailable::Videos
|
selected_tab = Frontend::ChannelPage::TabsAvailable::Videos
|
||||||
templated "channel"
|
templated "channel"
|
||||||
@ -58,6 +70,18 @@ module Invidious::Routes::Channels
|
|||||||
return env.redirect "/channel/#{channel.ucid}"
|
return env.redirect "/channel/#{channel.ucid}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if channel.is_age_gated
|
||||||
|
sort_by = ""
|
||||||
|
sort_options = [] of String
|
||||||
|
begin
|
||||||
|
playlist = get_playlist(channel.ucid.sub("UC", "UUSH"))
|
||||||
|
items = get_playlist_videos(playlist, offset: 0)
|
||||||
|
rescue ex : InfoException
|
||||||
|
# playlist doesnt exist.
|
||||||
|
items = [] of PlaylistVideo
|
||||||
|
end
|
||||||
|
next_continuation = nil
|
||||||
|
else
|
||||||
# TODO: support sort option for shorts
|
# TODO: support sort option for shorts
|
||||||
sort_by = ""
|
sort_by = ""
|
||||||
sort_options = [] of String
|
sort_options = [] of String
|
||||||
@ -66,6 +90,7 @@ module Invidious::Routes::Channels
|
|||||||
items, next_continuation = Channel::Tabs.get_shorts(
|
items, next_continuation = Channel::Tabs.get_shorts(
|
||||||
channel, continuation: continuation
|
channel, continuation: continuation
|
||||||
)
|
)
|
||||||
|
end
|
||||||
|
|
||||||
selected_tab = Frontend::ChannelPage::TabsAvailable::Shorts
|
selected_tab = Frontend::ChannelPage::TabsAvailable::Shorts
|
||||||
templated "channel"
|
templated "channel"
|
||||||
@ -81,6 +106,18 @@ module Invidious::Routes::Channels
|
|||||||
return env.redirect "/channel/#{channel.ucid}"
|
return env.redirect "/channel/#{channel.ucid}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if channel.is_age_gated
|
||||||
|
sort_by = ""
|
||||||
|
sort_options = [] of String
|
||||||
|
begin
|
||||||
|
playlist = get_playlist(channel.ucid.sub("UC", "UULV"))
|
||||||
|
items = get_playlist_videos(playlist, offset: 0)
|
||||||
|
rescue ex : InfoException
|
||||||
|
# playlist doesnt exist.
|
||||||
|
items = [] of PlaylistVideo
|
||||||
|
end
|
||||||
|
next_continuation = nil
|
||||||
|
else
|
||||||
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
|
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
|
||||||
sort_options = {"newest", "oldest", "popular"}
|
sort_options = {"newest", "oldest", "popular"}
|
||||||
|
|
||||||
@ -88,6 +125,7 @@ module Invidious::Routes::Channels
|
|||||||
items, next_continuation = Channel::Tabs.get_60_livestreams(
|
items, next_continuation = Channel::Tabs.get_60_livestreams(
|
||||||
channel, continuation: continuation, sort_by: sort_by
|
channel, continuation: continuation, sort_by: sort_by
|
||||||
)
|
)
|
||||||
|
end
|
||||||
|
|
||||||
selected_tab = Frontend::ChannelPage::TabsAvailable::Streams
|
selected_tab = Frontend::ChannelPage::TabsAvailable::Streams
|
||||||
templated "channel"
|
templated "channel"
|
||||||
|
@ -51,6 +51,12 @@ module Invidious::Routes::Search
|
|||||||
else
|
else
|
||||||
user = env.get? "user"
|
user = env.get? "user"
|
||||||
|
|
||||||
|
# An URL was copy/pasted in the search box.
|
||||||
|
# Redirect the user to the appropriate page.
|
||||||
|
if query.url?
|
||||||
|
return env.redirect UrlSanitizer.process(query.text).to_s
|
||||||
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
items = query.process
|
items = query.process
|
||||||
rescue ex : ChannelSearchException
|
rescue ex : ChannelSearchException
|
||||||
|
@ -131,7 +131,7 @@ module Invidious::Routes::VideoPlayback
|
|||||||
end
|
end
|
||||||
|
|
||||||
# TODO: Record bytes written so we can restart after a chunk fails
|
# TODO: Record bytes written so we can restart after a chunk fails
|
||||||
while true
|
loop do
|
||||||
if !range_end && content_length
|
if !range_end && content_length
|
||||||
range_end = content_length
|
range_end = content_length
|
||||||
end
|
end
|
||||||
|
@ -20,6 +20,9 @@ module Invidious::Search
|
|||||||
property region : String?
|
property region : String?
|
||||||
property channel : String = ""
|
property channel : String = ""
|
||||||
|
|
||||||
|
# Flag that indicates if the smart search features have been disabled.
|
||||||
|
@inhibit_ssf : Bool = false
|
||||||
|
|
||||||
# Return true if @raw_query is either `nil` or empty
|
# Return true if @raw_query is either `nil` or empty
|
||||||
private def empty_raw_query?
|
private def empty_raw_query?
|
||||||
return @raw_query.empty?
|
return @raw_query.empty?
|
||||||
@ -48,10 +51,18 @@ module Invidious::Search
|
|||||||
)
|
)
|
||||||
# Get the raw search query string (common to all search types). In
|
# Get the raw search query string (common to all search types). In
|
||||||
# Regular search mode, also look for the `search_query` URL parameter
|
# Regular search mode, also look for the `search_query` URL parameter
|
||||||
if @type.regular?
|
_raw_query = params["q"]?
|
||||||
@raw_query = params["q"]? || params["search_query"]? || ""
|
_raw_query ||= params["search_query"]? if @type.regular?
|
||||||
else
|
_raw_query ||= ""
|
||||||
@raw_query = params["q"]? || ""
|
|
||||||
|
# Remove surrounding whitespaces. Mostly useful for copy/pasted URLs.
|
||||||
|
@raw_query = _raw_query.strip
|
||||||
|
|
||||||
|
# Check for smart features (ex: URL search) inhibitor (backslash).
|
||||||
|
# If inhibitor is present, remove it.
|
||||||
|
if @raw_query.starts_with?('\\')
|
||||||
|
@inhibit_ssf = true
|
||||||
|
@raw_query = @raw_query[1..]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the page number (also common to all search types)
|
# Get the page number (also common to all search types)
|
||||||
@ -85,7 +96,7 @@ module Invidious::Search
|
|||||||
@filters = Filters.from_iv_params(params)
|
@filters = Filters.from_iv_params(params)
|
||||||
@channel = params["channel"]? || ""
|
@channel = params["channel"]? || ""
|
||||||
|
|
||||||
if @filters.default? && @raw_query.includes?(':')
|
if @filters.default? && @raw_query.index(/\w:\w/)
|
||||||
# Parse legacy filters from query
|
# Parse legacy filters from query
|
||||||
@filters, @channel, @query, subs = Filters.from_legacy_filters(@raw_query)
|
@filters, @channel, @query, subs = Filters.from_legacy_filters(@raw_query)
|
||||||
else
|
else
|
||||||
@ -136,5 +147,22 @@ module Invidious::Search
|
|||||||
|
|
||||||
return params
|
return params
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Checks if the query is a standalone URL
|
||||||
|
def url? : Bool
|
||||||
|
# If the smart features have been inhibited, don't go further.
|
||||||
|
return false if @inhibit_ssf
|
||||||
|
|
||||||
|
# Only supported in regular search mode
|
||||||
|
return false if !@type.regular?
|
||||||
|
|
||||||
|
# If filters are present, that's a regular search
|
||||||
|
return false if !@filters.default?
|
||||||
|
|
||||||
|
# Simple heuristics: domain name
|
||||||
|
return @raw_query.starts_with?(
|
||||||
|
/(https?:\/\/)?(www\.)?(m\.)?youtu(\.be|be\.com)\//
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -115,7 +115,7 @@ struct Invidious::User
|
|||||||
playlists.each do |item|
|
playlists.each do |item|
|
||||||
title = item["title"]?.try &.as_s?.try &.delete("<>")
|
title = item["title"]?.try &.as_s?.try &.delete("<>")
|
||||||
description = item["description"]?.try &.as_s?.try &.delete("\r")
|
description = item["description"]?.try &.as_s?.try &.delete("\r")
|
||||||
privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy }
|
privacy = item["privacy"]?.try &.as_s?.try { |raw_pl_privacy_state| PlaylistPrivacy.parse? raw_pl_privacy_state }
|
||||||
|
|
||||||
next if !title
|
next if !title
|
||||||
next if !description
|
next if !description
|
||||||
@ -161,7 +161,7 @@ struct Invidious::User
|
|||||||
# Youtube
|
# Youtube
|
||||||
# -------------------
|
# -------------------
|
||||||
|
|
||||||
private def is_opml?(mimetype : String, extension : String)
|
private def opml?(mimetype : String, extension : String)
|
||||||
opml_mimetypes = [
|
opml_mimetypes = [
|
||||||
"application/xml",
|
"application/xml",
|
||||||
"text/xml",
|
"text/xml",
|
||||||
@ -179,7 +179,7 @@ struct Invidious::User
|
|||||||
def from_youtube(user : User, body : String, filename : String, type : String) : Bool
|
def from_youtube(user : User, body : String, filename : String, type : String) : Bool
|
||||||
extension = filename.split(".").last
|
extension = filename.split(".").last
|
||||||
|
|
||||||
if is_opml?(type, extension)
|
if opml?(type, extension)
|
||||||
subscriptions = XML.parse(body)
|
subscriptions = XML.parse(body)
|
||||||
user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel|
|
user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel|
|
||||||
channel["xmlUrl"].match!(/UC[a-zA-Z0-9_-]{22}/)[0]
|
channel["xmlUrl"].match!(/UC[a-zA-Z0-9_-]{22}/)[0]
|
||||||
|
@ -177,65 +177,8 @@ struct Video
|
|||||||
# Misc. methods
|
# Misc. methods
|
||||||
|
|
||||||
def storyboards
|
def storyboards
|
||||||
storyboards = info.dig?("storyboards", "playerStoryboardSpecRenderer", "spec")
|
container = info.dig?("storyboards") || JSON::Any.new("{}")
|
||||||
.try &.as_s.split("|")
|
return IV::Videos::Storyboard.from_yt_json(container, self.length_seconds)
|
||||||
|
|
||||||
if !storyboards
|
|
||||||
if storyboard = info.dig?("storyboards", "playerLiveStoryboardSpecRenderer", "spec").try &.as_s
|
|
||||||
return [{
|
|
||||||
url: storyboard.split("#")[0],
|
|
||||||
width: 106,
|
|
||||||
height: 60,
|
|
||||||
count: -1,
|
|
||||||
interval: 5000,
|
|
||||||
storyboard_width: 3,
|
|
||||||
storyboard_height: 3,
|
|
||||||
storyboard_count: -1,
|
|
||||||
}]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
items = [] of NamedTuple(
|
|
||||||
url: String,
|
|
||||||
width: Int32,
|
|
||||||
height: Int32,
|
|
||||||
count: Int32,
|
|
||||||
interval: Int32,
|
|
||||||
storyboard_width: Int32,
|
|
||||||
storyboard_height: Int32,
|
|
||||||
storyboard_count: Int32)
|
|
||||||
|
|
||||||
return items if !storyboards
|
|
||||||
|
|
||||||
url = URI.parse(storyboards.shift)
|
|
||||||
params = HTTP::Params.parse(url.query || "")
|
|
||||||
|
|
||||||
storyboards.each_with_index do |sb, i|
|
|
||||||
width, height, count, storyboard_width, storyboard_height, interval, _, sigh = sb.split("#")
|
|
||||||
params["sigh"] = sigh
|
|
||||||
url.query = params.to_s
|
|
||||||
|
|
||||||
width = width.to_i
|
|
||||||
height = height.to_i
|
|
||||||
count = count.to_i
|
|
||||||
interval = interval.to_i
|
|
||||||
storyboard_width = storyboard_width.to_i
|
|
||||||
storyboard_height = storyboard_height.to_i
|
|
||||||
storyboard_count = (count / (storyboard_width * storyboard_height)).ceil.to_i
|
|
||||||
|
|
||||||
items << {
|
|
||||||
url: url.to_s.sub("$L", i).sub("$N", "M$M"),
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
count: count,
|
|
||||||
interval: interval,
|
|
||||||
storyboard_width: storyboard_width,
|
|
||||||
storyboard_height: storyboard_height,
|
|
||||||
storyboard_count: storyboard_count,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
items
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def paid
|
def paid
|
||||||
@ -280,7 +223,7 @@ struct Video
|
|||||||
info["genreUcid"].try &.as_s? ? "/channel/#{info["genreUcid"]}" : nil
|
info["genreUcid"].try &.as_s? ? "/channel/#{info["genreUcid"]}" : nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def is_vr : Bool?
|
def vr? : Bool?
|
||||||
return {"EQUIRECTANGULAR", "MESH"}.includes? self.projection_type
|
return {"EQUIRECTANGULAR", "MESH"}.includes? self.projection_type
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -361,6 +304,21 @@ struct Video
|
|||||||
{% if flag?(:debug_macros) %} {{debug}} {% end %}
|
{% if flag?(:debug_macros) %} {{debug}} {% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Macro to generate ? and = accessor methods for attributes in `info`
|
||||||
|
private macro predicate_bool(method_name, name)
|
||||||
|
# Return {{name.stringify}} from `info`
|
||||||
|
def {{method_name.id.underscore}}? : Bool
|
||||||
|
return info[{{name.stringify}}]?.try &.as_bool || false
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update {{name.stringify}} into `info`
|
||||||
|
def {{method_name.id.underscore}}=(value : Bool)
|
||||||
|
info[{{name.stringify}}] = JSON::Any.new(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
{% if flag?(:debug_macros) %} {{debug}} {% end %}
|
||||||
|
end
|
||||||
|
|
||||||
# Method definitions, using the macros above
|
# Method definitions, using the macros above
|
||||||
|
|
||||||
getset_string author
|
getset_string author
|
||||||
@ -382,11 +340,12 @@ struct Video
|
|||||||
getset_i64 likes
|
getset_i64 likes
|
||||||
getset_i64 views
|
getset_i64 views
|
||||||
|
|
||||||
|
# TODO: Make predicate_bool the default as to adhere to Crystal conventions
|
||||||
getset_bool allowRatings
|
getset_bool allowRatings
|
||||||
getset_bool authorVerified
|
getset_bool authorVerified
|
||||||
getset_bool isFamilyFriendly
|
getset_bool isFamilyFriendly
|
||||||
getset_bool isListed
|
getset_bool isListed
|
||||||
getset_bool isUpcoming
|
predicate_bool upcoming, isUpcoming
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_video(id, refresh = true, region = nil, force_refresh = false)
|
def get_video(id, refresh = true, region = nil, force_refresh = false)
|
||||||
|
@ -36,7 +36,13 @@ def parse_description(desc, video_id : String) : String?
|
|||||||
return "" if content.empty?
|
return "" if content.empty?
|
||||||
|
|
||||||
commands = desc["commandRuns"]?.try &.as_a
|
commands = desc["commandRuns"]?.try &.as_a
|
||||||
return content if commands.nil?
|
if commands.nil?
|
||||||
|
# Slightly faster than HTML.escape, as we're only doing one pass on
|
||||||
|
# the string instead of five for the standard library
|
||||||
|
return String.build do |str|
|
||||||
|
copy_string(str, content.each_codepoint, content.size)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
|
# Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
|
||||||
# (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are
|
# (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are
|
||||||
|
122
src/invidious/videos/storyboard.cr
Normal file
122
src/invidious/videos/storyboard.cr
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
require "uri"
|
||||||
|
require "http/params"
|
||||||
|
|
||||||
|
module Invidious::Videos
|
||||||
|
struct Storyboard
|
||||||
|
# Template URL
|
||||||
|
getter url : URI
|
||||||
|
getter proxied_url : URI
|
||||||
|
|
||||||
|
# Thumbnail parameters
|
||||||
|
getter width : Int32
|
||||||
|
getter height : Int32
|
||||||
|
getter count : Int32
|
||||||
|
getter interval : Int32
|
||||||
|
|
||||||
|
# Image (storyboard) parameters
|
||||||
|
getter rows : Int32
|
||||||
|
getter columns : Int32
|
||||||
|
getter images_count : Int32
|
||||||
|
|
||||||
|
def initialize(
|
||||||
|
*, @url, @width, @height, @count, @interval,
|
||||||
|
@rows, @columns, @images_count
|
||||||
|
)
|
||||||
|
authority = /(i\d?).ytimg.com/.match!(@url.host.not_nil!)[1]?
|
||||||
|
|
||||||
|
@proxied_url = URI.parse(HOST_URL)
|
||||||
|
@proxied_url.path = "/sb/#{authority}/#{@url.path.lchop("/sb/")}"
|
||||||
|
@proxied_url.query = @url.query
|
||||||
|
end
|
||||||
|
|
||||||
|
# Parse the JSON structure from Youtube
|
||||||
|
def self.from_yt_json(container : JSON::Any, length_seconds : Int32) : Array(Storyboard)
|
||||||
|
# Livestream storyboards are a bit different
|
||||||
|
# TODO: document exactly how
|
||||||
|
if storyboard = container.dig?("playerLiveStoryboardSpecRenderer", "spec").try &.as_s
|
||||||
|
return [Storyboard.new(
|
||||||
|
url: URI.parse(storyboard.split("#")[0]),
|
||||||
|
width: 106,
|
||||||
|
height: 60,
|
||||||
|
count: -1,
|
||||||
|
interval: 5000,
|
||||||
|
rows: 3,
|
||||||
|
columns: 3,
|
||||||
|
images_count: -1
|
||||||
|
)]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Split the storyboard string into chunks
|
||||||
|
#
|
||||||
|
# General format (whitespaces added for legibility):
|
||||||
|
# https://i.ytimg.com/sb/<video_id>/storyboard3_L$L/$N.jpg?sqp=<sig0>
|
||||||
|
# | 48 # 27 # 100 # 10 # 10 # 0 # default # rs$<sig1>
|
||||||
|
# | 80 # 45 # 95 # 10 # 10 # 10000 # M$M # rs$<sig2>
|
||||||
|
# | 160 # 90 # 95 # 5 # 5 # 10000 # M$M # rs$<sig3>
|
||||||
|
#
|
||||||
|
storyboards = container.dig?("playerStoryboardSpecRenderer", "spec")
|
||||||
|
.try &.as_s.split("|")
|
||||||
|
|
||||||
|
return [] of Storyboard if !storyboards
|
||||||
|
|
||||||
|
# The base URL is the first chunk
|
||||||
|
base_url = URI.parse(storyboards.shift)
|
||||||
|
|
||||||
|
return storyboards.map_with_index do |sb, i|
|
||||||
|
# Separate the different storyboard parameters:
|
||||||
|
# width/height: respective dimensions, in pixels, of a single thumbnail
|
||||||
|
# count: how many thumbnails are displayed across the full video
|
||||||
|
# columns/rows: maximum amount of thumbnails that can be stuffed in a
|
||||||
|
# single image, horizontally and vertically.
|
||||||
|
# interval: interval between two thumbnails, in milliseconds
|
||||||
|
# name: storyboard filename. Usually "M$M" or "default"
|
||||||
|
# sigh: URL cryptographic signature
|
||||||
|
width, height, count, columns, rows, interval, name, sigh = sb.split("#")
|
||||||
|
|
||||||
|
width = width.to_i
|
||||||
|
height = height.to_i
|
||||||
|
count = count.to_i
|
||||||
|
interval = interval.to_i
|
||||||
|
columns = columns.to_i
|
||||||
|
rows = rows.to_i
|
||||||
|
|
||||||
|
# Copy base URL object, so that we can modify it
|
||||||
|
url = base_url.dup
|
||||||
|
|
||||||
|
# Add the signature to the URL
|
||||||
|
params = url.query_params
|
||||||
|
params["sigh"] = sigh
|
||||||
|
url.query_params = params
|
||||||
|
|
||||||
|
# Replace the template parts with what we have
|
||||||
|
url.path = url.path.sub("$L", i).sub("$N", name)
|
||||||
|
|
||||||
|
# This value represents the maximum amount of thumbnails that can fit
|
||||||
|
# in a single image. The last image (or the only one for short videos)
|
||||||
|
# will contain less thumbnails than that.
|
||||||
|
thumbnails_per_image = columns * rows
|
||||||
|
|
||||||
|
# This value represents the total amount of storyboards required to
|
||||||
|
# hold all of the thumbnails. It can't be less than 1.
|
||||||
|
images_count = (count / thumbnails_per_image).ceil.to_i
|
||||||
|
|
||||||
|
# Compute the interval when needed (in general, that's only required
|
||||||
|
# for the first "default" storyboard).
|
||||||
|
if interval == 0
|
||||||
|
interval = ((length_seconds / count) * 1_000).to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
Storyboard.new(
|
||||||
|
url: url,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
count: count,
|
||||||
|
interval: interval,
|
||||||
|
rows: rows,
|
||||||
|
columns: columns,
|
||||||
|
images_count: images_count,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -110,13 +110,13 @@ module Invidious::Videos
|
|||||||
"Language" => @language_code,
|
"Language" => @language_code,
|
||||||
}
|
}
|
||||||
|
|
||||||
vtt = WebVTT.build(settings_field) do |vtt|
|
vtt = WebVTT.build(settings_field) do |builder|
|
||||||
@lines.each do |line|
|
@lines.each do |line|
|
||||||
# Section headers are excluded from the VTT conversion as to
|
# Section headers are excluded from the VTT conversion as to
|
||||||
# match the regular captions returned from YouTube as much as possible
|
# match the regular captions returned from YouTube as much as possible
|
||||||
next if line.is_a? HeadingLine
|
next if line.is_a? HeadingLine
|
||||||
|
|
||||||
vtt.cue(line.start_ms, line.end_ms, line.line)
|
builder.cue(line.start_ms, line.end_ms, line.line)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -6,4 +6,7 @@
|
|||||||
title="<%= translate(locale, "search") %>"
|
title="<%= translate(locale, "search") %>"
|
||||||
value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
|
value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<button type="submit" id="searchbutton" aria-label="<%= translate(locale, "search") %>">
|
||||||
|
<i class="icon ion-ios-search"></i>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
@ -62,7 +62,7 @@ we're going to need to do it here in order to allow for translations.
|
|||||||
"params" => params,
|
"params" => params,
|
||||||
"preferences" => preferences,
|
"preferences" => preferences,
|
||||||
"premiere_timestamp" => video.premiere_timestamp.try &.to_unix,
|
"premiere_timestamp" => video.premiere_timestamp.try &.to_unix,
|
||||||
"vr" => video.is_vr,
|
"vr" => video.vr?,
|
||||||
"projection_type" => video.projection_type,
|
"projection_type" => video.projection_type,
|
||||||
"local_disabled" => CONFIG.disabled?("local"),
|
"local_disabled" => CONFIG.disabled?("local"),
|
||||||
"support_reddit" => true
|
"support_reddit" => true
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
def add_yt_headers(request)
|
def add_yt_headers(request)
|
||||||
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
|
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
|
||||||
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"
|
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
|
request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
|
||||||
request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
||||||
|
121
src/invidious/yt_backend/url_sanitizer.cr
Normal file
121
src/invidious/yt_backend/url_sanitizer.cr
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
require "uri"
|
||||||
|
|
||||||
|
module UrlSanitizer
|
||||||
|
extend self
|
||||||
|
|
||||||
|
ALLOWED_QUERY_PARAMS = {
|
||||||
|
channel: ["u", "user", "lb"],
|
||||||
|
playlist: ["list"],
|
||||||
|
search: ["q", "search_query", "sp"],
|
||||||
|
watch: [
|
||||||
|
"v", # Video ID
|
||||||
|
"list", "index", # Playlist-related
|
||||||
|
"playlist", # Unnamed playlist (id,id,id,...) (embed-only?)
|
||||||
|
"t", "time_continue", "start", "end", # Timestamp
|
||||||
|
"lc", # Highlighted comment (watch page only)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Returns whether the given string is an ASCII word. This is the same as
|
||||||
|
# running the following regex in US-ASCII locale: /^[\w-]+$/
|
||||||
|
private def ascii_word?(str : String) : Bool
|
||||||
|
return false if str.bytesize != str.size
|
||||||
|
|
||||||
|
str.each_byte do |byte|
|
||||||
|
next if 'a'.ord <= byte <= 'z'.ord
|
||||||
|
next if 'A'.ord <= byte <= 'Z'.ord
|
||||||
|
next if '0'.ord <= byte <= '9'.ord
|
||||||
|
next if byte == '-'.ord || byte == '_'.ord
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return which kind of parameters are allowed based on the
|
||||||
|
# first path component (breadcrumb 0).
|
||||||
|
private def determine_allowed(path_root : String)
|
||||||
|
case path_root
|
||||||
|
when "watch", "w", "v", "embed", "e", "shorts", "clip"
|
||||||
|
return :watch
|
||||||
|
when .starts_with?("@"), "c", "channel", "user", "profile", "attribution_link"
|
||||||
|
return :channel
|
||||||
|
when "playlist", "mix"
|
||||||
|
return :playlist
|
||||||
|
when "results", "search"
|
||||||
|
return :search
|
||||||
|
else # hashtag, post, trending, brand URLs, etc..
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a new URI::Param containing only the allowed parameters
|
||||||
|
private def copy_params(unsafe_params : URI::Params, allowed_type) : URI::Params
|
||||||
|
new_params = URI::Params.new
|
||||||
|
|
||||||
|
ALLOWED_QUERY_PARAMS[allowed_type].each do |name|
|
||||||
|
if unsafe_params[name]?
|
||||||
|
# Only copy the last parameter, in case there is more than one
|
||||||
|
new_params[name] = unsafe_params.fetch_all(name)[-1]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return new_params
|
||||||
|
end
|
||||||
|
|
||||||
|
# Transform any user-supplied youtube URL into something we can trust
|
||||||
|
# and use across the code.
|
||||||
|
def process(str : String) : URI
|
||||||
|
# Because URI follows RFC3986 specifications, URL without a scheme
|
||||||
|
# will be parsed as a relative path. So we have to add a scheme ourselves.
|
||||||
|
str = "https://#{str}" if !str.starts_with?(/https?:\/\//)
|
||||||
|
|
||||||
|
unsafe_uri = URI.parse(str)
|
||||||
|
unsafe_host = unsafe_uri.host
|
||||||
|
unsafe_path = unsafe_uri.path
|
||||||
|
|
||||||
|
new_uri = URI.new(path: "/")
|
||||||
|
|
||||||
|
# Redirect to homepage for bogus URLs
|
||||||
|
return new_uri if (unsafe_host.nil? || unsafe_path.nil?)
|
||||||
|
|
||||||
|
breadcrumbs = unsafe_path
|
||||||
|
.split('/', remove_empty: true)
|
||||||
|
.compact_map do |bc|
|
||||||
|
# Exclude attempts at path trasversal
|
||||||
|
next if bc == "." || bc == ".."
|
||||||
|
|
||||||
|
# Non-alnum characters are unlikely in a genuine URL
|
||||||
|
next if !ascii_word?(bc)
|
||||||
|
|
||||||
|
bc
|
||||||
|
end
|
||||||
|
|
||||||
|
# If nothing remains, it's either a legit URL to the homepage
|
||||||
|
# (who does that!?) or because we filtered some junk earlier.
|
||||||
|
return new_uri if breadcrumbs.empty?
|
||||||
|
|
||||||
|
# Replace the original query parameters with the sanitized ones
|
||||||
|
case unsafe_host
|
||||||
|
when .ends_with?("youtube.com")
|
||||||
|
# Use our sanitized path (not forgetting the leading '/')
|
||||||
|
new_uri.path = "/#{breadcrumbs.join('/')}"
|
||||||
|
|
||||||
|
# Then determine which params are allowed, and copy them over
|
||||||
|
if allowed = determine_allowed(breadcrumbs[0])
|
||||||
|
new_uri.query_params = copy_params(unsafe_uri.query_params, allowed)
|
||||||
|
end
|
||||||
|
when "youtu.be"
|
||||||
|
# Always redirect to the watch page
|
||||||
|
new_uri.path = "/watch"
|
||||||
|
|
||||||
|
new_params = copy_params(unsafe_uri.query_params, :watch)
|
||||||
|
new_params["id"] = breadcrumbs[0]
|
||||||
|
|
||||||
|
new_uri.query_params = new_params
|
||||||
|
end
|
||||||
|
|
||||||
|
return new_uri
|
||||||
|
end
|
||||||
|
end
|
@ -6,10 +6,10 @@ module YoutubeAPI
|
|||||||
extend self
|
extend self
|
||||||
|
|
||||||
# For Android versions, see https://en.wikipedia.org/wiki/Android_version_history
|
# For Android versions, see https://en.wikipedia.org/wiki/Android_version_history
|
||||||
private ANDROID_APP_VERSION = "19.14.42"
|
private ANDROID_APP_VERSION = "19.32.34"
|
||||||
private ANDROID_USER_AGENT = "com.google.android.youtube/19.14.42 (Linux; U; Android 12; US) gzip"
|
|
||||||
private ANDROID_SDK_VERSION = 31_i64
|
|
||||||
private ANDROID_VERSION = "12"
|
private ANDROID_VERSION = "12"
|
||||||
|
private ANDROID_USER_AGENT = "com.google.android.youtube/#{ANDROID_APP_VERSION} (Linux; U; Android #{ANDROID_VERSION}; US) gzip"
|
||||||
|
private ANDROID_SDK_VERSION = 31_i64
|
||||||
|
|
||||||
private ANDROID_TS_APP_VERSION = "1.9"
|
private ANDROID_TS_APP_VERSION = "1.9"
|
||||||
private ANDROID_TS_USER_AGENT = "com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip"
|
private ANDROID_TS_USER_AGENT = "com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip"
|
||||||
@ -17,9 +17,9 @@ module YoutubeAPI
|
|||||||
# For Apple device names, see https://gist.github.com/adamawolf/3048717
|
# For Apple device names, see https://gist.github.com/adamawolf/3048717
|
||||||
# For iOS versions, see https://en.wikipedia.org/wiki/IOS_version_history#Releases,
|
# For iOS versions, see https://en.wikipedia.org/wiki/IOS_version_history#Releases,
|
||||||
# then go to the dedicated article of the major version you want.
|
# then go to the dedicated article of the major version you want.
|
||||||
private IOS_APP_VERSION = "19.16.3"
|
private IOS_APP_VERSION = "19.32.8"
|
||||||
private IOS_USER_AGENT = "com.google.ios.youtube/19.16.3 (iPhone14,5; U; CPU iOS 17_4 like Mac OS X;)"
|
private IOS_USER_AGENT = "com.google.ios.youtube/#{IOS_APP_VERSION} (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)"
|
||||||
private IOS_VERSION = "17.4.0.21E219" # Major.Minor.Patch.Build
|
private IOS_VERSION = "17.6.1.21G93" # Major.Minor.Patch.Build
|
||||||
|
|
||||||
private WINDOWS_VERSION = "10.0"
|
private WINDOWS_VERSION = "10.0"
|
||||||
|
|
||||||
@ -48,7 +48,7 @@ module YoutubeAPI
|
|||||||
ClientType::Web => {
|
ClientType::Web => {
|
||||||
name: "WEB",
|
name: "WEB",
|
||||||
name_proto: "1",
|
name_proto: "1",
|
||||||
version: "2.20240304.00.00",
|
version: "2.20240814.00.00",
|
||||||
screen: "WATCH_FULL_SCREEN",
|
screen: "WATCH_FULL_SCREEN",
|
||||||
os_name: "Windows",
|
os_name: "Windows",
|
||||||
os_version: WINDOWS_VERSION,
|
os_version: WINDOWS_VERSION,
|
||||||
@ -57,7 +57,7 @@ module YoutubeAPI
|
|||||||
ClientType::WebEmbeddedPlayer => {
|
ClientType::WebEmbeddedPlayer => {
|
||||||
name: "WEB_EMBEDDED_PLAYER",
|
name: "WEB_EMBEDDED_PLAYER",
|
||||||
name_proto: "56",
|
name_proto: "56",
|
||||||
version: "1.20240303.00.00",
|
version: "1.20240812.01.00",
|
||||||
screen: "EMBED",
|
screen: "EMBED",
|
||||||
os_name: "Windows",
|
os_name: "Windows",
|
||||||
os_version: WINDOWS_VERSION,
|
os_version: WINDOWS_VERSION,
|
||||||
@ -66,7 +66,7 @@ module YoutubeAPI
|
|||||||
ClientType::WebMobile => {
|
ClientType::WebMobile => {
|
||||||
name: "MWEB",
|
name: "MWEB",
|
||||||
name_proto: "2",
|
name_proto: "2",
|
||||||
version: "2.20240304.08.00",
|
version: "2.20240813.02.00",
|
||||||
os_name: "Android",
|
os_name: "Android",
|
||||||
os_version: ANDROID_VERSION,
|
os_version: ANDROID_VERSION,
|
||||||
platform: "MOBILE",
|
platform: "MOBILE",
|
||||||
@ -74,7 +74,7 @@ module YoutubeAPI
|
|||||||
ClientType::WebScreenEmbed => {
|
ClientType::WebScreenEmbed => {
|
||||||
name: "WEB",
|
name: "WEB",
|
||||||
name_proto: "1",
|
name_proto: "1",
|
||||||
version: "2.20240304.00.00",
|
version: "2.20240814.00.00",
|
||||||
screen: "EMBED",
|
screen: "EMBED",
|
||||||
os_name: "Windows",
|
os_name: "Windows",
|
||||||
os_version: WINDOWS_VERSION,
|
os_version: WINDOWS_VERSION,
|
||||||
@ -147,8 +147,8 @@ module YoutubeAPI
|
|||||||
ClientType::IOSMusic => {
|
ClientType::IOSMusic => {
|
||||||
name: "IOS_MUSIC",
|
name: "IOS_MUSIC",
|
||||||
name_proto: "26",
|
name_proto: "26",
|
||||||
version: "6.42",
|
version: "7.14",
|
||||||
user_agent: "com.google.ios.youtubemusic/6.42 (iPhone14,5; U; CPU iOS 17_4 like Mac OS X;)",
|
user_agent: "com.google.ios.youtubemusic/7.14 (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)",
|
||||||
device_make: "Apple",
|
device_make: "Apple",
|
||||||
device_model: "iPhone14,5",
|
device_model: "iPhone14,5",
|
||||||
os_name: "iPhone",
|
os_name: "iPhone",
|
||||||
@ -161,7 +161,7 @@ module YoutubeAPI
|
|||||||
ClientType::TvHtml5 => {
|
ClientType::TvHtml5 => {
|
||||||
name: "TVHTML5",
|
name: "TVHTML5",
|
||||||
name_proto: "7",
|
name_proto: "7",
|
||||||
version: "7.20240304.10.00",
|
version: "7.20240813.07.00",
|
||||||
},
|
},
|
||||||
ClientType::TvHtml5ScreenEmbed => {
|
ClientType::TvHtml5ScreenEmbed => {
|
||||||
name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
|
name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
|
||||||
|
Loading…
Reference in New Issue
Block a user