From 48cbe45a9d3212a6f32551741213b028700d59b2 Mon Sep 17 00:00:00 2001 From: edumoreira1506 Date: Wed, 20 Nov 2019 15:59:07 -0300 Subject: [PATCH 0001/2881] Add Previous/Next page buttons at the top of the page --- src/invidious/views/search.ecr | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index d084bd31..bc13b7ea 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -2,6 +2,24 @@ <%= search_query.not_nil!.size > 30 ? HTML.escape(query.not_nil![0,30].rstrip(".") + "...") : HTML.escape(query.not_nil!) %> - Invidious <% end %> +
+
+ <% if page > 1 %> + + <%= translate(locale, "Previous page") %> + + <% end %> +
+
+
+ <% if count >= 20 %> + + <%= translate(locale, "Next page") %> + + <% end %> +
+
+
<% videos.each_slice(4) do |slice| %> <% slice.each do |item| %> From 1fc9506442ae18c7c7f0a684a59714e679678a54 Mon Sep 17 00:00:00 2001 From: Alexander Pushkov Date: Tue, 21 Jan 2020 15:36:56 +0300 Subject: [PATCH 0002/2881] Add audio mode link to items --- src/invidious/views/components/item.ecr | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index f7b9cce6..61ec3ce4 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -138,10 +138,13 @@ <% end %>

<%= item.title %>

-

- +

+ <%= item.author %> + + +

From e3593fe197369c583fc6f91292ff8cc06f87eced Mon Sep 17 00:00:00 2001 From: Leon Klingele Date: Mon, 19 Aug 2019 12:10:25 +0200 Subject: [PATCH 0003/2881] js: add support to detect media keys in keydown handler See [0] for all the relevant codes. [0]: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values#Multimedia_keys Fixes a regression introduced in e6b4e1268945777c5d07dfca4362a1af23f6d970. Fixes https://github.com/omarroth/invidious/issues/712. --- assets/js/player.js | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/assets/js/player.js b/assets/js/player.js index eecc0868..dc1e633f 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -228,11 +228,24 @@ function set_time_percent(percent) { player.currentTime(newTime); } +function play() { + player.play(); +} + +function pause() { + player.pause(); +} + +function stop() { + player.pause(); + player.currentTime(0); +} + function toggle_play() { if (player.paused()) { - player.play(); + play(); } else { - player.pause(); + pause(); } } @@ -338,9 +351,22 @@ window.addEventListener('keydown', e => { switch (decoratedKey) { case ' ': case 'k': + case 'MediaPlayPause': action = toggle_play; break; + case 'MediaPlay': + action = play; + break; + + case 'MediaPause': + action = pause; + break; + + case 'MediaStop': + action = stop; + break; + case 'ArrowUp': if (isPlayerFocused) { action = increase_volume.bind(this, 0.1); @@ -357,9 +383,11 @@ window.addEventListener('keydown', e => { break; case 'ArrowRight': + case 'MediaFastForward': action = skip_seconds.bind(this, 5); break; case 'ArrowLeft': + case 'MediaTrackPrevious': action = skip_seconds.bind(this, -5); break; case 'l': @@ -391,9 +419,11 @@ window.addEventListener('keydown', e => { break; case 'N': + case 'MediaTrackNext': action = next_video; break; case 'P': + case 'MediaTrackPrevious': // TODO: Add support to play back previous video. break; From 5d8de5fde2dee11ee8feb63f0bce74d373eec56f Mon Sep 17 00:00:00 2001 From: Dmitry Sandalov Date: Sun, 17 May 2020 14:28:00 +0300 Subject: [PATCH 0004/2881] Allow user to subscribe to playlist (#17) --- src/invidious.cr | 33 ++++++++++++++++++---- src/invidious/playlists.cr | 21 ++++++++++++++ src/invidious/views/playlist.ecr | 6 ++++ src/invidious/views/view_all_playlists.ecr | 18 ++++++++++-- src/invidious/views/watch.ecr | 2 +- 5 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/invidious.cr b/src/invidious.cr index 6a197795..56722b7e 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -846,8 +846,14 @@ get "/view_all_playlists" do |env| user = user.as(User) - items = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 ORDER BY created", user.email, as: InvidiousPlaylist) - items.map! do |item| + items_created = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) + items_created.map! do |item| + item.author = "" + item + end + + items_saved = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id NOT LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) + items_saved.map! do |item| item.author = "" item end @@ -918,6 +924,25 @@ post "/create_playlist" do |env| env.redirect "/playlist?list=#{playlist.id}" end +get "/subscribe_playlist" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + user = user.as(User) + + playlist_id = env.params.query["list"] + playlist = get_playlist(PG_DB, playlist_id, locale) + subscribe_playlist(PG_DB, user, playlist) + + env.redirect "/playlist?list=#{playlist.id}" +end + get "/delete_playlist" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? @@ -933,10 +958,6 @@ get "/delete_playlist" do |env| sid = sid.as(String) plid = env.params.query["list"]? - if !plid || !plid.starts_with?("IV") - next env.redirect referer - end - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) if !playlist || playlist.author != user.email next env.redirect referer diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 9c8afd3c..184329dc 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -277,6 +277,27 @@ def create_playlist(db, title, privacy, user) return playlist end +def subscribe_playlist(db, user, playlist) + playlist = InvidiousPlaylist.new( + title: playlist.title.byte_slice(0, 150), + id: playlist.id, + author: user.email, + description: "", # Max 5000 characters + video_count: playlist.video_count, + created: Time.utc, + updated: playlist.updated, + privacy: PlaylistPrivacy::Private, + index: [] of Int64, + ) + + playlist_array = playlist.to_a + args = arg_array(playlist_array) + + db.exec("INSERT INTO playlists VALUES (#{args})", args: playlist_array) + + return playlist +end + def extract_playlist(plid, nodeset, index) videos = [] of PlaylistVideo diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index 7316af14..bb721c3a 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -45,6 +45,12 @@ <% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %>
+ <% else %> + <% if PG_DB.query_one?("SELECT id FROM playlists WHERE id = $1", playlist.id, as: String).nil? %> +
+ <% else %> +
+ <% end %> <% end %>
diff --git a/src/invidious/views/view_all_playlists.ecr b/src/invidious/views/view_all_playlists.ecr index 0fa7a325..5ec6aa31 100644 --- a/src/invidious/views/view_all_playlists.ecr +++ b/src/invidious/views/view_all_playlists.ecr @@ -6,7 +6,7 @@
-

<%= translate(locale, "`x` playlists", %(#{items.size})) %>

+

<%= translate(locale, "`x` created playlists", %(#{items_created.size})) %>

@@ -16,7 +16,21 @@

- <% items.each_slice(4) do |slice| %> + <% items_created.each_slice(4) do |slice| %> + <% slice.each do |item| %> + <%= rendered "components/item" %> + <% end %> + <% end %> +
+ +
+
+

<%= translate(locale, "`x` saved playlists", %(#{items_saved.size})) %>

+
+
+ +
+ <% items_saved.each_slice(4) do |slice| %> <% slice.each do |item| %> <%= rendered "components/item" %> <% end %> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 7c9acb14..61c3b7dc 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -102,7 +102,7 @@

<% if user %> - <% playlists = PG_DB.query_all("SELECT id,title FROM playlists WHERE author = $1", user.email, as: {String, String}) %> + <% playlists = PG_DB.query_all("SELECT id,title FROM playlists WHERE author = $1 AND id LIKE 'IV%'", user.email, as: {String, String}) %> <% if !playlists.empty? %>
From 50bab26a3a6409b2a23ff5537f0469434fd4c0b9 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Sat, 9 Nov 2019 14:21:31 -0500 Subject: [PATCH 0005/2881] Add support for CONNECT proxy --- src/invidious.cr | 66 ++++++++++++++++++++++++- src/invidious/helpers/handlers.cr | 29 +++++++++++ src/invidious/helpers/helpers.cr | 4 ++ src/invidious/helpers/jobs.cr | 82 +++++++++++++++++++++++-------- src/invidious/helpers/proxy.cr | 8 ++- src/invidious/helpers/utils.cr | 5 +- src/invidious/mixes.cr | 2 +- src/invidious/trending.cr | 2 +- 8 files changed, 170 insertions(+), 28 deletions(-) diff --git a/src/invidious.cr b/src/invidious.cr index 6a197795..b0a99d21 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -5568,7 +5568,7 @@ get "/videoplayback" do |env| next env.redirect location end - IO.copy(response.body_io, env.response) + IO.copy response.body_io, env.response end rescue ex end @@ -5865,6 +5865,69 @@ get "/Captcha" do |env| response.body end +connect "*" do |env| + if CONFIG.proxy_address.empty? + env.response.status_code = 400 + next + end + + url = env.request.headers["Host"]?.try { |u| u.split(":") } + host = url.try &.[0]? + port = url.try &.[1]? + + host = "www.google.com" if !host || host.empty? + port = "443" if !port || port.empty? + + # if env.request.internal_uri + # env.request.internal_uri.not_nil!.path = "#{host}:#{port}" + # end + + user, pass = env.request.headers["Proxy-Authorization"]? + .try { |i| i.lchop("Basic ") } + .try { |i| Base64.decode_string(i) } + .try &.split(":", 2) || {nil, nil} + + if CONFIG.proxy_user != user || CONFIG.proxy_pass != pass + env.response.status_code = 403 + next + end + + begin + upstream = TCPSocket.new(host, port) + rescue ex + logger.puts("Exception: #{ex.message}") + env.response.status_code = 400 + next + end + + env.response.reset + env.response.upgrade do |downstream| + downstream = downstream.as(TCPSocket) + downstream.sync = true + + spawn do + begin + bytes = 1 + while bytes != 0 + bytes = IO.copy upstream, downstream + end + rescue ex + end + end + + begin + bytes = 1 + while bytes != 0 + bytes = IO.copy downstream, upstream + end + rescue ex + ensure + upstream.close + downstream.close + end + end +end + # Undocumented, creates anonymous playlist with specified 'video_ids', max 50 videos get "/watch_videos" do |env| response = YT_POOL.client &.get(env.request.resource) @@ -5939,6 +6002,7 @@ end public_folder "assets" Kemal.config.powered_by_header = false +add_handler ProxyHandler.new add_handler FilteredCompressHandler.new add_handler APIHandler.new add_handler AuthHandler.new diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index 87b10bc9..f3b99b7d 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -212,3 +212,32 @@ class DenyFrame < Kemal::Handler call_next env end end + +class ProxyHandler < Kemal::Handler + def call(env) + if env.request.headers["Proxy-Authorization"]? && env.request.method != "CONNECT" + user, pass = env.request.headers["Proxy-Authorization"]? + .try { |i| i.lchop("Basic ") } + .try { |i| Base64.decode_string(i) } + .try &.split(":", 2) || {nil, nil} + + if CONFIG.proxy_user != user || CONFIG.proxy_pass != pass + env.response.status_code = 403 + return + end + + HTTP::Client.exec(env.request.method, "#{env.request.headers["Host"]?}#{env.request.resource}", env.request.headers, env.request.body) do |response| + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) && key.downcase != "transfer-encoding" + env.response.headers[key] = value + end + end + IO.copy response.body_io, env.response + end + env.response.close + return + else + call_next env + end + end +end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 96d14737..ba095bc3 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -263,6 +263,10 @@ struct Config admin_email: {type: String, default: "omarroth@protonmail.com"}, # Email for bug reports cookies: {type: HTTP::Cookies, default: HTTP::Cookies.new, converter: StringToCookies}, # Saved cookies in "name1=value1; name2=value2..." format captcha_key: {type: String?, default: nil}, # Key for Anti-Captcha + proxy_address: {type: String, default: ""}, + proxy_port: {type: Int32, default: 8080}, + proxy_user: {type: String, default: ""}, + proxy_pass: {type: String, default: ""}, }) end diff --git a/src/invidious/helpers/jobs.cr b/src/invidious/helpers/jobs.cr index c6e0ef42..02c3ab05 100644 --- a/src/invidious/helpers/jobs.cr +++ b/src/invidious/helpers/jobs.cr @@ -249,15 +249,34 @@ def bypass_captcha(captcha_key, logger) end headers = response.cookies.add_request_headers(HTTP::Headers.new) - - response = JSON.parse(HTTP::Client.post("https://api.anti-captcha.com/createTask", body: { - "clientKey" => CONFIG.captcha_key, - "task" => { - "type" => "NoCaptchaTaskProxyless", - "websiteURL" => "https://www.youtube.com/watch?v=CvFH_6DNRCY&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999", - "websiteKey" => site_key, - }, - }.to_json).body) + captcha_client = HTTPClient.new(URI.parse("https://api.anti-captcha.com")) + captcha_client.family = CONFIG.force_resolve || Socket::Family::INET + if !CONFIG.proxy_address.empty? + response = JSON.parse(captcha_client.post("/createTask", body: { + "clientKey" => CONFIG.captcha_key, + "task" => { + "type" => "NoCaptchaTask", + "websiteURL" => "https://www.youtube.com#{path}", + "websiteKey" => site_key, + "proxyType" => "http", + "proxyAddress" => CONFIG.proxy_address, + "proxyPort" => CONFIG.proxy_port, + "proxyLogin" => CONFIG.proxy_user, + "proxyPassword" => CONFIG.proxy_pass, + "userAgent" => headers["user-agent"], + }, + }.to_json).body) + else + response = JSON.parse(captcha_client.post("/createTask", body: { + "clientKey" => CONFIG.captcha_key, + "task" => { + "type" => "NoCaptchaTaskProxyless", + "websiteURL" => "https://www.youtube.com#{path}", + "websiteKey" => site_key, + "userAgent" => headers["user-agent"], + }, + }.to_json).body) + end raise response["error"].as_s if response["error"]? task_id = response["taskId"].as_i @@ -265,7 +284,7 @@ def bypass_captcha(captcha_key, logger) loop do sleep 10.seconds - response = JSON.parse(HTTP::Client.post("https://api.anti-captcha.com/getTaskResult", body: { + response = JSON.parse(captcha_client.post("/getTaskResult", body: { "clientKey" => CONFIG.captcha_key, "taskId" => task_id, }.to_json).body) @@ -283,7 +302,11 @@ def bypass_captcha(captcha_key, logger) yield response.cookies.select { |cookie| cookie.name != "PREF" } elsif response.headers["Location"]?.try &.includes?("/sorry/index") location = response.headers["Location"].try { |u| URI.parse(u) } - headers = HTTP::Headers{":authority" => location.host.not_nil!} + headers = HTTP::Headers{ + ":authority" => location.host.not_nil!, + "origin" => "https://www.google.com", + "user-agent" => "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36", + } response = YT_POOL.client &.get(location.full_path, headers) html = XML.parse_html(response.body) @@ -297,14 +320,32 @@ def bypass_captcha(captcha_key, logger) captcha_client = HTTPClient.new(URI.parse("https://api.anti-captcha.com")) captcha_client.family = CONFIG.force_resolve || Socket::Family::INET - response = JSON.parse(captcha_client.post("/createTask", body: { - "clientKey" => CONFIG.captcha_key, - "task" => { - "type" => "NoCaptchaTaskProxyless", - "websiteURL" => location.to_s, - "websiteKey" => site_key, - }, - }.to_json).body) + if !CONFIG.proxy_address.empty? + response = JSON.parse(captcha_client.post("/createTask", body: { + "clientKey" => CONFIG.captcha_key, + "task" => { + "type" => "NoCaptchaTask", + "websiteURL" => location.to_s, + "websiteKey" => site_key, + "proxyType" => "http", + "proxyAddress" => CONFIG.proxy_address, + "proxyPort" => CONFIG.proxy_port, + "proxyLogin" => CONFIG.proxy_user, + "proxyPassword" => CONFIG.proxy_pass, + "userAgent" => headers["user-agent"], + }, + }.to_json).body) + else + response = JSON.parse(captcha_client.post("/createTask", body: { + "clientKey" => CONFIG.captcha_key, + "task" => { + "type" => "NoCaptchaTaskProxyless", + "websiteURL" => location.to_s, + "websiteKey" => site_key, + "userAgent" => headers["user-agent"], + }, + }.to_json).body) + end raise response["error"].as_s if response["error"]? task_id = response["taskId"].as_i @@ -326,8 +367,7 @@ def bypass_captcha(captcha_key, logger) inputs["g-recaptcha-response"] = response["solution"]["gRecaptchaResponse"].as_s headers["content-type"] = "application/x-www-form-urlencoded" - headers["origin"] = "https://www.google.com" - headers["user-agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36" + headers["referer"] = location.to_s response = YT_POOL.client &.post("/sorry/index", headers: headers, form: inputs) headers = HTTP::Headers{ diff --git a/src/invidious/helpers/proxy.cr b/src/invidious/helpers/proxy.cr index 4f415ba0..af114e29 100644 --- a/src/invidious/helpers/proxy.cr +++ b/src/invidious/helpers/proxy.cr @@ -1,3 +1,7 @@ +def connect(path : String, &block : HTTP::Server::Context -> _) + Kemal::RouteHandler::INSTANCE.add_route("CONNECT", path, &block) +end + # See https://github.com/crystal-lang/crystal/issues/2963 class HTTPProxy getter proxy_host : String @@ -124,7 +128,7 @@ def get_nova_proxies(country_code = "US") client.connect_timeout = 10.seconds headers = HTTP::Headers.new - headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" + headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36" headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9" headers["Host"] = "www.proxynova.com" @@ -161,7 +165,7 @@ def get_spys_proxies(country_code = "US") client.connect_timeout = 10.seconds headers = HTTP::Headers.new - headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" + headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36" headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9" headers["Host"] = "spys.one" diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 1fff206d..52ca0f82 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -2,11 +2,12 @@ require "lsquic" require "pool/connection" def add_yt_headers(request) - request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36" + return if request.resource.starts_with? "/sorry/index" + + request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36" 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-language"] ||= "en-us,en;q=0.5" - return if request.resource.starts_with? "/sorry/index" request.headers["x-youtube-client-name"] ||= "1" request.headers["x-youtube-client-version"] ||= "1.20180719" if !CONFIG.cookies.empty? diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr index 04a37b87..f5ff40ef 100644 --- a/src/invidious/mixes.cr +++ b/src/invidious/mixes.cr @@ -20,7 +20,7 @@ end def fetch_mix(rdid, video_id, cookies = nil, locale = nil) headers = HTTP::Headers.new - headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36" + headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36" if cookies headers = cookies.add_request_headers(headers) diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index 017c42f5..4f80be64 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -1,6 +1,6 @@ def fetch_trending(trending_type, region, locale) headers = HTTP::Headers.new - headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" + headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36" region ||= "US" region = region.upcase From 80941eacbd72f328f7b1c77f4a936ebd9baa80fa Mon Sep 17 00:00:00 2001 From: mendel5 <60322520+mendel5@users.noreply.github.com> Date: Tue, 26 May 2020 09:57:10 +0200 Subject: [PATCH 0006/2881] More consistent HTML IDs for info section (#1156) * More consistent IDs for info section More consistent IDs for info section: watch-on-youtube, annotations and download * Consistent IDs: channel-name * Consistent IDs: published-date The term "published" can also be found in the answer for the following YouTube API request: https://developers.google.com/youtube/v3/docs/videos/list --- src/invidious/views/watch.ecr | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 7c9acb14..c46bf280 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -86,10 +86,10 @@
- + <%= translate(locale, "Watch on YouTube") %> -

+

<% if params.annotations %> <%= translate(locale, "Hide annotations") %> @@ -130,7 +130,7 @@ <% end %> <% if CONFIG.dmca_content.includes?(video.id) || CONFIG.disabled?("downloads") %> -

<%= translate(locale, "Download is disabled.") %>

+

<%= translate(locale, "Download is disabled.") %>

<% else %>
@@ -199,7 +199,7 @@
- <%= video.author %> + <%= video.author %>
@@ -208,7 +208,7 @@ <% sub_count_text = video.sub_count_text %> <%= rendered "components/subscribe_widget" %> -

+

<% if video.premiere_timestamp %> <%= translate(locale, "Premieres `x`", video.premiere_timestamp.not_nil!.to_s("%B %-d, %R UTC")) %> <% else %> From af7c57b082d3800343b313746e7a346c4d1051f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Allan=20Nordh=C3=B8y?= Date: Tue, 26 May 2020 18:02:21 +0000 Subject: [PATCH 0007/2881] TRANSLATION file for l10n --- TRANSLATION | 1 + 1 file changed, 1 insertion(+) create mode 100644 TRANSLATION diff --git a/TRANSLATION b/TRANSLATION new file mode 100644 index 00000000..fa340d71 --- /dev/null +++ b/TRANSLATION @@ -0,0 +1 @@ +https://hosted.weblate.org/projects/invidious/ From 6435c7b92161d573babd90cbe354b9671aeb5987 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Thu, 28 May 2020 11:47:48 -0500 Subject: [PATCH 0008/2881] Fix reCaptcha --- src/invidious/helpers/jobs.cr | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/invidious/helpers/jobs.cr b/src/invidious/helpers/jobs.cr index c6e0ef42..6479fa90 100644 --- a/src/invidious/helpers/jobs.cr +++ b/src/invidious/helpers/jobs.cr @@ -241,7 +241,8 @@ def bypass_captcha(captcha_key, logger) if response.body.includes?("To continue with your YouTube experience, please fill out the form below.") html = XML.parse_html(response.body) form = html.xpath_node(%(//form[@action="/das_captcha"])).not_nil! - site_key = form.xpath_node(%(.//div[@class="g-recaptcha"])).try &.["data-sitekey"] + site_key = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-sitekey"] + s_value = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-s"] inputs = {} of String => String form.xpath_nodes(%(.//input[@name])).map do |node| @@ -253,9 +254,10 @@ def bypass_captcha(captcha_key, logger) response = JSON.parse(HTTP::Client.post("https://api.anti-captcha.com/createTask", body: { "clientKey" => CONFIG.captcha_key, "task" => { - "type" => "NoCaptchaTaskProxyless", - "websiteURL" => "https://www.youtube.com/watch?v=CvFH_6DNRCY&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999", - "websiteKey" => site_key, + "type" => "NoCaptchaTaskProxyless", + "websiteURL" => "https://www.youtube.com#{path}", + "websiteKey" => site_key, + "recaptchaDataSValue" => s_value, }, }.to_json).body) @@ -278,6 +280,7 @@ def bypass_captcha(captcha_key, logger) end inputs["g-recaptcha-response"] = response["solution"]["gRecaptchaResponse"].as_s + headers["Cookies"] = response["solution"]["cookies"].as_h.map { |k, v| "#{k}=#{v}" }.join("; ") response = YT_POOL.client &.post("/das_captcha", headers, form: inputs) yield response.cookies.select { |cookie| cookie.name != "PREF" } @@ -288,7 +291,8 @@ def bypass_captcha(captcha_key, logger) html = XML.parse_html(response.body) form = html.xpath_node(%(//form[@action="index"])).not_nil! - site_key = form.xpath_node(%(.//div[@class="g-recaptcha"])).try &.["data-sitekey"] + site_key = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-sitekey"] + s_value = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-s"] inputs = {} of String => String form.xpath_nodes(%(.//input[@name])).map do |node| @@ -300,9 +304,10 @@ def bypass_captcha(captcha_key, logger) response = JSON.parse(captcha_client.post("/createTask", body: { "clientKey" => CONFIG.captcha_key, "task" => { - "type" => "NoCaptchaTaskProxyless", - "websiteURL" => location.to_s, - "websiteKey" => site_key, + "type" => "NoCaptchaTaskProxyless", + "websiteURL" => location.to_s, + "websiteKey" => site_key, + "recaptchaDataSValue" => s_value, }, }.to_json).body) @@ -325,10 +330,7 @@ def bypass_captcha(captcha_key, logger) end inputs["g-recaptcha-response"] = response["solution"]["gRecaptchaResponse"].as_s - headers["content-type"] = "application/x-www-form-urlencoded" - headers["origin"] = "https://www.google.com" - headers["user-agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36" - + headers["Cookies"] = response["solution"]["cookies"].as_h.map { |k, v| "#{k}=#{v}" }.join("; ") response = YT_POOL.client &.post("/sorry/index", headers: headers, form: inputs) headers = HTTP::Headers{ "Cookie" => URI.parse(response.headers["location"]).query_params["google_abuse"].split(";")[0], From 8305af8f1036e9cb2ffc8aab5eb738d73c9f5654 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Fri, 29 May 2020 20:06:43 -0500 Subject: [PATCH 0009/2881] Update docker build --- docker/Dockerfile | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index d0e4827a..505b8d88 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -3,17 +3,14 @@ RUN apk add --no-cache curl crystal shards libc-dev \ yaml-dev libxml2-dev sqlite-dev zlib-dev openssl-dev \ yaml-static sqlite-static zlib-static openssl-libs-static WORKDIR /invidious -RUN curl -Lo /etc/apk/keys/omarroth.rsa.pub https://github.com/omarroth/boringssl-alpine/releases/download/1.1.0-r0/omarroth.rsa.pub && \ - curl -Lo boringssl-dev.apk https://github.com/omarroth/boringssl-alpine/releases/download/1.1.0-r0/boringssl-dev-1.1.0-r0.apk && \ - curl -Lo lsquic.apk https://github.com/omarroth/lsquic-alpine/releases/download/2.6.3-r0/lsquic-2.6.3-r0.apk && \ - apk verify --no-cache boringssl-dev.apk lsquic.apk && \ - tar -xf boringssl-dev.apk usr/lib/libcrypto.a usr/lib/libssl.a && \ - tar -xf lsquic.apk usr/lib/liblsquic.a && \ - rm /etc/apk/keys/omarroth.rsa.pub boringssl-dev.apk lsquic.apk COPY ./shard.yml ./shard.yml RUN shards update && shards install && \ - mv ./usr/lib/* ./lib/lsquic/src/lsquic/ext && \ - rm -r ./usr /root/.cache + # TODO: Document build instructions + # See https://github.com/omarroth/boringssl-alpine/blob/master/APKBUILD, + # https://github.com/omarroth/lsquic-alpine/blob/master/APKBUILD, + # https://github.com/omarroth/lsquic.cr/issues/1#issuecomment-631610081 + # for details building static lib + curl -Lo ./lib/lsquic/src/lsquic/ext/liblsquic.a https://omar.yt/lsquic/liblsquic.a COPY ./src/ ./src/ # TODO: .git folder is required for building – this is destructive. # See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. From c422a6dd4ff47779425793fd48a7ee03b52260fd Mon Sep 17 00:00:00 2001 From: Sandro Date: Sat, 6 Jun 2020 04:12:43 +0200 Subject: [PATCH 0010/2881] Add RAM requirement Closes #1152 --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d8cc1c6e..3526e581 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,9 @@ $ docker volume rm invidious_postgresdata $ docker-compose build ``` -### Linux: +### Linux + +To manually compile invidious you need at least 2GB of RAM. If you have less please fall back to Docker. #### Install dependencies From 24013af3bb84368dda0e65c5e2fb8df0cb5348c6 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 15 Jun 2020 19:24:35 +0200 Subject: [PATCH 0011/2881] Mention SWAP --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3526e581..afe59c51 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ $ docker-compose build ### Linux -To manually compile invidious you need at least 2GB of RAM. If you have less please fall back to Docker. +To manually compile invidious you need at least 2GB of RAM. If you have less you can setup SWAP to have a combined amount of 2 GB or fall back to Docker. #### Install dependencies From d30a972a909e66d963ee953349fe045a1d9a41ee Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Mon, 15 Jun 2020 17:57:20 -0500 Subject: [PATCH 0012/2881] Support Crystal 0.35.0 --- shard.yml | 6 +-- src/invidious.cr | 4 +- src/invidious/helpers/handlers.cr | 4 +- src/invidious/helpers/helpers.cr | 50 +++++++++++++++----- src/invidious/helpers/static_file_handler.cr | 4 +- src/invidious/helpers/utils.cr | 2 +- 6 files changed, 48 insertions(+), 22 deletions(-) diff --git a/shard.yml b/shard.yml index 59baf650..2c1e54aa 100644 --- a/shard.yml +++ b/shard.yml @@ -11,13 +11,13 @@ targets: dependencies: pg: github: will/crystal-pg - version: ~> 0.21.0 + version: ~> 0.21.1 sqlite3: github: crystal-lang/crystal-sqlite3 version: ~> 0.16.0 kemal: github: kemalcr/kemal - version: ~> 0.26.1 + branch: master pool: github: ysbaddaden/pool version: ~> 0.2.3 @@ -28,6 +28,6 @@ dependencies: github: omarroth/lsquic.cr branch: dev -crystal: 0.34.0 +crystal: 0.35.0 license: AGPLv3 diff --git a/src/invidious.cr b/src/invidious.cr index 56722b7e..75d1e0d1 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -23,7 +23,7 @@ require "pg" require "sqlite3" require "xml" require "yaml" -require "zip" +require "compress/zip" require "protodec/utils" require "./invidious/helpers/*" require "./invidious/*" @@ -2660,7 +2660,7 @@ post "/data_control" do |env| PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) when "import_newpipe" - Zip::Reader.open(IO::Memory.new(body)) do |file| + Compress::Zip::Reader.open(IO::Memory.new(body)) do |file| file.each_entry do |entry| if entry.filename == "newpipe.db" tempfile = File.tempfile(".db") diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index 87b10bc9..d0b6c5a3 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -74,10 +74,10 @@ class FilteredCompressHandler < Kemal::Handler if request_headers.includes_word?("Accept-Encoding", "gzip") env.response.headers["Content-Encoding"] = "gzip" - env.response.output = Gzip::Writer.new(env.response.output, sync_close: true) + env.response.output = Compress::Gzip::Writer.new(env.response.output, sync_close: true) elsif request_headers.includes_word?("Accept-Encoding", "deflate") env.response.headers["Content-Encoding"] = "deflate" - env.response.output = Flate::Writer.new(env.response.output, sync_close: true) + env.response.output = Compress::Deflate::Writer.new(env.response.output, sync_close: true) end call_next env diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 96d14737..f16b5c6e 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -625,6 +625,7 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil) end def check_enum(db, logger, enum_name, struct_type = nil) + return # TODO if !db.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool) logger.puts("CREATE TYPE #{enum_name}") @@ -646,18 +647,14 @@ def check_table(db, logger, table_name, struct_type = nil) end end - if !struct_type - return - end + return if !struct_type struct_array = struct_type.to_type_tuple column_array = get_column_array(db, table_name) column_types = File.read("config/sql/#{table_name}.sql").match(/CREATE TABLE public\.#{table_name}\n\((?[\d\D]*?)\);/) - .try &.["types"].split(",").map { |line| line.strip } + .try &.["types"].split(",").map { |line| line.strip }.reject &.starts_with?("CONSTRAINT") - if !column_types - return - end + return if !column_types struct_array.each_with_index do |name, i| if name != column_array[i]? @@ -708,6 +705,15 @@ def check_table(db, logger, table_name, struct_type = nil) end end end + + return if column_array.size <= struct_array.size + + # column_array.each do |column| + # if !struct_array.includes? column + # logger.puts("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") + # db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") + # end + # end end class PG::ResultSet @@ -897,15 +903,35 @@ end def proxy_file(response, env) if response.headers.includes_word?("Content-Encoding", "gzip") - Gzip::Writer.open(env.response) do |deflate| - response.pipe(deflate) + Compress::Gzip::Writer.open(env.response) do |deflate| + IO.copy response.body_io, deflate end elsif response.headers.includes_word?("Content-Encoding", "deflate") - Flate::Writer.open(env.response) do |deflate| - response.pipe(deflate) + Compress::Deflate::Writer.open(env.response) do |deflate| + IO.copy response.body_io, deflate end else - response.pipe(env.response) + IO.copy response.body_io, env.response + end +end + +# See https://github.com/kemalcr/kemal/pull/576 +class HTTP::Server::Response::Output + def close + return if closed? + + unless response.wrote_headers? + response.content_length = @out_count + end + + ensure_headers_written + + super + + if @chunked + @io << "0\r\n\r\n" + @io.flush + end end end diff --git a/src/invidious/helpers/static_file_handler.cr b/src/invidious/helpers/static_file_handler.cr index 20d92b9c..be9d36ab 100644 --- a/src/invidious/helpers/static_file_handler.cr +++ b/src/invidious/helpers/static_file_handler.cr @@ -81,12 +81,12 @@ def send_file(env : HTTP::Server::Context, file_path : String, data : Slice(UInt condition = config.is_a?(Hash) && config["gzip"]? == true && filesize > minsize && Kemal::Utils.zip_types(file_path) if condition && request_headers.includes_word?("Accept-Encoding", "gzip") env.response.headers["Content-Encoding"] = "gzip" - Gzip::Writer.open(env.response) do |deflate| + Compress::Gzip::Writer.open(env.response) do |deflate| IO.copy(file, deflate) end elsif condition && request_headers.includes_word?("Accept-Encoding", "deflate") env.response.headers["Content-Encoding"] = "deflate" - Flate::Writer.open(env.response) do |deflate| + Compress::Deflate::Writer.open(env.response) do |deflate| IO.copy(file, deflate) end else diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 1fff206d..a4bd1d54 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -332,7 +332,7 @@ end def sha256(text) digest = OpenSSL::Digest.new("SHA256") digest << text - return digest.hexdigest + return digest.final.hexstring end def subscribe_pubsub(topic, key, config) From 4d4b6a2fa03b9de428a931729600c252ed801c36 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Mon, 15 Jun 2020 17:00:34 -0500 Subject: [PATCH 0013/2881] Remove top page --- src/invidious.cr | 62 +---------------------------- src/invidious/helpers/helpers.cr | 25 ------------ src/invidious/helpers/jobs.cr | 35 ---------------- src/invidious/views/preferences.ecr | 5 --- src/invidious/views/top.ecr | 20 ---------- 5 files changed, 2 insertions(+), 145 deletions(-) delete mode 100644 src/invidious/views/top.ecr diff --git a/src/invidious.cr b/src/invidious.cr index 75d1e0d1..6b408dc6 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -195,15 +195,6 @@ if config.statistics_enabled end end -top_videos = [] of Video -if config.top_enabled - spawn do - pull_top_videos(config, PG_DB) do |videos| - top_videos = videos - end - end -end - popular_videos = [] of ChannelVideo spawn do pull_popular_videos(PG_DB) do |videos| @@ -367,12 +358,6 @@ get "/" do |env| templated "empty" when "Popular" templated "popular" - when "Top" - if config.top_enabled - templated "top" - else - templated "empty" - end when "Trending" env.redirect "/feed/trending" when "Subscriptions" @@ -2123,10 +2108,6 @@ post "/preferences" do |env| end config.default_user_preferences.feed_menu = admin_feed_menu - top_enabled = env.params.body["top_enabled"]?.try &.as(String) - top_enabled ||= "off" - config.top_enabled = top_enabled == "on" - captcha_enabled = env.params.body["captcha_enabled"]?.try &.as(String) captcha_enabled ||= "off" config.captcha_enabled = captcha_enabled == "on" @@ -3044,12 +3025,7 @@ end get "/feed/top" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - if config.top_enabled - templated "top" - else - env.redirect "/" - end + env.redirect "/" end get "/feed/popular" do |env| @@ -4171,41 +4147,7 @@ get "/api/v1/top" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? env.response.content_type = "application/json" - - if !config.top_enabled - error_message = {"error" => "Administrator has disabled this endpoint."}.to_json - env.response.status_code = 400 - next error_message - end - - JSON.build do |json| - json.array do - top_videos.each do |video| - # Top videos have much more information than provided below (adaptiveFormats, etc) - # but can be very out of date, so we only provide a subset here - - json.object do - json.field "title", video.title - json.field "videoId", video.id - json.field "videoThumbnails" do - generate_thumbnails(json, video.id, config, Kemal.config) - end - - json.field "lengthSeconds", video.length_seconds - json.field "viewCount", video.views - - json.field "author", video.author - json.field "authorId", video.ucid - json.field "authorUrl", "/channel/#{video.ucid}" - json.field "published", video.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale)) - - json.field "description", html_to_content(video.description_html) - json.field "descriptionHtml", video.description_html - end - end - end - end + "[]" end get "/api/v1/channels/:ucid" do |env| diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index f16b5c6e..f6ba33cd 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -239,7 +239,6 @@ struct Config hmac_key: String?, # HMAC signing key for CSRF tokens and verifying pubsub subscriptions domain: String?, # Domain to be used for links to resources on the site where an absolute URL is required use_pubsub_feeds: {type: Bool | Int32, default: false}, # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) - top_enabled: {type: Bool, default: true}, captcha_enabled: {type: Bool, default: true}, login_enabled: {type: Bool, default: true}, registration_enabled: {type: Bool, default: true}, @@ -276,30 +275,6 @@ struct DBConfig }) end -def rank_videos(db, n) - top = [] of {Float64, String} - - db.query("SELECT id, wilson_score, published FROM videos WHERE views > 5000 ORDER BY published DESC LIMIT 1000") do |rs| - rs.each do - id = rs.read(String) - wilson_score = rs.read(Float64) - published = rs.read(Time) - - # Exponential decay, older videos tend to rank lower - temperature = wilson_score * Math.exp(-0.000005*((Time.utc - published).total_minutes)) - top << {temperature, id} - end - end - - top.sort! - - # Make hottest come first - top.reverse! - top = top.map { |a, b| b } - - return top[0..n - 1] -end - def login_req(f_req) data = { # Unfortunately there's not much information available on `bgRequest`; part of Google's BotGuard diff --git a/src/invidious/helpers/jobs.cr b/src/invidious/helpers/jobs.cr index 6479fa90..a9aee064 100644 --- a/src/invidious/helpers/jobs.cr +++ b/src/invidious/helpers/jobs.cr @@ -170,41 +170,6 @@ def subscribe_to_feeds(db, logger, key, config) end end -def pull_top_videos(config, db) - loop do - begin - top = rank_videos(db, 40) - rescue ex - sleep 1.minute - Fiber.yield - - next - end - - if top.size == 0 - sleep 1.minute - Fiber.yield - - next - end - - videos = [] of Video - - top.each do |id| - begin - videos << get_video(id, db) - rescue ex - next - end - end - - yield videos - - sleep 1.minute - Fiber.yield - end -end - def pull_popular_videos(db) loop do videos = db.query_all("SELECT DISTINCT ON (ucid) * FROM channel_videos WHERE ucid IN \ diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index 7e899133..fb5bd44b 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -227,11 +227,6 @@ <% end %>

-
- - checked<% end %>> -
-
checked<% end %>> diff --git a/src/invidious/views/top.ecr b/src/invidious/views/top.ecr deleted file mode 100644 index f5db3aaa..00000000 --- a/src/invidious/views/top.ecr +++ /dev/null @@ -1,20 +0,0 @@ -<% content_for "header" do %> -"> - - <% if env.get("preferences").as(Preferences).default_home != "Top" %> - <%= translate(locale, "Top") %> - Invidious - <% else %> - Invidious - <% end %> - -<% end %> - -<%= rendered "components/feed_menu" %> - -
- <% top_videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> -
From c1cbdae5ee52669f7c52c3b477a1649d014d4684 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Mon, 15 Jun 2020 17:10:30 -0500 Subject: [PATCH 0014/2881] Make HOST_URL constant --- src/invidious.cr | 123 ++++++++++++---------------- src/invidious/channels.cr | 58 +++++-------- src/invidious/helpers/helpers.cr | 13 ++- src/invidious/helpers/signatures.cr | 8 +- src/invidious/helpers/utils.cr | 4 +- src/invidious/playlists.cr | 50 +++++------ src/invidious/search.cr | 53 ++++++------ src/invidious/videos.cr | 37 ++++----- src/invidious/views/watch.ecr | 16 ++-- 9 files changed, 163 insertions(+), 199 deletions(-) diff --git a/src/invidious.cr b/src/invidious.cr index 6b408dc6..958f95f7 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -50,6 +50,7 @@ PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") REDDIT_URL = URI.parse("https://www.reddit.com") TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com") YT_URL = URI.parse("https://www.youtube.com") +HOST_URL = make_host_url(CONFIG, Kemal.config) CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"} @@ -202,10 +203,11 @@ spawn do end end -decrypt_function = [] of {SigProc, Int32} +DECRYPT_FUNCTION = [] of {SigProc, Int32} spawn do update_decrypt_function do |function| - decrypt_function = function + DECRYPT_FUNCTION.clear + function.each { |i| DECRYPT_FUNCTION << i } end end @@ -1351,16 +1353,14 @@ get "/opensearch.xml" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? env.response.content_type = "application/opensearchdescription+xml" - host = make_host_url(config, Kemal.config) - XML.build(indent: " ", encoding: "UTF-8") do |xml| xml.element("OpenSearchDescription", xmlns: "http://a9.com/-/spec/opensearch/1.1/") do xml.element("ShortName") { xml.text "Invidious" } xml.element("LongName") { xml.text "Invidious Search" } xml.element("Description") { xml.text "Search for videos, channels, and playlists on Invidious" } xml.element("InputEncoding") { xml.text "UTF-8" } - xml.element("Image", width: 48, height: 48, type: "image/x-icon") { xml.text "#{host}/favicon.ico" } - xml.element("Url", type: "text/html", method: "get", template: "#{host}/search?q={searchTerms}") + xml.element("Image", width: 48, height: 48, type: "image/x-icon") { xml.text "#{HOST_URL}/favicon.ico" } + xml.element("Url", type: "text/html", method: "get", template: "#{HOST_URL}/search?q={searchTerms}") end end end @@ -2473,8 +2473,6 @@ get "/subscription_manager" do |env| subscriptions.sort_by! { |channel| channel.author.downcase } if action_takeout - host_url = make_host_url(config, Kemal.config) - if format == "json" env.response.content_type = "application/json" env.response.headers["content-disposition"] = "attachment" @@ -2500,7 +2498,7 @@ get "/subscription_manager" do |env| if format == "newpipe" xmlUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=#{channel.id}" else - xmlUrl = "#{host_url}/feed/channel/#{channel.id}" + xmlUrl = "#{HOST_URL}/feed/channel/#{channel.id}" end xml.element("outline", text: channel.author, title: channel.author, @@ -3179,25 +3177,23 @@ get "/feed/channel/:ucid" do |env| ) end - host_url = make_host_url(config, Kemal.config) - XML.build(indent: " ", encoding: "UTF-8") do |xml| xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", "xml:lang": "en-US") do - xml.element("link", rel: "self", href: "#{host_url}#{env.request.resource}") + xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") xml.element("id") { xml.text "yt:channel:#{channel.ucid}" } xml.element("yt:channelId") { xml.text channel.ucid } xml.element("title") { xml.text channel.author } - xml.element("link", rel: "alternate", href: "#{host_url}/channel/#{channel.ucid}") + xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}") xml.element("author") do xml.element("name") { xml.text channel.author } - xml.element("uri") { xml.text "#{host_url}/channel/#{channel.ucid}" } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" } end videos.each do |video| - video.to_xml(host_url, channel.auto_generated, params, xml) + video.to_xml(channel.auto_generated, params, xml) end end end @@ -3231,19 +3227,18 @@ get "/feed/private" do |env| params = HTTP::Params.parse(env.params.query["params"]? || "") videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) - host_url = make_host_url(config, Kemal.config) XML.build(indent: " ", encoding: "UTF-8") do |xml| xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", "xml:lang": "en-US") do - xml.element("link", "type": "text/html", rel: "alternate", href: "#{host_url}/feed/subscriptions") + xml.element("link", "type": "text/html", rel: "alternate", href: "#{HOST_URL}/feed/subscriptions") xml.element("link", "type": "application/atom+xml", rel: "self", - href: "#{host_url}#{env.request.resource}") + href: "#{HOST_URL}#{env.request.resource}") xml.element("title") { xml.text translate(locale, "Invidious Private Feed for `x`", user.email) } (notifications + videos).each do |video| - video.to_xml(locale, host_url, params, xml) + video.to_xml(locale, params, xml) end end end @@ -3257,8 +3252,6 @@ get "/feed/playlist/:plid" do |env| plid = env.params.url["plid"] params = HTTP::Params.parse(env.params.query["params"]? || "") - - host_url = make_host_url(config, Kemal.config) path = env.request.path if plid.starts_with? "IV" @@ -3269,18 +3262,18 @@ get "/feed/playlist/:plid" do |env| xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", "xml:lang": "en-US") do - xml.element("link", rel: "self", href: "#{host_url}#{env.request.resource}") + xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") xml.element("id") { xml.text "iv:playlist:#{plid}" } xml.element("iv:playlistId") { xml.text plid } xml.element("title") { xml.text playlist.title } - xml.element("link", rel: "alternate", href: "#{host_url}/playlist?list=#{plid}") + xml.element("link", rel: "alternate", href: "#{HOST_URL}/playlist?list=#{plid}") xml.element("author") do xml.element("name") { xml.text playlist.author } end videos.each do |video| - video.to_xml(host_url, false, xml) + video.to_xml(false, xml) end end end @@ -3299,7 +3292,7 @@ get "/feed/playlist/:plid" do |env| when "url", "href" full_path = URI.parse(node[attribute.name]).full_path query_string_opt = full_path.starts_with?("/watch?v=") ? "&#{params}" : "" - node[attribute.name] = "#{host_url}#{full_path}#{query_string_opt}" + node[attribute.name] = "#{HOST_URL}#{full_path}#{query_string_opt}" else nil # Skip end end @@ -3308,7 +3301,7 @@ get "/feed/playlist/:plid" do |env| document = document.to_xml(options: XML::SaveOptions::NO_DECL) document.scan(/(?[^<]+)<\/uri>/).each do |match| - content = "#{host_url}#{URI.parse(match["url"]).full_path}" + content = "#{HOST_URL}#{URI.parse(match["url"]).full_path}" document = document.gsub(match[0], "#{content}") end @@ -3684,7 +3677,7 @@ get "/channel/:ucid/community" do |env| end begin - items = JSON.parse(fetch_channel_community(ucid, continuation, locale, config, Kemal.config, "json", thin_mode)) + items = JSON.parse(fetch_channel_community(ucid, continuation, locale, "json", thin_mode)) rescue ex env.response.status_code = 500 error_message = ex.message @@ -3737,7 +3730,6 @@ get "/api/v1/storyboards/:id" do |env| end storyboards = video.storyboards - width = env.params.query["width"]? height = env.params.query["height"]? @@ -3745,7 +3737,7 @@ get "/api/v1/storyboards/:id" do |env| response = JSON.build do |json| json.object do json.field "storyboards" do - generate_storyboards(json, id, storyboards, config, Kemal.config) + generate_storyboards(json, id, storyboards) end end end @@ -3775,8 +3767,7 @@ get "/api/v1/storyboards/:id" do |env| end_time = storyboard[:interval].milliseconds storyboard[:storyboard_count].times do |i| - host_url = make_host_url(config, Kemal.config) - url = storyboard[:url].gsub("$M", i).gsub("https://i9.ytimg.com", host_url) + url = storyboard[:url].gsub("$M", i).gsub("https://i9.ytimg.com", HOST_URL) storyboard[:storyboard_height].times do |j| storyboard[:storyboard_width].times do |k| @@ -4099,7 +4090,7 @@ get "/api/v1/videos/:id" do |env| next error_message end - video.to_json(locale, config, Kemal.config, decrypt_function) + video.to_json(locale) end get "/api/v1/trending" do |env| @@ -4121,7 +4112,7 @@ get "/api/v1/trending" do |env| videos = JSON.build do |json| json.array do trending.each do |video| - video.to_json(locale, config, Kemal.config, json) + video.to_json(locale, json) end end end @@ -4137,7 +4128,7 @@ get "/api/v1/popular" do |env| JSON.build do |json| json.array do popular_videos.each do |video| - video.to_json(locale, config, Kemal.config, json) + video.to_json(locale, json) end end end @@ -4178,7 +4169,7 @@ get "/api/v1/channels/:ucid" do |env| count = 0 else begin - videos, count = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) + count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) rescue ex error_message = {"error" => ex.message}.to_json env.response.status_code = 500 @@ -4247,7 +4238,7 @@ get "/api/v1/channels/:ucid" do |env| json.field "latestVideos" do json.array do videos.each do |video| - video.to_json(locale, config, Kemal.config, json) + video.to_json(locale, json) end end end @@ -4308,7 +4299,7 @@ end end begin - videos, count = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) + count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) rescue ex error_message = {"error" => ex.message}.to_json env.response.status_code = 500 @@ -4318,7 +4309,7 @@ end JSON.build do |json| json.array do videos.each do |video| - video.to_json(locale, config, Kemal.config, json) + video.to_json(locale, json) end end end @@ -4344,7 +4335,7 @@ end JSON.build do |json| json.array do videos.each do |video| - video.to_json(locale, config, Kemal.config, json) + video.to_json(locale, json) end end end @@ -4359,9 +4350,9 @@ end ucid = env.params.url["ucid"] continuation = env.params.query["continuation"]? - sort_by = env.params.query["sort"]?.try &.downcase - sort_by ||= env.params.query["sort_by"]?.try &.downcase - sort_by ||= "last" + sort_by = env.params.query["sort"]?.try &.downcase || + env.params.query["sort_by"]?.try &.downcase || + "last" begin channel = get_about_info(ucid, locale) @@ -4383,9 +4374,7 @@ end json.field "playlists" do json.array do items.each do |item| - if item.is_a?(SearchPlaylist) - item.to_json(locale, config, Kemal.config, json) - end + item.to_json(locale, json) if item.is_a?(SearchPlaylist) end end end @@ -4414,7 +4403,7 @@ end # sort_by = env.params.query["sort_by"]?.try &.downcase begin - fetch_channel_community(ucid, continuation, locale, config, Kemal.config, format, thin_mode) + fetch_channel_community(ucid, continuation, locale, format, thin_mode) rescue ex env.response.status_code = 400 error_message = {"error" => ex.message}.to_json @@ -4440,7 +4429,7 @@ get "/api/v1/channels/search/:ucid" do |env| JSON.build do |json| json.array do search_results.each do |item| - item.to_json(locale, config, Kemal.config, json) + item.to_json(locale, json) end end end @@ -4485,7 +4474,7 @@ get "/api/v1/search" do |env| JSON.build do |json| json.array do search_results.each do |item| - item.to_json(locale, config, Kemal.config, json) + item.to_json(locale, json) end end end @@ -4562,7 +4551,7 @@ end next error_message end - response = playlist.to_json(offset, locale, config, Kemal.config, continuation: continuation) + response = playlist.to_json(offset, locale, continuation: continuation) if format == "html" response = JSON.parse(response) @@ -4626,7 +4615,7 @@ get "/api/v1/mixes/:rdid" do |env| json.field "videoThumbnails" do json.array do - generate_thumbnails(json, video.id, config, Kemal.config) + generate_thumbnails(json, video.id) end end @@ -4661,7 +4650,7 @@ get "/api/v1/auth/notifications" do |env| topics = env.params.query["topics"]?.try &.split(",").uniq.first(1000) topics ||= [] of String - create_notification_stream(env, config, Kemal.config, decrypt_function, topics, connection_channel) + create_notification_stream(env, topics, connection_channel) end post "/api/v1/auth/notifications" do |env| @@ -4670,7 +4659,7 @@ post "/api/v1/auth/notifications" do |env| topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000) topics ||= [] of String - create_notification_stream(env, config, Kemal.config, decrypt_function, topics, connection_channel) + create_notification_stream(env, topics, connection_channel) end get "/api/v1/auth/preferences" do |env| @@ -4714,7 +4703,7 @@ get "/api/v1/auth/feed" do |env| json.field "notifications" do json.array do notifications.each do |video| - video.to_json(locale, config, Kemal.config, json) + video.to_json(locale, json) end end end @@ -4722,7 +4711,7 @@ get "/api/v1/auth/feed" do |env| json.field "videos" do json.array do videos.each do |video| - video.to_json(locale, config, Kemal.config, json) + video.to_json(locale, json) end end end @@ -4794,7 +4783,7 @@ get "/api/v1/auth/playlists" do |env| JSON.build do |json| json.array do playlists.each do |playlist| - playlist.to_json(0, locale, config, Kemal.config, json) + playlist.to_json(0, locale, json) end end end @@ -4825,10 +4814,8 @@ post "/api/v1/auth/playlists" do |env| next error_message end - host_url = make_host_url(config, Kemal.config) - playlist = create_playlist(PG_DB, title, privacy, user) - env.response.headers["Location"] = "#{host_url}/api/v1/auth/playlists/#{playlist.id}" + env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{playlist.id}" env.response.status_code = 201 { "title" => title, @@ -4958,11 +4945,9 @@ post "/api/v1/auth/playlists/:plid/videos" do |env| PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array) PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = video_count + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, plid) - host_url = make_host_url(config, Kemal.config) - - env.response.headers["Location"] = "#{host_url}/api/v1/auth/playlists/#{plid}/videos/#{playlist_video.index.to_u64.to_s(16).upcase}" + env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{plid}/videos/#{playlist_video.index.to_u64.to_s(16).upcase}" env.response.status_code = 201 - playlist_video.to_json(locale, config, Kemal.config, index: playlist.index.size) + playlist_video.to_json(locale, index: playlist.index.size) end delete "/api/v1/auth/playlists/:plid/videos/:index" do |env| @@ -5251,12 +5236,10 @@ get "/api/manifest/hls_variant/*" do |env| env.response.content_type = "application/x-mpegURL" env.response.headers.add("Access-Control-Allow-Origin", "*") - host_url = make_host_url(config, Kemal.config) - - manifest = manifest.body + manifest = response.body if local - manifest = manifest.gsub("https://www.youtube.com", host_url) + manifest = manifest.gsub("https://www.youtube.com", HOST_URL) manifest = manifest.gsub("index.m3u8", "index.m3u8?local=true") end @@ -5276,9 +5259,7 @@ get "/api/manifest/hls_playlist/*" do |env| env.response.content_type = "application/x-mpegURL" env.response.headers.add("Access-Control-Allow-Origin", "*") - host_url = make_host_url(config, Kemal.config) - - manifest = manifest.body + manifest = response.body if local manifest = manifest.gsub(/^https:\/\/r\d---.{11}\.c\.youtube\.com[^\n]*/m) do |match| @@ -5313,7 +5294,7 @@ get "/api/manifest/hls_playlist/*" do |env| raw_params["local"] = "true" - "#{host_url}/videoplayback?#{raw_params}" + "#{HOST_URL}/videoplayback?#{raw_params}" end end @@ -5784,7 +5765,7 @@ get "/vi/:id/:name" do |env| headers = HTTP::Headers{":authority" => "i.ytimg.com"} if name == "maxres.jpg" - build_thumbnails(id, config, Kemal.config).each do |thumb| + build_thumbnails(id).each do |thumb| if YT_POOL.client &.head("/vi/#{id}/#{thumb[:url]}.jpg", headers).status_code == 200 name = thumb[:url] + ".jpg" break diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr index afc1528e..f1a57eee 100644 --- a/src/invidious/channels.cr +++ b/src/invidious/channels.cr @@ -9,14 +9,14 @@ struct InvidiousChannel end struct ChannelVideo - def to_json(locale, config, kemal_config, json : JSON::Builder) + def to_json(locale, json : JSON::Builder) json.object do json.field "type", "shortVideo" json.field "title", self.title json.field "videoId", self.id json.field "videoThumbnails" do - generate_thumbnails(json, self.id, config, Kemal.config) + generate_thumbnails(json, self.id) end json.field "lengthSeconds", self.length_seconds @@ -31,17 +31,17 @@ struct ChannelVideo end end - def to_json(locale, config, kemal_config, json : JSON::Builder | Nil = nil) + def to_json(locale, json : JSON::Builder | Nil = nil) if json - to_json(locale, config, kemal_config, json) + to_json(locale, json) else JSON.build do |json| - to_json(locale, config, kemal_config, json) + to_json(locale, json) end end end - def to_xml(locale, host_url, query_params, xml : XML::Builder) + def to_xml(locale, query_params, xml : XML::Builder) query_params["v"] = self.id xml.element("entry") do @@ -49,17 +49,17 @@ struct ChannelVideo xml.element("yt:videoId") { xml.text self.id } xml.element("yt:channelId") { xml.text self.ucid } xml.element("title") { xml.text self.title } - xml.element("link", rel: "alternate", href: "#{host_url}/watch?#{query_params}") + xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}") xml.element("author") do xml.element("name") { xml.text self.author } - xml.element("uri") { xml.text "#{host_url}/channel/#{self.ucid}" } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } end xml.element("content", type: "xhtml") do xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do - xml.element("a", href: "#{host_url}/watch?#{query_params}") do - xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg") + xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do + xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") end end end @@ -69,18 +69,18 @@ struct ChannelVideo xml.element("media:group") do xml.element("media:title") { xml.text self.title } - xml.element("media:thumbnail", url: "#{host_url}/vi/#{self.id}/mqdefault.jpg", + xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg", width: "320", height: "180") end end end - def to_xml(locale, config, kemal_config, xml : XML::Builder | Nil = nil) + def to_xml(locale, xml : XML::Builder | Nil = nil) if xml - to_xml(locale, config, kemal_config, xml) + to_xml(locale, xml) else XML.build do |xml| - to_xml(locale, config, kemal_config, xml) + to_xml(locale, xml) end end end @@ -557,7 +557,7 @@ def extract_channel_playlists_cursor(url, auto_generated) end # TODO: Add "sort_by" -def fetch_channel_community(ucid, continuation, locale, config, kemal_config, format, thin_mode) +def fetch_channel_community(ucid, continuation, locale, format, thin_mode) response = YT_POOL.client &.get("/channel/#{ucid}/community?gl=US&hl=en") if response.status_code != 200 response = YT_POOL.client &.get("/user/#{ucid}/community?gl=US&hl=en") @@ -708,7 +708,7 @@ def fetch_channel_community(ucid, continuation, locale, config, kemal_config, fo json.field "title", attachment["title"]["simpleText"].as_s json.field "videoId", video_id json.field "videoThumbnails" do - generate_thumbnails(json, video_id, config, kemal_config) + generate_thumbnails(json, video_id) end json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s) @@ -956,33 +956,17 @@ def get_about_info(ucid, locale) end def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest") - count = 0 videos = [] of SearchVideo 2.times do |i| url = produce_channel_videos_url(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by) - response = YT_POOL.client &.get(url) - json = JSON.parse(response.body) - - if json["content_html"]? && !json["content_html"].as_s.empty? - document = XML.parse_html(json["content_html"].as_s) - nodeset = document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")])) - - if !json["load_more_widget_html"]?.try &.as_s.empty? - count += 30 - end - - if auto_generated - videos += extract_videos(nodeset) - else - videos += extract_videos(nodeset, ucid, author) - end - else - break - end + response = YT_POOL.client &.get(url, headers) + initial_data = JSON.parse(response.body).as_a.find &.["response"]? + break if !initial_data + videos.concat extract_videos(initial_data.as_h) end - return videos, count + return videos.size, videos end def get_latest_videos(ucid) diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index f6ba33cd..b572ee1c 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -727,13 +727,10 @@ def cache_annotation(db, id, annotations) end end - if has_legacy_annotations - # TODO: Update on conflict? - db.exec("INSERT INTO annotations VALUES ($1, $2) ON CONFLICT DO NOTHING", id, annotations) - end + db.exec("INSERT INTO annotations VALUES ($1, $2) ON CONFLICT DO NOTHING", id, annotations) if has_legacy_annotations end -def create_notification_stream(env, config, kemal_config, decrypt_function, topics, connection_channel) +def create_notification_stream(env, topics, connection_channel) connection = Channel(PQ::Notification).new(8) connection_channel.send({true, connection}) @@ -753,7 +750,7 @@ def create_notification_stream(env, config, kemal_config, decrypt_function, topi video = get_video(video_id, PG_DB) video.published = published - response = JSON.parse(video.to_json(locale, config, kemal_config, decrypt_function)) + response = JSON.parse(video.to_json(locale)) if fields_text = env.params.query["fields"]? begin @@ -787,7 +784,7 @@ def create_notification_stream(env, config, kemal_config, decrypt_function, topi when .match(/UC[A-Za-z0-9_-]{22}/) PG_DB.query_all("SELECT * FROM channel_videos WHERE ucid = $1 AND published > $2 ORDER BY published DESC LIMIT 15", topic, Time.unix(since.not_nil!), as: ChannelVideo).each do |video| - response = JSON.parse(video.to_json(locale, config, Kemal.config)) + response = JSON.parse(video.to_json(locale)) if fields_text = env.params.query["fields"]? begin @@ -829,7 +826,7 @@ def create_notification_stream(env, config, kemal_config, decrypt_function, topi video = get_video(video_id, PG_DB) video.published = Time.unix(published) - response = JSON.parse(video.to_json(locale, config, Kemal.config, decrypt_function)) + response = JSON.parse(video.to_json(locale)) if fields_text = env.params.query["fields"]? begin diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index f82cc8dd..0aaacd04 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -40,12 +40,12 @@ def fetch_decrypt_function(id = "CvFH_6DNRCY") return decrypt_function end -def decrypt_signature(fmt, op) +def decrypt_signature(fmt : Hash(String, JSON::Any)) return "" if !fmt["s"]? || !fmt["sp"]? - sp = fmt["sp"] - sig = fmt["s"].split("") - op.each do |proc, value| + sp = fmt["sp"].as_s + sig = fmt["s"].as_s.split("") + DECRYPT_FUNCTION.each do |proc, value| sig = proc.call(sig, value) end diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index a4bd1d54..a39a0b16 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -351,10 +351,8 @@ def subscribe_pubsub(topic, key, config) nonce = Random::Secure.hex(4) signature = "#{time}:#{nonce}" - host_url = make_host_url(config, Kemal.config) - body = { - "hub.callback" => "#{host_url}/feed/webhook/v1:#{time}:#{nonce}:#{OpenSSL::HMAC.hexdigest(:sha1, key, signature)}", + "hub.callback" => "#{HOST_URL}/feed/webhook/v1:#{time}:#{nonce}:#{OpenSSL::HMAC.hexdigest(:sha1, key, signature)}", "hub.topic" => "https://www.youtube.com/xml/feeds/videos.xml?#{topic}", "hub.verify" => "async", "hub.mode" => "subscribe", diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 184329dc..fcf73dad 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -1,26 +1,26 @@ struct PlaylistVideo - def to_xml(host_url, auto_generated, xml : XML::Builder) + def to_xml(auto_generated, xml : XML::Builder) xml.element("entry") do xml.element("id") { xml.text "yt:video:#{self.id}" } xml.element("yt:videoId") { xml.text self.id } xml.element("yt:channelId") { xml.text self.ucid } xml.element("title") { xml.text self.title } - xml.element("link", rel: "alternate", href: "#{host_url}/watch?v=#{self.id}") + xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?v=#{self.id}") xml.element("author") do if auto_generated xml.element("name") { xml.text self.author } - xml.element("uri") { xml.text "#{host_url}/channel/#{self.ucid}" } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } else xml.element("name") { xml.text author } - xml.element("uri") { xml.text "#{host_url}/channel/#{ucid}" } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" } end end xml.element("content", type: "xhtml") do xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do - xml.element("a", href: "#{host_url}/watch?v=#{self.id}") do - xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg") + xml.element("a", href: "#{HOST_URL}/watch?v=#{self.id}") do + xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") end end end @@ -29,23 +29,23 @@ struct PlaylistVideo xml.element("media:group") do xml.element("media:title") { xml.text self.title } - xml.element("media:thumbnail", url: "#{host_url}/vi/#{self.id}/mqdefault.jpg", + xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg", width: "320", height: "180") end end end - def to_xml(host_url, auto_generated, xml : XML::Builder? = nil) + def to_xml(auto_generated, xml : XML::Builder? = nil) if xml - to_xml(host_url, auto_generated, xml) + to_xml(auto_generated, xml) else XML.build do |json| - to_xml(host_url, auto_generated, xml) + to_xml(auto_generated, xml) end end end - def to_json(locale, config, kemal_config, json : JSON::Builder, index : Int32?) + def to_json(locale, json : JSON::Builder, index : Int32?) json.object do json.field "title", self.title json.field "videoId", self.id @@ -55,7 +55,7 @@ struct PlaylistVideo json.field "authorUrl", "/channel/#{self.ucid}" json.field "videoThumbnails" do - generate_thumbnails(json, self.id, config, kemal_config) + generate_thumbnails(json, self.id) end if index @@ -69,12 +69,12 @@ struct PlaylistVideo end end - def to_json(locale, config, kemal_config, json : JSON::Builder? = nil, index : Int32? = nil) + def to_json(locale, json : JSON::Builder? = nil, index : Int32? = nil) if json - to_json(locale, config, kemal_config, json, index: index) + to_json(locale, json, index: index) else JSON.build do |json| - to_json(locale, config, kemal_config, json, index: index) + to_json(locale, json, index: index) end end end @@ -93,7 +93,7 @@ struct PlaylistVideo end struct Playlist - def to_json(offset, locale, config, kemal_config, json : JSON::Builder, continuation : String? = nil) + def to_json(offset, locale, json : JSON::Builder, continuation : String? = nil) json.object do json.field "type", "playlist" json.field "title", self.title @@ -130,19 +130,19 @@ struct Playlist json.array do videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation) videos.each_with_index do |video, index| - video.to_json(locale, config, Kemal.config, json) + video.to_json(locale, json) end end end end end - def to_json(offset, locale, config, kemal_config, json : JSON::Builder? = nil, continuation : String? = nil) + def to_json(offset, locale, json : JSON::Builder? = nil, continuation : String? = nil) if json - to_json(offset, locale, config, kemal_config, json, continuation: continuation) + to_json(offset, locale, json, continuation: continuation) else JSON.build do |json| - to_json(offset, locale, config, kemal_config, json, continuation: continuation) + to_json(offset, locale, json, continuation: continuation) end end end @@ -172,7 +172,7 @@ enum PlaylistPrivacy end struct InvidiousPlaylist - def to_json(offset, locale, config, kemal_config, json : JSON::Builder, continuation : String? = nil) + def to_json(offset, locale, json : JSON::Builder, continuation : String? = nil) json.object do json.field "type", "invidiousPlaylist" json.field "title", self.title @@ -195,19 +195,19 @@ struct InvidiousPlaylist json.array do videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation) videos.each_with_index do |video, index| - video.to_json(locale, config, Kemal.config, json, offset + index) + video.to_json(locale, json, offset + index) end end end end end - def to_json(offset, locale, config, kemal_config, json : JSON::Builder? = nil, continuation : String? = nil) + def to_json(offset, locale, json : JSON::Builder? = nil, continuation : String? = nil) if json - to_json(offset, locale, config, kemal_config, json, continuation: continuation) + to_json(offset, locale, json, continuation: continuation) else JSON.build do |json| - to_json(offset, locale, config, kemal_config, json, continuation: continuation) + to_json(offset, locale, json, continuation: continuation) end end end diff --git a/src/invidious/search.cr b/src/invidious/search.cr index e8521629..7a88f316 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -1,5 +1,5 @@ struct SearchVideo - def to_xml(host_url, auto_generated, query_params, xml : XML::Builder) + def to_xml(auto_generated, query_params, xml : XML::Builder) query_params["v"] = self.id xml.element("entry") do @@ -7,22 +7,22 @@ struct SearchVideo xml.element("yt:videoId") { xml.text self.id } xml.element("yt:channelId") { xml.text self.ucid } xml.element("title") { xml.text self.title } - xml.element("link", rel: "alternate", href: "#{host_url}/watch?#{query_params}") + xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}") xml.element("author") do if auto_generated xml.element("name") { xml.text self.author } - xml.element("uri") { xml.text "#{host_url}/channel/#{self.ucid}" } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } else xml.element("name") { xml.text author } - xml.element("uri") { xml.text "#{host_url}/channel/#{ucid}" } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" } end end xml.element("content", type: "xhtml") do xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do - xml.element("a", href: "#{host_url}/watch?#{query_params}") do - xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg") + xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do + xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") end xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text html_to_content(self.description_html) } @@ -33,7 +33,7 @@ struct SearchVideo xml.element("media:group") do xml.element("media:title") { xml.text self.title } - xml.element("media:thumbnail", url: "#{host_url}/vi/#{self.id}/mqdefault.jpg", + xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg", width: "320", height: "180") xml.element("media:description") { xml.text html_to_content(self.description_html) } end @@ -44,17 +44,17 @@ struct SearchVideo end end - def to_xml(host_url, auto_generated, query_params, xml : XML::Builder | Nil = nil) + def to_xml(auto_generated, query_params, xml : XML::Builder | Nil = nil) if xml - to_xml(host_url, auto_generated, query_params, xml) + to_xml(HOST_URL, auto_generated, query_params, xml) else XML.build do |json| - to_xml(host_url, auto_generated, query_params, xml) + to_xml(HOST_URL, auto_generated, query_params, xml) end end end - def to_json(locale, config, kemal_config, json : JSON::Builder) + def to_json(locale, json : JSON::Builder) json.object do json.field "type", "video" json.field "title", self.title @@ -65,7 +65,7 @@ struct SearchVideo json.field "authorUrl", "/channel/#{self.ucid}" json.field "videoThumbnails" do - generate_thumbnails(json, self.id, config, kemal_config) + generate_thumbnails(json, self.id) end json.field "description", html_to_content(self.description_html) @@ -78,15 +78,20 @@ struct SearchVideo json.field "liveNow", self.live_now json.field "paid", self.paid json.field "premium", self.premium + json.field "isUpcoming", self.is_upcoming + + if self.premiere_timestamp + json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix + end end end - def to_json(locale, config, kemal_config, json : JSON::Builder | Nil = nil) + def to_json(locale, json : JSON::Builder | Nil = nil) if json - to_json(locale, config, kemal_config, json) + to_json(locale, json) else JSON.build do |json| - to_json(locale, config, kemal_config, json) + to_json(locale, json) end end end @@ -116,7 +121,7 @@ struct SearchPlaylistVideo end struct SearchPlaylist - def to_json(locale, config, kemal_config, json : JSON::Builder) + def to_json(locale, json : JSON::Builder) json.object do json.field "type", "playlist" json.field "title", self.title @@ -137,7 +142,7 @@ struct SearchPlaylist json.field "lengthSeconds", video.length_seconds json.field "videoThumbnails" do - generate_thumbnails(json, video.id, config, Kemal.config) + generate_thumbnails(json, video.id) end end end @@ -146,12 +151,12 @@ struct SearchPlaylist end end - def to_json(locale, config, kemal_config, json : JSON::Builder | Nil = nil) + def to_json(locale, json : JSON::Builder | Nil = nil) if json - to_json(locale, config, kemal_config, json) + to_json(locale, json) else JSON.build do |json| - to_json(locale, config, kemal_config, json) + to_json(locale, json) end end end @@ -168,7 +173,7 @@ struct SearchPlaylist end struct SearchChannel - def to_json(locale, config, kemal_config, json : JSON::Builder) + def to_json(locale, json : JSON::Builder) json.object do json.field "type", "channel" json.field "author", self.author @@ -198,12 +203,12 @@ struct SearchChannel end end - def to_json(locale, config, kemal_config, json : JSON::Builder | Nil = nil) + def to_json(locale, json : JSON::Builder | Nil = nil) if json - to_json(locale, config, kemal_config, json) + to_json(locale, json) else JSON.build do |json| - to_json(locale, config, kemal_config, json) + to_json(locale, json) end end end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 7e815ca1..ed5847e4 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -255,17 +255,20 @@ struct Video end end - def to_json(locale, config, kemal_config, decrypt_function, json : JSON::Builder) + def to_json(locale, json : JSON::Builder) json.object do json.field "type", "video" json.field "title", self.title json.field "videoId", self.id + + json.field "error", info["reason"] if info["reason"]? + json.field "videoThumbnails" do - generate_thumbnails(json, self.id, config, kemal_config) + generate_thumbnails(json, self.id) end json.field "storyboards" do - generate_storyboards(json, self.id, self.storyboards, config, kemal_config) + generate_storyboards(json, self.id, self.storyboards) end json.field "description", html_to_content(self.description_html) @@ -316,16 +319,12 @@ struct Video json.field "premiereTimestamp", self.premiere_timestamp.not_nil!.to_unix end - if player_response["streamingData"]?.try &.["hlsManifestUrl"]? - host_url = make_host_url(config, kemal_config) - - hlsvp = player_response["streamingData"]["hlsManifestUrl"].as_s - hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url) - + if hlsvp = self.hls_manifest_url + hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", HOST_URL) json.field "hlsUrl", hlsvp end - json.field "dashUrl", "#{make_host_url(config, kemal_config)}/api/manifest/dash/id/#{id}" + json.field "dashUrl", "#{HOST_URL}/api/manifest/dash/id/#{id}" json.field "adaptiveFormats" do json.array do @@ -424,7 +423,7 @@ struct Video json.field "videoId", rv["id"] json.field "title", rv["title"] json.field "videoThumbnails" do - generate_thumbnails(json, rv["id"], config, kemal_config) + generate_thumbnails(json, rv["id"]) end json.field "author", rv["author"] @@ -457,12 +456,12 @@ struct Video end end - def to_json(locale, config, kemal_config, decrypt_function, json : JSON::Builder | Nil = nil) + def to_json(locale, json : JSON::Builder | Nil = nil) if json - to_json(locale, config, kemal_config, decrypt_function, json) + to_json(locale, json) else JSON.build do |json| - to_json(locale, config, kemal_config, decrypt_function, json) + to_json(locale, json) end end end @@ -1391,9 +1390,9 @@ def process_video_params(query, preferences) return params end -def build_thumbnails(id, config, kemal_config) +def build_thumbnails(id) return { - {name: "maxres", host: "#{make_host_url(config, kemal_config)}", url: "maxres", height: 720, width: 1280}, + {name: "maxres", host: "#{HOST_URL}", url: "maxres", height: 720, width: 1280}, {name: "maxresdefault", host: "https://i.ytimg.com", url: "maxresdefault", height: 720, width: 1280}, {name: "sddefault", host: "https://i.ytimg.com", url: "sddefault", height: 480, width: 640}, {name: "high", host: "https://i.ytimg.com", url: "hqdefault", height: 360, width: 480}, @@ -1405,9 +1404,9 @@ def build_thumbnails(id, config, kemal_config) } end -def generate_thumbnails(json, id, config, kemal_config) +def generate_thumbnails(json, id) json.array do - build_thumbnails(id, config, kemal_config).each do |thumbnail| + build_thumbnails(id).each do |thumbnail| json.object do json.field "quality", thumbnail[:name] json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg" @@ -1418,7 +1417,7 @@ def generate_thumbnails(json, id, config, kemal_config) end end -def generate_storyboards(json, id, storyboards, config, kemal_config) +def generate_storyboards(json, id, storyboards) json.array do storyboards.each do |storyboard| json.object do diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 708456f9..ae6341e0 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -3,23 +3,23 @@ "> - + - + - - + + - + - - - + + + <%= rendered "components/player_sources" %> From 1eca969cf6b4096789014619285c98d1def40ee3 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Mon, 15 Jun 2020 17:33:23 -0500 Subject: [PATCH 0015/2881] Add support for polymer redesign --- config/sql/videos.sql | 17 - spec/helpers_spec.cr | 4 +- src/invidious.cr | 173 ++-- src/invidious/channels.cr | 32 +- src/invidious/comments.cr | 24 +- src/invidious/helpers/helpers.cr | 168 +++- src/invidious/helpers/jobs.cr | 2 +- src/invidious/helpers/signatures.cr | 4 +- src/invidious/helpers/utils.cr | 2 +- src/invidious/mixes.cr | 1 - src/invidious/search.cr | 69 +- src/invidious/trending.cr | 24 +- src/invidious/users.cr | 4 +- src/invidious/videos.cr | 926 ++++++++-------------- src/invidious/views/components/item.ecr | 4 +- src/invidious/views/components/player.ecr | 12 +- src/invidious/views/watch.ecr | 54 +- 17 files changed, 634 insertions(+), 886 deletions(-) diff --git a/config/sql/videos.sql b/config/sql/videos.sql index 6ded01de..8def2f83 100644 --- a/config/sql/videos.sql +++ b/config/sql/videos.sql @@ -7,23 +7,6 @@ CREATE TABLE public.videos id text NOT NULL, info text, updated timestamp with time zone, - title text, - views bigint, - likes integer, - dislikes integer, - wilson_score double precision, - published timestamp with time zone, - description text, - language text, - author text, - ucid text, - allowed_regions text[], - is_family_friendly boolean, - genre text, - genre_url text, - license text, - sub_count_text text, - author_thumbnail text, CONSTRAINT videos_pkey PRIMARY KEY (id) ); diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index 37e36c61..26922bb2 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -27,9 +27,9 @@ describe "Helper" do describe "#produce_channel_search_url" do it "correctly produces token for searching a specific channel" do - produce_channel_search_url("UCXuqSBlHAE6Xw-yeJA0Tunw", "", 100).should eq("/browse_ajax?continuation=4qmFsgI-EhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWnpaV0Z5WTJnd0FqZ0JZQUZxQUxnQkFIb0RNVEF3WgA%3D&gl=US&hl=en") + produce_channel_search_url("UCXuqSBlHAE6Xw-yeJA0Tunw", "", 100).should eq("/browse_ajax?continuation=4qmFsgI2EhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaGEVnWnpaV0Z5WTJnNEFYb0RNVEF3dUFFQVoA&gl=US&hl=en") - produce_channel_search_url("UCXuqSBlHAE6Xw-yeJA0Tunw", "По ожиशुपतिरपि子而時ஸ்றீனி", 0).should eq("/browse_ajax?continuation=4qmFsgJ8EhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWnpaV0Z5WTJnd0FqZ0JZQUZxQUxnQkFIb0JNQT09Wj7Qn9C-INC-0LbQuOCktuClgeCkquCkpOCkv-CksOCkquCkv-WtkOiAjOaZguCuuOCvjeCuseCvgOCuqeCuvw%3D%3D&gl=US&hl=en") + produce_channel_search_url("UCXuqSBlHAE6Xw-yeJA0Tunw", "По ожиशुपतिरपि子而時ஸ்றீனி", 0).should eq("/browse_ajax?continuation=4qmFsgJ0EhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaGEVnWnpaV0Z5WTJnNEFYb0JNTGdCQUE9PVo-0J_QviDQvtC20LjgpLbgpYHgpKrgpKTgpL_gpLDgpKrgpL_lrZDogIzmmYLgrrjgr43grrHgr4Dgrqngrr8%3D&gl=US&hl=en") end end diff --git a/src/invidious.cr b/src/invidious.cr index 958f95f7..c95c6419 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -510,16 +510,16 @@ get "/watch" do |env| comment_html ||= "" end - fmt_stream = video.fmt_stream(decrypt_function) - adaptive_fmts = video.adaptive_fmts(decrypt_function) + fmt_stream = video.fmt_stream + adaptive_fmts = video.adaptive_fmts if params.local - fmt_stream.each { |fmt| fmt["url"] = URI.parse(fmt["url"]).full_path } - adaptive_fmts.each { |fmt| fmt["url"] = URI.parse(fmt["url"]).full_path } + fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).full_path) } + adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).full_path) } end - video_streams = video.video_streams(adaptive_fmts) - audio_streams = video.audio_streams(adaptive_fmts) + video_streams = video.video_streams + audio_streams = video.audio_streams # Older videos may not have audio sources available. # We redirect here so they're not unplayable @@ -549,33 +549,23 @@ get "/watch" do |env| aspect_ratio = "16:9" - video.description_html = fill_links(video.description_html, "https", "www.youtube.com") - video.description_html = replace_links(video.description_html) - - host_url = make_host_url(config, Kemal.config) - - if video.player_response["streamingData"]?.try &.["hlsManifestUrl"]? - hlsvp = video.player_response["streamingData"]["hlsManifestUrl"].as_s - hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url) - end - thumbnail = "/vi/#{video.id}/maxres.jpg" if params.raw if params.listen - url = audio_streams[0]["url"] + url = audio_streams[0]["url"].as_s audio_streams.each do |fmt| - if fmt["bitrate"] == params.quality.rchop("k") - url = fmt["url"] + if fmt["bitrate"].as_i == params.quality.rchop("k").to_i + url = fmt["url"].as_s end end else - url = fmt_stream[0]["url"] + url = fmt_stream[0]["url"].as_s fmt_stream.each do |fmt| - if fmt["label"].split(" - ")[0] == params.quality - url = fmt["url"] + if fmt["quality"].as_s == params.quality + url = fmt["url"].as_s end end end @@ -583,24 +573,6 @@ get "/watch" do |env| next env.redirect url end - rvs = [] of Hash(String, String) - video.info["rvs"]?.try &.split(",").each do |rv| - rvs << HTTP::Params.parse(rv).to_h - end - - rating = video.info["avg_rating"].to_f64 - if video.views > 0 - engagement = ((video.dislikes.to_f + video.likes.to_f)/video.views * 100) - else - engagement = 0 - end - - playability_status = video.player_response["playabilityStatus"]? - if playability_status && playability_status["status"] == "LIVE_STREAM_OFFLINE" && !video.premiere_timestamp - reason = playability_status["reason"]?.try &.as_s - end - reason ||= "" - templated "watch" end @@ -752,16 +724,16 @@ get "/embed/:id" do |env| notifications.delete(id) end - fmt_stream = video.fmt_stream(decrypt_function) - adaptive_fmts = video.adaptive_fmts(decrypt_function) + fmt_stream = video.fmt_stream + adaptive_fmts = video.adaptive_fmts if params.local - fmt_stream.each { |fmt| fmt["url"] = URI.parse(fmt["url"]).full_path } - adaptive_fmts.each { |fmt| fmt["url"] = URI.parse(fmt["url"]).full_path } + fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).full_path) } + adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).full_path) } end - video_streams = video.video_streams(adaptive_fmts) - audio_streams = video.audio_streams(adaptive_fmts) + video_streams = video.video_streams + audio_streams = video.audio_streams if audio_streams.empty? && !video.live_now if params.quality == "dash" @@ -788,25 +760,13 @@ get "/embed/:id" do |env| aspect_ratio = nil - video.description_html = fill_links(video.description_html, "https", "www.youtube.com") - video.description_html = replace_links(video.description_html) - - host_url = make_host_url(config, Kemal.config) - - if video.player_response["streamingData"]?.try &.["hlsManifestUrl"]? - hlsvp = video.player_response["streamingData"]["hlsManifestUrl"].as_s - hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url) - end - thumbnail = "/vi/#{video.id}/maxres.jpg" if params.raw - url = fmt_stream[0]["url"] + url = fmt_stream[0]["url"].as_s fmt_stream.each do |fmt| - if fmt["label"].split(" - ")[0] == params.quality - url = fmt["url"] - end + url = fmt["url"].as_s if fmt["quality"].as_s == params.quality end next env.redirect url @@ -1469,7 +1429,6 @@ post "/login" do |env| traceback = IO::Memory.new # See https://github.com/ytdl-org/youtube-dl/blob/2019.04.07/youtube_dl/extractor/youtube.py#L82 - # TODO: Convert to QUIC begin client = QUIC::Client.new(LOGIN_URL) headers = HTTP::Headers.new @@ -2329,8 +2288,7 @@ get "/modify_notifications" do |env| end headers = cookies.add_request_headers(headers) - match = html.body.match(/'XSRF_TOKEN': "(?[A-Za-z0-9\_\-\=]+)"/) - if match + if match = html.body.match(/'XSRF_TOKEN': "(?[^"]+)"/) session_token = match["session_token"] else next env.redirect referer @@ -3575,14 +3533,14 @@ get "/channel/:ucid" do |env| item.author end end - items = items.select { |item| item.is_a?(SearchPlaylist) }.map { |item| item.as(SearchPlaylist) } + items = items.select(&.is_a?(SearchPlaylist)).map(&.as(SearchPlaylist)) items.each { |item| item.author = "" } else sort_options = {"newest", "oldest", "popular"} sort_by ||= "newest" - items, count = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - items.select! { |item| !item.paid } + count, items = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) + items.reject! &.paid env.set "search", "channel:#{channel.ucid} " end @@ -5125,7 +5083,7 @@ get "/api/manifest/dash/id/:id" do |env| next end - if dashmpd = video.player_response["streamingData"]?.try &.["dashManifestUrl"]?.try &.as_s + if dashmpd = video.dash_manifest_url manifest = YT_POOL.client &.get(URI.parse(dashmpd).full_path).body manifest = manifest.gsub(/[^<]+<\/BaseURL>/) do |baseurl| @@ -5142,16 +5100,16 @@ get "/api/manifest/dash/id/:id" do |env| next manifest end - adaptive_fmts = video.adaptive_fmts(decrypt_function) + adaptive_fmts = video.adaptive_fmts if local adaptive_fmts.each do |fmt| - fmt["url"] = URI.parse(fmt["url"]).full_path + fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).full_path) end end - audio_streams = video.audio_streams(adaptive_fmts) - video_streams = video.video_streams(adaptive_fmts).sort_by { |stream| {stream["size"].split("x")[0].to_i, stream["fps"].to_i} }.reverse + audio_streams = video.audio_streams + video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse XML.build(indent: " ", encoding: "UTF-8") do |xml| xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011", @@ -5161,24 +5119,22 @@ get "/api/manifest/dash/id/:id" do |env| i = 0 {"audio/mp4", "audio/webm"}.each do |mime_type| - mime_streams = audio_streams.select { |stream| stream["type"].starts_with? mime_type } - if mime_streams.empty? - next - end + mime_streams = audio_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } + next if mime_streams.empty? xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do mime_streams.each do |fmt| - codecs = fmt["type"].split("codecs=")[1].strip('"') - bandwidth = fmt["bitrate"].to_i * 1000 - itag = fmt["itag"] - url = fmt["url"] + codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') + bandwidth = fmt["bitrate"].as_i + itag = fmt["itag"].as_i + url = fmt["url"].as_s xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011", value: "2") xml.element("BaseURL") { xml.text url } - xml.element("SegmentBase", indexRange: fmt["index"]) do - xml.element("Initialization", range: fmt["init"]) + xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do + xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}") end end end @@ -5187,21 +5143,24 @@ get "/api/manifest/dash/id/:id" do |env| i += 1 end + potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144} + {"video/mp4", "video/webm"}.each do |mime_type| - mime_streams = video_streams.select { |stream| stream["type"].starts_with? mime_type } + mime_streams = video_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } next if mime_streams.empty? heights = [] of Int32 xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, scanType: "progressive") do mime_streams.each do |fmt| - codecs = fmt["type"].split("codecs=")[1].strip('"') - bandwidth = fmt["bitrate"] - itag = fmt["itag"] - url = fmt["url"] - width, height = fmt["size"].split("x").map { |i| i.to_i } + codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') + bandwidth = fmt["bitrate"].as_i + itag = fmt["itag"].as_i + url = fmt["url"].as_s + width = fmt["width"].as_i + height = fmt["height"].as_i # Resolutions reported by YouTube player (may not accurately reflect source) - height = [4320, 2160, 1440, 1080, 720, 480, 360, 240, 144].sort_by { |i| (height - i).abs }[0] + height = potential_heights.min_by { |i| (height - i).abs } next if unique_res && heights.includes? height heights << height @@ -5209,8 +5168,8 @@ get "/api/manifest/dash/id/:id" do |env| startWithSAP: "1", maxPlayoutRate: "1", bandwidth: bandwidth, frameRate: fmt["fps"]) do xml.element("BaseURL") { xml.text url } - xml.element("SegmentBase", indexRange: fmt["index"]) do - xml.element("Initialization", range: fmt["init"]) + xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do + xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}") end end end @@ -5224,10 +5183,10 @@ get "/api/manifest/dash/id/:id" do |env| end get "/api/manifest/hls_variant/*" do |env| - manifest = YT_POOL.client &.get(env.request.path) + response = YT_POOL.client &.get(env.request.path) - if manifest.status_code != 200 - env.response.status_code = manifest.status_code + if response.status_code != 200 + env.response.status_code = response.status_code next end @@ -5247,10 +5206,10 @@ get "/api/manifest/hls_variant/*" do |env| end get "/api/manifest/hls_playlist/*" do |env| - manifest = YT_POOL.client &.get(env.request.path) + response = YT_POOL.client &.get(env.request.path) - if manifest.status_code != 200 - env.response.status_code = manifest.status_code + if response.status_code != 200 + env.response.status_code = response.status_code next end @@ -5320,7 +5279,7 @@ get "/latest_version" do |env| end id ||= env.params.query["id"]? - itag ||= env.params.query["itag"]? + itag ||= env.params.query["itag"]?.try &.to_i region = env.params.query["region"]? @@ -5335,26 +5294,16 @@ get "/latest_version" do |env| video = get_video(id, PG_DB, region: region) - fmt_stream = video.fmt_stream(decrypt_function) - adaptive_fmts = video.adaptive_fmts(decrypt_function) + fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag } + url = fmt.try &.["url"]?.try &.as_s - urls = (fmt_stream + adaptive_fmts).select { |fmt| fmt["itag"] == itag } - if urls.empty? + if !url env.response.status_code = 404 next - elsif urls.size > 1 - env.response.status_code = 409 - next end - url = urls[0]["url"] - if local - url = URI.parse(url).full_path.not_nil! - end - - if title - url += "&title=#{title}" - end + url = URI.parse(url).full_path.not_nil! if local + url = "#{url}&title=#{title}" if title env.redirect url end diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr index f1a57eee..cbfa521d 100644 --- a/src/invidious/channels.cr +++ b/src/invidious/channels.cr @@ -232,9 +232,9 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) nodeset = document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")])) if auto_generated - videos = extract_videos(nodeset) + videos = extract_videos_html(nodeset) else - videos = extract_videos(nodeset, ucid, author) + videos = extract_videos_html(nodeset, ucid, author) end end @@ -317,9 +317,9 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) nodeset = nodeset.not_nil! if auto_generated - videos = extract_videos(nodeset) + videos = extract_videos_html(nodeset) else - videos = extract_videos(nodeset, ucid, author) + videos = extract_videos_html(nodeset, ucid, author) end count = nodeset.size @@ -429,7 +429,7 @@ def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by) if auto_generated items = extract_shelf_items(nodeset, ucid, author) else - items = extract_items(nodeset, ucid, author) + items = extract_items_html(nodeset, ucid, author) end return items, continuation @@ -584,16 +584,8 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) headers = HTTP::Headers.new headers["cookie"] = response.cookies.add_request_headers(headers)["cookie"] - headers["content-type"] = "application/x-www-form-urlencoded" - headers["x-client-data"] = "CIi2yQEIpbbJAQipncoBCNedygEIqKPKAQ==" - headers["x-spf-previous"] = "" - headers["x-spf-referer"] = "" - - headers["x-youtube-client-name"] = "1" - headers["x-youtube-client-version"] = "2.20180719" - - session_token = response.body.match(/"XSRF_TOKEN":"(?[A-Za-z0-9\_\-\=]+)"/).try &.["session_token"]? || "" + session_token = response.body.match(/"XSRF_TOKEN":"(?[^"]+)"/).try &.["session_token"]? || "" post_req = { session_token: session_token, } @@ -633,13 +625,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) next if !post - if !post["contentText"]? - content_html = "" - else - content_html = post["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s || - post["contentText"]["runs"]?.try &.as_a.try { |r| content_to_comment_html(r).try &.to_s } || "" - end - + content_html = post["contentText"]?.try { |t| parse_content(t) } || "" author = post["authorText"]?.try &.["simpleText"]? || "" json.object do @@ -960,7 +946,7 @@ def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest") 2.times do |i| url = produce_channel_videos_url(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by) - response = YT_POOL.client &.get(url, headers) + response = YT_POOL.client &.get(url) initial_data = JSON.parse(response.body).as_a.find &.["response"]? break if !initial_data videos.concat extract_videos(initial_data.as_h) @@ -980,7 +966,7 @@ def get_latest_videos(ucid) document = XML.parse_html(json["content_html"].as_s) nodeset = document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")])) - videos = extract_videos(nodeset, ucid) + videos = extract_videos_html(nodeset, ucid) end return videos diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 24564bb9..5490d2ea 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -59,7 +59,7 @@ end def fetch_youtube_comments(id, db, cursor, format, locale, thin_mode, region, sort_by = "top") video = get_video(id, db, region: region) - session_token = video.info["session_token"]? + session_token = video.session_token case cursor when nil, "" @@ -85,17 +85,9 @@ def fetch_youtube_comments(id, db, cursor, format, locale, thin_mode, region, so session_token: session_token, } - headers = HTTP::Headers.new - - headers["content-type"] = "application/x-www-form-urlencoded" - headers["cookie"] = video.info["cookie"] - - headers["x-client-data"] = "CIi2yQEIpbbJAQipncoBCNedygEIqKPKAQ==" - headers["x-spf-previous"] = "https://www.youtube.com/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999" - headers["x-spf-referer"] = "https://www.youtube.com/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999" - - headers["x-youtube-client-name"] = "1" - headers["x-youtube-client-version"] = "2.20180719" + headers = HTTP::Headers{ + "cookie" => video.cookie, + } response = YT_POOL.client(region, &.post("/comment_service_ajax?action_get_comments=1&hl=en&gl=US", headers, form: post_req)) response = JSON.parse(response.body) @@ -150,8 +142,7 @@ def fetch_youtube_comments(id, db, cursor, format, locale, thin_mode, region, so node_comment = node["commentRenderer"] end - content_html = node_comment["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s || - node_comment["contentText"]["runs"]?.try &.as_a.try { |r| content_to_comment_html(r).try &.to_s } || "" + content_html = node_comment["contentText"]?.try { |t| parse_content(t) } || "" author = node_comment["authorText"]?.try &.["simpleText"]? || "" json.field "author", author @@ -523,6 +514,11 @@ def fill_links(html, scheme, host) return html.to_xml(options: XML::SaveOptions::NO_DECL) end +def parse_content(content : JSON::Any) : String + content["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s || + content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r).try &.to_s } || "" +end + def content_to_comment_html(content) comment_html = content.map do |run| text = HTML.escape(run["text"].as_s) diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index b572ee1c..7a251052 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -313,13 +313,149 @@ def html_to_content(description_html : String) return description end -def extract_videos(nodeset, ucid = nil, author_name = nil) - videos = extract_items(nodeset, ucid, author_name) - videos.select { |item| item.is_a?(SearchVideo) }.map { |video| video.as(SearchVideo) } +def extract_videos(initial_data : Hash(String, JSON::Any)) + extract_items(initial_data).select(&.is_a?(SearchVideo)).map(&.as(SearchVideo)) end -def extract_items(nodeset, ucid = nil, author_name = nil) - # TODO: Make this a 'common', so it makes more sense to be used here +def extract_items(initial_data : Hash(String, JSON::Any)) + items = [] of SearchItem + + initial_data.try { |t| + t["contents"]? || t["response"]? + }.try { |t| + t["twoColumnBrowseResultsRenderer"]?.try &.["tabs"].as_a[0]?.try &.["tabRenderer"]["content"] || + t["twoColumnSearchResultsRenderer"]?.try &.["primaryContents"] || + t["continuationContents"]? + }.try { |t| t["sectionListRenderer"]? || t["sectionListContinuation"]? } + .try &.["contents"] + .as_a.each { |c| + c.try &.["itemSectionRenderer"]["contents"].as_a + .try { |t| t[0]?.try &.["shelfRenderer"]?.try &.["content"]["expandedShelfContentsRenderer"]?.try &.["items"].as_a || t } + .each { |item| + if i = item["videoRenderer"]? + video_id = i["videoId"].as_s + title = i["title"].try { |t| t["simpleText"]?.try &.as_s || t["runs"]?.try &.as_a.map(&.["text"].as_s).join("") } || "" + + author_info = i["ownerText"]?.try &.["runs"].as_a[0]? + author = author_info.try &.["text"].as_s || "" + author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || "" + + published = i["publishedTimeText"]?.try &.["simpleText"]?.try { |t| decode_date(t.as_s) } || Time.local + view_count = i["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 + description_html = i["descriptionSnippet"]?.try { |t| parse_content(t) } || "" + length_seconds = i["lengthText"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || 0 + + live_now = false + paid = false + premium = false + + premiere_timestamp = i["upcomingEventData"]?.try &.["startTime"]?.try { |t| Time.unix(t.as_s.to_i64) } + + i["badges"]?.try &.as_a.each do |badge| + b = badge["metadataBadgeRenderer"] + case b["label"].as_s + when "LIVE NOW" + live_now = true + when "New", "4K", "CC" + # TODO + when "Premium" + paid = true + + # TODO: Potentially available as i["topStandaloneBadge"]["metadataBadgeRenderer"] + premium = true + else nil # Ignore + end + end + + items << SearchVideo.new( + title: title, + id: video_id, + author: author, + ucid: author_id, + published: published, + views: view_count, + description_html: description_html, + length_seconds: length_seconds, + live_now: live_now, + paid: paid, + premium: premium, + premiere_timestamp: premiere_timestamp + ) + elsif i = item["channelRenderer"]? + author = i["title"]["simpleText"]?.try &.as_s || "" + author_id = i["channelId"]?.try &.as_s || "" + + author_thumbnail = i["thumbnail"]["thumbnails"]?.try &.as_a[0]?.try { |u| "https:#{u["url"]}" } || "" + subscriber_count = i["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s.try { |s| short_text_to_number(s.split(" ")[0]) } || 0 + + auto_generated = false + auto_generated = true if !i["videoCountText"]? + video_count = i["videoCountText"]?.try &.["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0 + description_html = i["descriptionSnippet"]?.try { |t| parse_content(t) } || "" + + items << SearchChannel.new( + author: author, + ucid: author_id, + author_thumbnail: author_thumbnail, + subscriber_count: subscriber_count, + video_count: video_count, + description_html: description_html, + auto_generated: auto_generated, + ) + elsif i = item["playlistRenderer"]? + title = i["title"]["simpleText"]?.try &.as_s || "" + plid = i["playlistId"]?.try &.as_s || "" + + video_count = i["videoCount"]?.try &.as_s.to_i || 0 + playlist_thumbnail = i["thumbnails"].as_a[0]?.try &.["thumbnails"]?.try &.as_a[0]?.try &.["url"].as_s || "" + + author_info = i["shortBylineText"]["runs"].as_a[0]? + author = author_info.try &.["text"].as_s || "" + author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || "" + + videos = i["videos"]?.try &.as_a.map do |v| + v = v["childVideoRenderer"] + v_title = v["title"]["simpleText"]?.try &.as_s || "" + v_id = v["videoId"]?.try &.as_s || "" + v_length_seconds = v["lengthText"]?.try &.["simpleText"]?.try { |t| decode_length_seconds(t.as_s) } || 0 + SearchPlaylistVideo.new( + title: v_title, + id: v_id, + length_seconds: v_length_seconds + ) + end || [] of SearchPlaylistVideo + + # TODO: i["publishedTimeText"]? + + items << SearchPlaylist.new( + title: title, + id: plid, + author: author, + ucid: author_id, + video_count: video_count, + videos: videos, + thumbnail: playlist_thumbnail + ) + elsif i = item["radioRenderer"]? # Mix + # TODO + elsif i = item["showRenderer"]? # Show + # TODO + elsif i = item["shelfRenderer"]? + elsif i = item["horizontalCardListRenderer"]? + elsif i = item["searchPyvRenderer"]? # Ad + end + } + } + + items +end + +def extract_videos_html(nodeset, ucid = nil, author_name = nil) + extract_items_html(nodeset, ucid, author_name).select(&.is_a?(SearchVideo)).map(&.as(SearchVideo)) +end + +def extract_items_html(nodeset, ucid = nil, author_name = nil) + # TODO: Make this a 'CommonItem', so it makes more sense to be used here items = [] of SearchItem nodeset.each do |node| @@ -456,7 +592,7 @@ def extract_items(nodeset, ucid = nil, author_name = nil) paid = true end - premiere_timestamp = node.xpath_node(%q(.//ul[@class="yt-lockup-meta-info"]/li/span[@class="localized-date"])).try &.["data-timestamp"]?.try &.to_i64 + premiere_timestamp = node.xpath_node(%q(.//ul[@class="yt-lockup-meta-info"]/li/span[@class="localized-date"])).try &.["data-timestamp"]?.try &.to_i64? if premiere_timestamp premiere_timestamp = Time.unix(premiere_timestamp) end @@ -683,12 +819,12 @@ def check_table(db, logger, table_name, struct_type = nil) return if column_array.size <= struct_array.size - # column_array.each do |column| - # if !struct_array.includes? column - # logger.puts("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") - # db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") - # end - # end + column_array.each do |column| + if !struct_array.includes? column + logger.puts("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") + db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") + end + end end class PG::ResultSet @@ -864,12 +1000,12 @@ def create_notification_stream(env, topics, connection_channel) end end -def extract_initial_data(body) - initial_data = body.match(/window\["ytInitialData"\] = (?.*?);\n/).try &.["info"] || "{}" +def extract_initial_data(body) : Hash(String, JSON::Any) + initial_data = body.match(/window\["ytInitialData"\]\s*=\s*(?.*?);+\n/).try &.["info"] || "{}" if initial_data.starts_with?("JSON.parse(\"") - return JSON.parse(JSON.parse(%({"initial_data":"#{initial_data[12..-3]}"}))["initial_data"].as_s) + return JSON.parse(JSON.parse(%({"initial_data":"#{initial_data[12..-3]}"}))["initial_data"].as_s).as_h else - return JSON.parse(initial_data) + return JSON.parse(initial_data).as_h end end diff --git a/src/invidious/helpers/jobs.cr b/src/invidious/helpers/jobs.cr index a9aee064..e3d7b520 100644 --- a/src/invidious/helpers/jobs.cr +++ b/src/invidious/helpers/jobs.cr @@ -201,7 +201,7 @@ end def bypass_captcha(captcha_key, logger) loop do begin - {"/watch?v=CvFH_6DNRCY&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999", produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw")}.each do |path| + {"/watch?v=CvFH_6DNRCY&gl=US&hl=en&has_verified=1&bpctr=9999999999", produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw")}.each do |path| response = YT_POOL.client &.get(path) if response.body.includes?("To continue with your YouTube experience, please fill out the form below.") html = XML.parse_html(response.body) diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index 0aaacd04..5eabb91b 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -1,8 +1,8 @@ alias SigProc = Proc(Array(String), Int32, Array(String)) def fetch_decrypt_function(id = "CvFH_6DNRCY") - document = YT_POOL.client &.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1").body - url = document.match(/src="(?.*player_ias[^\/]+\/en_US\/base.js)"/).not_nil!["url"] + document = YT_POOL.client &.get("/watch?v=#{id}&gl=US&hl=en").body + url = document.match(/src="(?\/yts\/jsbin\/player_ias-[^\/]+\/en_US\/base.js)"/).not_nil!["url"] player = YT_POOL.client &.get(url).body function_name = player.match(/^(?[^=]+)=function\(\w\){\w=\w\.split\(""\);[^\. ]+\.[^( ]+/m).not_nil!["name"] diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index a39a0b16..a51f15ce 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -8,7 +8,7 @@ def add_yt_headers(request) request.headers["accept-language"] ||= "en-us,en;q=0.5" return if request.resource.starts_with? "/sorry/index" request.headers["x-youtube-client-name"] ||= "1" - request.headers["x-youtube-client-version"] ||= "1.20180719" + request.headers["x-youtube-client-version"] ||= "2.20200609" if !CONFIG.cookies.empty? request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" end diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr index 04a37b87..6c01d78b 100644 --- a/src/invidious/mixes.cr +++ b/src/invidious/mixes.cr @@ -20,7 +20,6 @@ end def fetch_mix(rdid, video_id, cookies = nil, locale = nil) headers = HTTP::Headers.new - headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36" if cookies headers = cookies.add_request_headers(headers) diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 7a88f316..b4bd6226 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -96,6 +96,10 @@ struct SearchVideo end end + def is_upcoming + premiere_timestamp ? true : false + end + db_mapping({ title: String, id: String, @@ -227,61 +231,35 @@ end alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist def channel_search(query, page, channel) - response = YT_POOL.client &.get("/channel/#{channel}?disable_polymer=1&hl=en&gl=US") - document = XML.parse_html(response.body) - canonical = document.xpath_node(%q(//link[@rel="canonical"])) + response = YT_POOL.client &.get("/channel/#{channel}?hl=en&gl=US") + response = YT_POOL.client &.get("/user/#{channel}?hl=en&gl=US") if response.headers["location"]? + response = YT_POOL.client &.get("/c/#{channel}?hl=en&gl=US") if response.headers["location"]? - if !canonical - response = YT_POOL.client &.get("/c/#{channel}?disable_polymer=1&hl=en&gl=US") - document = XML.parse_html(response.body) - canonical = document.xpath_node(%q(//link[@rel="canonical"])) - end + ucid = response.body.match(/\\"channelId\\":\\"(?[^\\]+)\\"/).try &.["ucid"]? - if !canonical - response = YT_POOL.client &.get("/user/#{channel}?disable_polymer=1&hl=en&gl=US") - document = XML.parse_html(response.body) - canonical = document.xpath_node(%q(//link[@rel="canonical"])) - end - - if !canonical - return 0, [] of SearchItem - end - - ucid = canonical["href"].split("/")[-1] + return 0, [] of SearchItem if !ucid url = produce_channel_search_url(ucid, query, page) response = YT_POOL.client &.get(url) - json = JSON.parse(response.body) + initial_data = JSON.parse(response.body).as_a.find &.["response"]? + return 0, [] of SearchItem if !initial_data + items = extract_items(initial_data.as_h) - if json["content_html"]? && !json["content_html"].as_s.empty? - document = XML.parse_html(json["content_html"].as_s) - nodeset = document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")])) - - count = nodeset.size - items = extract_items(nodeset) - else - count = 0 - items = [] of SearchItem - end - - return count, items + return items.size, items end def search(query, page = 1, search_params = produce_search_params(content_type: "all"), region = nil) - if query.empty? - return {0, [] of SearchItem} - end + return 0, [] of SearchItem if query.empty? - html = YT_POOL.client(region, &.get("/results?q=#{URI.encode_www_form(query)}&page=#{page}&sp=#{search_params}&hl=en&disable_polymer=1").body) - if html.empty? - return {0, [] of SearchItem} - end + body = YT_POOL.client(region, &.get("/results?q=#{URI.encode_www_form(query)}&page=#{page}&sp=#{search_params}&hl=en").body) + return 0, [] of SearchItem if body.empty? - html = XML.parse_html(html) - nodeset = html.xpath_nodes(%q(//ol[@class="item-section"]/li)) - items = extract_items(nodeset) + initial_data = extract_initial_data(body) + items = extract_items(initial_data) - return {nodeset.size, items} + # initial_data["estimatedResults"]?.try &.as_s.to_i64 + + return items.size, items end def produce_search_params(sort : String = "relevance", date : String = "", content_type : String = "", @@ -387,12 +365,9 @@ def produce_channel_search_url(ucid, query, page) "2:string" => ucid, "3:base64" => { "2:string" => "search", - "6:varint" => 2_i64, "7:varint" => 1_i64, - "12:varint" => 1_i64, - "13:string" => "", - "23:varint" => 0_i64, "15:string" => "#{page}", + "23:varint" => 0_i64, }, "11:string" => query, }, diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index 017c42f5..8d078387 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -1,7 +1,4 @@ def fetch_trending(trending_type, region, locale) - headers = HTTP::Headers.new - headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" - region ||= "US" region = region.upcase @@ -11,7 +8,7 @@ def fetch_trending(trending_type, region, locale) if trending_type && trending_type != "Default" trending_type = trending_type.downcase.capitalize - response = YT_POOL.client &.get("/feed/trending?gl=#{region}&hl=en", headers).body + response = YT_POOL.client &.get("/feed/trending?gl=#{region}&hl=en").body initial_data = extract_initial_data(response) @@ -21,31 +18,28 @@ def fetch_trending(trending_type, region, locale) if url url["channelListSubMenuAvatarRenderer"]["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"] url = url["channelListSubMenuAvatarRenderer"]["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s - url += "&disable_polymer=1&gl=#{region}&hl=en" + url = "#{url}&gl=#{region}&hl=en" trending = YT_POOL.client &.get(url).body plid = extract_plid(url) else - trending = YT_POOL.client &.get("/feed/trending?gl=#{region}&hl=en&disable_polymer=1").body + trending = YT_POOL.client &.get("/feed/trending?gl=#{region}&hl=en").body end else - trending = YT_POOL.client &.get("/feed/trending?gl=#{region}&hl=en&disable_polymer=1").body + trending = YT_POOL.client &.get("/feed/trending?gl=#{region}&hl=en").body end - trending = XML.parse_html(trending) - nodeset = trending.xpath_nodes(%q(//ul/li[@class="expanded-shelf-content-item-wrapper"])) - trending = extract_videos(nodeset) + initial_data = extract_initial_data(trending) + trending = extract_videos(initial_data) return {trending, plid} end def extract_plid(url) - plid = URI.parse(url) - .try { |i| HTTP::Params.parse(i.query.not_nil!)["bp"] } + return url.try { |i| URI.parse(i).query } + .try { |i| HTTP::Params.parse(i)["bp"] } .try { |i| URI.decode_www_form(i) } .try { |i| Base64.decode(i) } .try { |i| IO::Memory.new(i) } .try { |i| Protodec::Any.parse(i) } - .try { |i| i["44:0:embedded"]["2:1:string"].as_s } - - return plid + .try &.["44:0:embedded"]?.try &.["2:1:string"]?.try &.as_s end diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 0aa94d82..ba15692c 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -267,7 +267,7 @@ def subscribe_ajax(channel_id, action, env_headers) end headers = cookies.add_request_headers(headers) - if match = html.body.match(/'XSRF_TOKEN': "(?[A-Za-z0-9\_\-\=]+)"/) + if match = html.body.match(/'XSRF_TOKEN': "(?[^"]+)"/) session_token = match["session_token"] headers["content-type"] = "application/x-www-form-urlencoded" @@ -300,7 +300,7 @@ end # end # headers = cookies.add_request_headers(headers) # -# if match = html.body.match(/'XSRF_TOKEN': "(?[A-Za-z0-9\_\-\=]+)"/) +# if match = html.body.match(/'XSRF_TOKEN': "(?[^"]+)"/) # session_token = match["session_token"] # # headers["content-type"] = "application/x-www-form-urlencoded" diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index ed5847e4..f2638f14 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -246,12 +246,9 @@ struct VideoPreferences end struct Video - property player_json : JSON::Any? - property recommended_json : JSON::Any? - - module HTTPParamConverter + module JSONConverter def self.from_rs(rs) - HTTP::Params.parse(rs.read(String)) + JSON.parse(rs.read(String)).as_h end end @@ -271,7 +268,7 @@ struct Video generate_storyboards(json, self.id, self.storyboards) end - json.field "description", html_to_content(self.description_html) + json.field "description", self.description json.field "descriptionHtml", self.description_html json.field "published", self.published.to_unix json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) @@ -310,13 +307,13 @@ struct Video json.field "lengthSeconds", self.length_seconds json.field "allowRatings", self.allow_ratings - json.field "rating", self.info["avg_rating"].to_f32 + json.field "rating", self.average_rating json.field "isListed", self.is_listed json.field "liveNow", self.live_now json.field "isUpcoming", self.is_upcoming if self.premiere_timestamp - json.field "premiereTimestamp", self.premiere_timestamp.not_nil!.to_unix + json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix end if hlsvp = self.hls_manifest_url @@ -328,21 +325,21 @@ struct Video json.field "adaptiveFormats" do json.array do - self.adaptive_fmts(decrypt_function).each do |fmt| + self.adaptive_fmts.each do |fmt| json.object do - json.field "index", fmt["index"] - json.field "bitrate", fmt["bitrate"] - json.field "init", fmt["init"] + json.field "index", "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}" + json.field "bitrate", fmt["bitrate"].as_i.to_s + json.field "init", "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}" json.field "url", fmt["url"] - json.field "itag", fmt["itag"] - json.field "type", fmt["type"] - json.field "clen", fmt["clen"] - json.field "lmt", fmt["lmt"] - json.field "projectionType", fmt["projection_type"] + json.field "itag", fmt["itag"].as_i.to_s + json.field "type", fmt["mimeType"] + json.field "clen", fmt["contentLength"] + json.field "lmt", fmt["lastModified"] + json.field "projectionType", fmt["projectionType"] fmt_info = itag_to_metadata?(fmt["itag"]) if fmt_info - fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.to_i || 30 + fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 json.field "fps", fps json.field "container", fmt_info["ext"] json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] @@ -368,16 +365,16 @@ struct Video json.field "formatStreams" do json.array do - self.fmt_stream(decrypt_function).each do |fmt| + self.fmt_stream.each do |fmt| json.object do json.field "url", fmt["url"] - json.field "itag", fmt["itag"] - json.field "type", fmt["type"] + json.field "itag", fmt["itag"].as_i.to_s + json.field "type", fmt["mimeType"] json.field "quality", fmt["quality"] fmt_info = itag_to_metadata?(fmt["itag"]) if fmt_info - fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.to_i || 30 + fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 json.field "fps", fps json.field "container", fmt_info["ext"] json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] @@ -415,9 +412,7 @@ struct Video json.field "recommendedVideos" do json.array do - self.info["rvs"]?.try &.split(",").each do |rv| - rv = HTTP::Params.parse(rv) - + self.related_videos.each do |rv| if rv["id"]? json.object do json.field "videoId", rv["id"] @@ -436,7 +431,7 @@ struct Video qualities.each do |quality| json.object do - json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-") + json.field "url", rv["author_thumbnail"]?.try &.gsub(/s\d+-/, "s#{quality}-") json.field "width", quality json.field "height", quality end @@ -445,9 +440,9 @@ struct Video end end - json.field "lengthSeconds", rv["length_seconds"].to_i - json.field "viewCountText", rv["short_view_count_text"] - json.field "viewCount", rv["view_count"]?.try &.to_i64 + json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i + json.field "viewCountText", rv["short_view_count_text"]? + json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64 end end end @@ -466,256 +461,150 @@ struct Video end end - # `description_html` is stored in DB as `description`, which can be - # quite confusing. Since it currently isn't very practical to rename - # it, we instead define a getter and setter here. - def description_html - self.description + def title + info["videoDetails"]["title"]?.try &.as_s || "" end - def description_html=(other : String) - self.description = other + def ucid + info["videoDetails"]["channelId"]?.try &.as_s || "" + end + + def author + info["videoDetails"]["author"]?.try &.as_s || "" + end + + def length_seconds : Int32 + info["microformat"]?.try &.["playerMicroformatRenderer"]?.try &.["lengthSeconds"]?.try &.as_s.to_i || + info["videoDetails"]["lengthSeconds"]?.try &.as_s.to_i || 0 + end + + def views : Int64 + info["videoDetails"]["viewCount"]?.try &.as_s.to_i64 || 0_i64 + end + + def likes : Int64 + info["likes"]?.try &.as_i64 || 0_i64 + end + + def dislikes : Int64 + info["dislikes"]?.try &.as_i64 || 0_i64 + end + + def average_rating : Float64 + # (likes / (likes + dislikes) * 4 + 1) + info["videoDetails"]["averageRating"]?.try { |t| t.as_f? || t.as_i64?.try &.to_f64 }.try &.round(4) || 0.0 + end + + def published : Time + info["microformat"]?.try &.["playerMicroformatRenderer"]?.try &.["publishDate"]?.try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location.local) } || Time.local + end + + def published=(other : Time) + info["microformat"].as_h["playerMicroformatRenderer"].as_h["publishDate"] = JSON::Any.new(other.to_s("%Y-%m-%d")) + end + + def cookie + info["cookie"]?.try &.as_h.map { |k, v| "#{k}=#{v}" }.join("; ") || "" end def allow_ratings - allow_ratings = player_response["videoDetails"]?.try &.["allowRatings"]?.try &.as_bool - - if allow_ratings.nil? - return true - end - - return allow_ratings + r = info["videoDetails"]["allowRatings"]?.try &.as_bool + r.nil? ? false : r end def live_now - live_now = player_response["videoDetails"]?.try &.["isLive"]?.try &.as_bool - - if live_now.nil? - return false - end - - return live_now + info["videoDetails"]["isLiveContent"]?.try &.as_bool || false end def is_listed - is_listed = player_response["videoDetails"]?.try &.["isCrawlable"]?.try &.as_bool - - if is_listed.nil? - return true - end - - return is_listed + info["videoDetails"]["isCrawlable"]?.try &.as_bool || false end def is_upcoming - is_upcoming = player_response["videoDetails"]?.try &.["isUpcoming"]?.try &.as_bool - - if is_upcoming.nil? - return false - end - - return is_upcoming + info["videoDetails"]["isUpcoming"]?.try &.as_bool || false end - def premiere_timestamp - if self.is_upcoming - premiere_timestamp = player_response["playabilityStatus"]? - .try &.["liveStreamability"]? - .try &.["liveStreamabilityRenderer"]? - .try &.["offlineSlate"]? - .try &.["liveStreamOfflineSlateRenderer"]? - .try &.["scheduledStartTime"]?.try &.as_s.to_i64 - end - - if premiere_timestamp - premiere_timestamp = Time.unix(premiere_timestamp) - end - - return premiere_timestamp + def premiere_timestamp : Time? + info["microformat"]?.try &.["playerMicroformatRenderer"]? + .try &.["liveBroadcastDetails"]?.try &.["startTimestamp"]?.try { |t| Time.parse_rfc3339(t.as_s) } end def keywords - keywords = player_response["videoDetails"]?.try &.["keywords"]?.try &.as_a - keywords ||= [] of String - - return keywords + info["videoDetails"]["keywords"]?.try &.as_a.map &.as_s || [] of String end - def fmt_stream(decrypt_function) - streams = [] of HTTP::Params - - if fmt_streams = player_response["streamingData"]?.try &.["formats"]? - fmt_streams.as_a.each do |fmt_stream| - if !fmt_stream.as_h? - next - end - - fmt = {} of String => String - - fmt["lmt"] = fmt_stream["lastModified"]?.try &.as_s || "0" - fmt["projection_type"] = "1" - fmt["type"] = fmt_stream["mimeType"].as_s - fmt["clen"] = fmt_stream["contentLength"]?.try &.as_s || "0" - fmt["bitrate"] = fmt_stream["bitrate"]?.try &.as_i.to_s || "0" - fmt["itag"] = fmt_stream["itag"].as_i.to_s - if fmt_stream["url"]? - fmt["url"] = fmt_stream["url"].as_s - end - if cipher = fmt_stream["cipher"]? || fmt_stream["signatureCipher"]? - HTTP::Params.parse(cipher.as_s).each do |key, value| - fmt[key] = value - end - end - fmt["quality"] = fmt_stream["quality"].as_s - - if fmt_stream["width"]? - fmt["size"] = "#{fmt_stream["width"]}x#{fmt_stream["height"]}" - fmt["height"] = fmt_stream["height"].as_i.to_s - end - - if fmt_stream["fps"]? - fmt["fps"] = fmt_stream["fps"].as_i.to_s - end - - if fmt_stream["qualityLabel"]? - fmt["quality_label"] = fmt_stream["qualityLabel"].as_s - end - - params = HTTP::Params.new - fmt.each do |key, value| - params[key] = value - end - - streams << params - end - - streams.sort_by! { |stream| stream["height"].to_i }.reverse! - elsif fmt_stream = self.info["url_encoded_fmt_stream_map"]? - fmt_stream.split(",").each do |string| - if !string.empty? - streams << HTTP::Params.parse(string) - end - end - end - - streams.each { |s| s.add("label", "#{s["quality"]} - #{s["type"].split(";")[0].split("/")[1]}") } - streams = streams.uniq { |s| s["label"] } - - if self.info["region"]? - streams.each do |fmt| - fmt["url"] += "®ion=" + self.info["region"] - end - end - - streams.each do |fmt| - fmt["url"] += "&host=" + (URI.parse(fmt["url"]).host || "") - fmt["url"] += decrypt_signature(fmt, decrypt_function) - end - - return streams + def related_videos + info["relatedVideos"]?.try &.as_a.map { |h| h.as_h.transform_values &.as_s } || [] of Hash(String, String) end - def adaptive_fmts(decrypt_function) - adaptive_fmts = [] of HTTP::Params - - if fmts = player_response["streamingData"]?.try &.["adaptiveFormats"]? - fmts.as_a.each do |adaptive_fmt| - next if !adaptive_fmt.as_h? - fmt = {} of String => String - - if init = adaptive_fmt["initRange"]? - fmt["init"] = "#{init["start"]}-#{init["end"]}" - end - fmt["init"] ||= "0-0" - - fmt["lmt"] = adaptive_fmt["lastModified"]?.try &.as_s || "0" - fmt["projection_type"] = "1" - fmt["type"] = adaptive_fmt["mimeType"].as_s - fmt["clen"] = adaptive_fmt["contentLength"]?.try &.as_s || "0" - fmt["bitrate"] = adaptive_fmt["bitrate"]?.try &.as_i.to_s || "0" - fmt["itag"] = adaptive_fmt["itag"].as_i.to_s - if adaptive_fmt["url"]? - fmt["url"] = adaptive_fmt["url"].as_s - end - if cipher = adaptive_fmt["cipher"]? || adaptive_fmt["signatureCipher"]? - HTTP::Params.parse(cipher.as_s).each do |key, value| - fmt[key] = value - end - end - if index = adaptive_fmt["indexRange"]? - fmt["index"] = "#{index["start"]}-#{index["end"]}" - end - fmt["index"] ||= "0-0" - - if adaptive_fmt["width"]? - fmt["size"] = "#{adaptive_fmt["width"]}x#{adaptive_fmt["height"]}" - end - - if adaptive_fmt["fps"]? - fmt["fps"] = adaptive_fmt["fps"].as_i.to_s - end - - if adaptive_fmt["qualityLabel"]? - fmt["quality_label"] = adaptive_fmt["qualityLabel"].as_s - end - - params = HTTP::Params.new - fmt.each do |key, value| - params[key] = value - end - - adaptive_fmts << params - end - elsif fmts = self.info["adaptive_fmts"]? - fmts.split(",") do |string| - adaptive_fmts << HTTP::Params.parse(string) - end - end - - if self.info["region"]? - adaptive_fmts.each do |fmt| - fmt["url"] += "®ion=" + self.info["region"] - end - end - - adaptive_fmts.each do |fmt| - fmt["url"] += "&host=" + (URI.parse(fmt["url"]).host || "") - fmt["url"] += decrypt_signature(fmt, decrypt_function) - end - - return adaptive_fmts + def allowed_regions + info["microformat"]?.try &.["playerMicroformatRenderer"]? + .try &.["availableCountries"]?.try &.as_a.map &.as_s || [] of String end - def video_streams(adaptive_fmts) - video_streams = adaptive_fmts.select { |s| s["type"].starts_with? "video" } - - return video_streams + def author_thumbnail : String + info["authorThumbnail"]?.try &.as_s || "" end - def audio_streams(adaptive_fmts) - audio_streams = adaptive_fmts.select { |s| s["type"].starts_with? "audio" } - audio_streams.sort_by! { |s| s["bitrate"].to_i }.reverse! - audio_streams.each do |stream| - stream["bitrate"] = (stream["bitrate"].to_f64/1000).to_i.to_s + def sub_count_text : String + info["subCountText"]?.try &.as_s || "-" + end + + def fmt_stream + return @fmt_stream.as(Array(Hash(String, JSON::Any))) if @fmt_stream + fmt_stream = info["streamingData"]?.try &.["formats"]?.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any) + fmt_stream.each do |fmt| + if s = (fmt["cipher"]? || fmt["signatureCipher"]?).try { |h| HTTP::Params.parse(h.as_s) } + s.each do |k, v| + fmt[k] = JSON::Any.new(v) + end + fmt["url"] = JSON::Any.new("#{fmt["url"]}#{decrypt_signature(fmt)}") + end + + fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}") + fmt["url"] = JSON::Any.new("#{fmt["url"]}®ion=#{self.info["region"]}") if self.info["region"]? end - - return audio_streams + fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 } + @fmt_stream = fmt_stream + return @fmt_stream.as(Array(Hash(String, JSON::Any))) end - def player_response - @player_json = JSON.parse(@info["player_response"]) if !@player_json - @player_json.not_nil! + def adaptive_fmts + return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) if @adaptive_fmts + fmt_stream = info["streamingData"]?.try &.["adaptiveFormats"]?.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any) + fmt_stream.each do |fmt| + if s = (fmt["cipher"]? || fmt["signatureCipher"]?).try { |h| HTTP::Params.parse(h.as_s) } + s.each do |k, v| + fmt[k] = JSON::Any.new(v) + end + fmt["url"] = JSON::Any.new("#{fmt["url"]}#{decrypt_signature(fmt)}") + end + + fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}") + fmt["url"] = JSON::Any.new("#{fmt["url"]}®ion=#{self.info["region"]}") if self.info["region"]? + end + fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 } + @adaptive_fmts = fmt_stream + return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) + end + + def video_streams + adaptive_fmts.select &.["mimeType"]?.try &.as_s.starts_with?("video") + end + + def audio_streams + adaptive_fmts.select &.["mimeType"]?.try &.as_s.starts_with?("audio") end def storyboards - storyboards = player_response["storyboards"]? + storyboards = info["storyboards"]? .try &.as_h .try &.["playerStoryboardSpecRenderer"]? .try &.["spec"]? .try &.as_s.split("|") if !storyboards - if storyboard = player_response["storyboards"]? + if storyboard = info["storyboards"]? .try &.as_h .try &.["playerLiveStoryboardSpecRenderer"]? .try &.["spec"]? @@ -743,9 +632,7 @@ struct Video storyboard_height: Int32, storyboard_count: Int32) - if !storyboards - return items - end + return items if !storyboards url = URI.parse(storyboards.shift) params = HTTP::Params.parse(url.query || "") @@ -779,82 +666,98 @@ struct Video end def paid - reason = player_response["playabilityStatus"]?.try &.["reason"]? + reason = info["playabilityStatus"]?.try &.["reason"]? paid = reason == "This video requires payment to watch." ? true : false - - return paid + paid end def premium - if info["premium"]? - self.info["premium"] == "true" - else - false - end + keywords.includes? "YouTube Red" end - def captions - captions = [] of Caption - if player_response["captions"]? - caption_list = player_response["captions"]["playerCaptionsTracklistRenderer"]["captionTracks"]?.try &.as_a - caption_list ||= [] of JSON::Any - - caption_list.each do |caption| - caption = Caption.from_json(caption.to_json) - caption.name.simpleText = caption.name.simpleText.split(" - ")[0] - captions << caption - end + def captions : Array(Caption) + return @captions.as(Array(Caption)) if @captions + captions = info["captions"]?.try &.["playerCaptionsTracklistRenderer"]?.try &.["captionTracks"]?.try &.as_a.map do |caption| + caption = Caption.from_json(caption.to_json) + caption.name.simpleText = caption.name.simpleText.split(" - ")[0] + caption end + captions ||= [] of Caption + @captions = captions + return @captions.as(Array(Caption)) + end - return captions + def description + description = info["microformat"]?.try &.["playerMicroformatRenderer"]? + .try &.["description"]?.try &.["simpleText"]?.try &.as_s || "" + end + + # TODO + def description=(value : String) + @description = value + end + + def description_html + info["descriptionHtml"]?.try &.as_s || "

" + end + + def description_html=(value : String) + info["descriptionHtml"] = JSON::Any.new(value) end def short_description - short_description = self.description_html.gsub(/(
)|(|"|\n)/, { - "
": " ", - "
": " ", - "\"": """, - "\n": " ", - }) - short_description = XML.parse_html(short_description).content[0..200].strip(" ") - - if short_description.empty? - short_description = " " - end - - return short_description + info["shortDescription"]?.try &.as_s || "" end - def length_seconds - player_response["videoDetails"]["lengthSeconds"].as_s.to_i + def hls_manifest_url : String? + info["streamingData"]?.try &.["hlsManifestUrl"]?.try &.as_s + end + + def dash_manifest_url + info["streamingData"]?.try &.["dashManifestUrl"]?.try &.as_s + end + + def genre : String + info["genre"]?.try &.as_s || "" + end + + def genre_url : String + info["genreUcid"]? ? "/channel/#{info["genreUcid"]}" : "" + end + + def license : String? + info["license"]?.try &.as_s + end + + def is_family_friendly : Bool + info["microformat"]?.try &.["playerMicroformatRenderer"]["isFamilySafe"]?.try &.as_bool || false + end + + def wilson_score : Float64 + ci_lower_bound(likes, likes + dislikes).round(4) + end + + def engagement : Float64 + ((likes + dislikes) / views).round(4) + end + + def reason : String? + info["reason"]?.try &.as_s + end + + def session_token : String? + info["sessionToken"]?.try &.as_s? end db_mapping({ - id: String, - info: { - type: HTTP::Params, - default: HTTP::Params.parse(""), - converter: Video::HTTPParamConverter, - }, - updated: Time, - title: String, - views: Int64, - likes: Int32, - dislikes: Int32, - wilson_score: Float64, - published: Time, - description: String, - language: String?, - author: String, - ucid: String, - allowed_regions: Array(String), - is_family_friendly: Bool, - genre: String, - genre_url: String, - license: String, - sub_count_text: String, - author_thumbnail: String, + id: String, + info: {type: Hash(String, JSON::Any), converter: Video::JSONConverter}, + updated: Time, }) + + @captions : Array(Caption)? + @adaptive_fmts : Array(Hash(String, JSON::Any))? + @fmt_stream : Array(Hash(String, JSON::Any))? end struct Caption @@ -878,121 +781,64 @@ class VideoRedirect < Exception end end -def get_video(id, db, refresh = true, region = nil, force_refresh = false) - if (video = db.query_one?("SELECT * FROM videos WHERE id = $1", id, as: Video)) && !region - # If record was last updated over 10 minutes ago, or video has since premiered, - # refresh (expire param in response lasts for 6 hours) - if (refresh && - (Time.utc - video.updated > 10.minutes) || - (video.premiere_timestamp && video.premiere_timestamp.as(Time) < Time.utc)) || - force_refresh - begin - video = fetch_video(id, region) - video_array = video.to_a +def parse_related(r : JSON::Any) : JSON::Any? + # TODO: r["endScreenPlaylistRenderer"], etc. + return if !r["endScreenVideoRenderer"]? + r = r["endScreenVideoRenderer"].as_h - args = arg_array(video_array[1..-1], 2) + return if !r["lengthInSeconds"]? - db.exec("UPDATE videos SET (info,updated,title,views,likes,dislikes,wilson_score,\ - published,description,language,author,ucid,allowed_regions,is_family_friendly,\ - genre,genre_url,license,sub_count_text,author_thumbnail)\ - = (#{args}) WHERE id = $1", args: video_array) - rescue ex - db.exec("DELETE FROM videos * WHERE id = $1", id) - raise ex - end - end - else - video = fetch_video(id, region) - video_array = video.to_a - - args = arg_array(video_array) - - if !region - db.exec("INSERT INTO videos VALUES (#{args}) ON CONFLICT (id) DO NOTHING", args: video_array) - end - end - - return video + rv = {} of String => JSON::Any + rv["author"] = r["shortBylineText"]["runs"][0]?.try &.["text"] || JSON::Any.new("") + rv["ucid"] = r["shortBylineText"]["runs"][0]?.try &.["navigationEndpoint"]["browseEndpoint"]["browseId"] || JSON::Any.new("") + rv["author_url"] = JSON::Any.new("/channel/#{rv["ucid"]}") + rv["length_seconds"] = JSON::Any.new(r["lengthInSeconds"].as_i.to_s) + rv["title"] = r["title"]["simpleText"] + rv["short_view_count_text"] = JSON::Any.new(r["shortViewCountText"]?.try &.["simpleText"]?.try &.as_s || "") + rv["view_count"] = JSON::Any.new(r["title"]["accessibility"]?.try &.["accessibilityData"]["label"].as_s.match(/(?[1-9](\d+,?)*) views/).try &.["views"].gsub(/\D/, "") || "") + rv["id"] = r["videoId"] + JSON::Any.new(rv) end -def extract_recommended(recommended_videos) - rvs = [] of HTTP::Params +def extract_polymer_config(body) + params = {} of String => JSON::Any + player_response = body.match(/window\["ytInitialPlayerResponse"\]\s*=\s*(?.*?);\n/) + .try { |r| JSON.parse(r["info"]).as_h } - recommended_videos.try &.each do |compact_renderer| - if compact_renderer["compactRadioRenderer"]? || compact_renderer["compactPlaylistRenderer"]? - # TODO - elsif video_renderer = compact_renderer["compactVideoRenderer"]? - recommended_video = HTTP::Params.new - recommended_video["id"] = video_renderer["videoId"].as_s - recommended_video["title"] = video_renderer["title"]["simpleText"].as_s - - next if !video_renderer["shortBylineText"]? - - recommended_video["author"] = video_renderer["shortBylineText"]["runs"].as_a[0]["text"].as_s - recommended_video["ucid"] = video_renderer["shortBylineText"]["runs"].as_a[0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s - recommended_video["author_thumbnail"] = video_renderer["channelThumbnail"]["thumbnails"][0]["url"].as_s - - if view_count = video_renderer["viewCountText"]?.try { |field| field["simpleText"]?.try &.as_s || field["runs"][0]?.try &.["text"].as_s }.try &.delete(", views watching").to_i64?.try &.to_s - recommended_video["view_count"] = view_count - recommended_video["short_view_count_text"] = "#{number_to_short_text(view_count.to_i64)} views" - end - recommended_video["length_seconds"] = decode_length_seconds(video_renderer["lengthText"]?.try &.["simpleText"]?.try &.as_s || "0:00").to_s - - rvs << recommended_video - end + if body.includes?("To continue with your YouTube experience, please fill out the form below.") || + body.includes?("https://www.google.com/sorry/index") + params["reason"] = JSON::Any.new("Could not extract video info. Instance is likely blocked.") + elsif !player_response + params["reason"] = JSON::Any.new("Video unavailable.") + elsif player_response["playabilityStatus"]?.try &.["status"]?.try &.as_s != "OK" + reason = player_response["playabilityStatus"]["errorScreen"]?.try &.["playerErrorMessageRenderer"]?.try &.["subreason"]?.try { |s| s["simpleText"]?.try &.as_s || s["runs"].as_a.map { |r| r["text"] }.join("") } || + player_response["playabilityStatus"]["reason"].as_s + params["reason"] = JSON::Any.new(reason) end - rvs -end + params["sessionToken"] = JSON::Any.new(body.match(/"XSRF_TOKEN":"(?[^"]+)"/).try &.["session_token"]?) + params["shortDescription"] = JSON::Any.new(body.match(/"og:description" content="(?[^"]+)"/).try &.["description"]?) -def extract_polymer_config(body, html) - params = HTTP::Params.new + return params if !player_response - params["session_token"] = body.match(/"XSRF_TOKEN":"(?[A-Za-z0-9\_\-\=]+)"/).try &.["session_token"] || "" - - html_info = JSON.parse(body.match(/ytplayer\.config = (?.*?);ytplayer\.load/).try &.["info"] || "{}").try &.["args"]?.try &.as_h - - if html_info - html_info.each do |key, value| - params[key] = value.to_s - end + {"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f| + params[f] = player_response[f] if player_response[f]? end - initial_data = extract_initial_data(body) + yt_initial_data = body.match(/window\["ytInitialData"\]\s*=\s*(?.*?);\n/) + .try { |r| JSON.parse(r["info"]).as_h } - primary_results = initial_data["contents"]? - .try &.["twoColumnWatchNextResults"]? - .try &.["results"]? - .try &.["results"]? - .try &.["contents"]? - - comment_continuation = primary_results.try &.as_a.select { |object| object["itemSectionRenderer"]? }[0]? - .try &.["itemSectionRenderer"]? - .try &.["continuations"]? - .try &.[0]? - .try &.["nextContinuationData"]? - - params["ctoken"] = comment_continuation.try &.["continuation"]?.try &.as_s || "" - params["itct"] = comment_continuation.try &.["clickTrackingParams"]?.try &.as_s || "" - - rvs = initial_data["contents"]? - .try &.["twoColumnWatchNextResults"]? - .try &.["secondaryResults"]? - .try &.["secondaryResults"]? - .try &.["results"]? - .try &.as_a - - params["rvs"] = extract_recommended(rvs).join(",") - - # TODO: Watching now - params["views"] = primary_results.try &.as_a.select { |object| object["videoPrimaryInfoRenderer"]? }[0]? - .try &.["videoPrimaryInfoRenderer"]? - .try &.["viewCount"]? - .try &.["videoViewCountRenderer"]? - .try &.["viewCount"]? - .try &.["simpleText"]? - .try &.as_s.gsub(/\D/, "").to_i64.to_s || "0" + params["relatedVideos"] = yt_initial_data.try &.["playerOverlays"]?.try &.["playerOverlayRenderer"]? + .try &.["endScreen"]?.try &.["watchNextEndScreenRenderer"]?.try &.["results"]?.try &.as_a.compact_map { |r| + parse_related r + }.try { |a| JSON::Any.new(a) } || yt_initial_data.try &.["webWatchNextResponseExtensionData"]?.try &.["relatedVideoArgs"]? + .try &.as_s.split(",").map { |r| + r = HTTP::Params.parse(r).to_h + JSON::Any.new(Hash.zip(r.keys, r.values.map { |v| JSON::Any.new(v) })) + }.try { |a| JSON::Any.new(a) } || JSON::Any.new([] of JSON::Any) + primary_results = yt_initial_data.try &.["contents"]?.try &.["twoColumnWatchNextResults"]?.try &.["results"]? + .try &.["results"]?.try &.["contents"]? sentiment_bar = primary_results.try &.as_a.select { |object| object["videoPrimaryInfoRenderer"]? }[0]? .try &.["videoPrimaryInfoRenderer"]? .try &.["sentimentBar"]? @@ -1000,34 +846,13 @@ def extract_polymer_config(body, html) .try &.["tooltip"]? .try &.as_s - likes, dislikes = sentiment_bar.try &.split(" / ").map { |a| a.delete(", ").to_i32 }[0, 2] || {0, 0} + likes, dislikes = sentiment_bar.try &.split(" / ", 2).map &.gsub(/\D/, "").to_i64 || {0_i64, 0_i64} + params["likes"] = JSON::Any.new(likes) + params["dislikes"] = JSON::Any.new(dislikes) - params["likes"] = "#{likes}" - params["dislikes"] = "#{dislikes}" - - published = primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]? - .try &.["videoSecondaryInfoRenderer"]? - .try &.["dateText"]? - .try &.["simpleText"]? - .try &.as_s.split(" ")[-3..-1].join(" ") - - if published - params["published"] = Time.parse(published, "%b %-d, %Y", Time::Location.local).to_unix.to_s - else - params["published"] = Time.utc(1990, 1, 1).to_unix.to_s - end - - params["description_html"] = "

" - - description_html = primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]? - .try &.["videoSecondaryInfoRenderer"]? - .try &.["description"]? - .try &.["runs"]? - .try &.as_a - - if description_html - params["description_html"] = content_to_comment_html(description_html) - end + params["descriptionHtml"] = JSON::Any.new(primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]? + .try &.["videoSecondaryInfoRenderer"]?.try &.["description"]?.try &.["runs"]? + .try &.as_a.try { |t| content_to_comment_html(t).gsub("\n", "
") } || "

") metadata = primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]? .try &.["videoSecondaryInfoRenderer"]? @@ -1036,10 +861,6 @@ def extract_polymer_config(body, html) .try &.["rows"]? .try &.as_a - params["genre"] = "" - params["genre_ucid"] = "" - params["license"] = "" - metadata.try &.each do |row| title = row["metadataRowRenderer"]?.try &.["title"]?.try &.["simpleText"]?.try &.as_s contents = row["metadataRowRenderer"]? @@ -1050,220 +871,125 @@ def extract_polymer_config(body, html) contents = contents.try &.["runs"]? .try &.as_a[0]? - params["genre"] = contents.try &.["text"]? - .try &.as_s || "" - params["genre_ucid"] = contents.try &.["navigationEndpoint"]? - .try &.["browseEndpoint"]? - .try &.["browseId"]?.try &.as_s || "" + params["genre"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "") + params["genreUcid"] = JSON::Any.new(contents.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]? + .try &.["browseId"]?.try &.as_s || "") elsif title.try &.== "License" contents = contents.try &.["runs"]? .try &.as_a[0]? - params["license"] = contents.try &.["text"]? - .try &.as_s || "" + params["license"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "") elsif title.try &.== "Licensed to YouTube by" - params["license"] = contents.try &.["simpleText"]? - .try &.as_s || "" + params["license"] = JSON::Any.new(contents.try &.["simpleText"]?.try &.as_s || "") end end author_info = primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]? - .try &.["videoSecondaryInfoRenderer"]? - .try &.["owner"]? - .try &.["videoOwnerRenderer"]? + .try &.["videoSecondaryInfoRenderer"]?.try &.["owner"]?.try &.["videoOwnerRenderer"]? - params["author_thumbnail"] = author_info.try &.["thumbnail"]? - .try &.["thumbnails"]? - .try &.as_a[0]? - .try &.["url"]? - .try &.as_s || "" + params["authorThumbnail"] = JSON::Any.new(author_info.try &.["thumbnail"]? + .try &.["thumbnails"]?.try &.as_a[0]?.try &.["url"]? + .try &.as_s || "") - params["sub_count_text"] = author_info.try &.["subscriberCountText"]? - .try &.["simpleText"]? - .try &.as_s.gsub(/\D/, "") || "0" + params["subCountText"] = JSON::Any.new(author_info.try &.["subscriberCountText"]? + .try { |t| t["simpleText"]? || t["runs"]?.try &.[0]?.try &.["text"]? }.try &.as_s.split(" ", 2)[0] || "-") - return params + initial_data = body.match(/ytplayer\.config\s*=\s*(?.*?);ytplayer\.web_player_context_config/) + .try { |r| JSON.parse(r["info"]) }.try &.["args"]["player_response"]? + .try &.as_s?.try &.try { |r| JSON.parse(r).as_h } + + return params if !initial_data + + {"playabilityStatus", "streamingData"}.each do |f| + params[f] = initial_data[f] if initial_data[f]? + end + + params end -def extract_player_config(body, html) - params = HTTP::Params.new - - if md = body.match(/'XSRF_TOKEN': "(?[A-Za-z0-9\_\-\=]+)"/) - params["session_token"] = md["session_token"] - end - - if md = body.match(/'RELATED_PLAYER_ARGS': (?.*?),\n/) - recommended_json = JSON.parse(md["json"]) - rvs_params = recommended_json["rvs"].as_s.split(",").map { |params| HTTP::Params.parse(params) } - - if watch_next_response = recommended_json["watch_next_response"]? - watch_next_json = JSON.parse(watch_next_response.as_s) - rvs = watch_next_json["contents"]? - .try &.["twoColumnWatchNextResults"]? - .try &.["secondaryResults"]? - .try &.["secondaryResults"]? - .try &.["results"]? - .try &.as_a - - rvs = extract_recommended(rvs).compact_map do |rv| - if !rv["short_view_count_text"]? - rv_params = rvs_params.select { |rv_params| rv_params["id"]? == (rv["id"]? || "") }[0]? - - if rv_params.try &.["short_view_count_text"]? - rv["short_view_count_text"] = rv_params.not_nil!["short_view_count_text"] - rv - else - nil - end - else - rv - end +def get_video(id, db, refresh = true, region = nil, force_refresh = false) + if (video = db.query_one?("SELECT * FROM videos WHERE id = $1", id, as: Video)) && !region + # If record was last updated over 10 minutes ago, or video has since premiered, + # refresh (expire param in response lasts for 6 hours) + if (refresh && + (Time.utc - video.updated > 10.minutes) || + (video.premiere_timestamp.try &.< Time.utc)) || + force_refresh + begin + video = fetch_video(id, region) + db.exec("UPDATE videos SET (id, info, updated) = ($1, $2, $3) WHERE id = $1", video.id, video.info.to_json, video.updated) + rescue ex + db.exec("DELETE FROM videos * WHERE id = $1", id) + raise ex end - params["rvs"] = (rvs.map &.to_s).join(",") - end - end - - html_info = body.match(/ytplayer\.config = (?.*?);ytplayer\.load/).try &.["info"] - - if html_info - JSON.parse(html_info)["args"].as_h.each do |key, value| - params[key] = value.to_s end else - error_message = html.xpath_node(%q(//h1[@id="unavailable-message"])) - if error_message - params["reason"] = error_message.content.strip - elsif body.includes?("To continue with your YouTube experience, please fill out the form below.") || - body.includes?("https://www.google.com/sorry/index") - params["reason"] = "Could not extract video info. Instance is likely blocked." - else - params["reason"] = "Video unavailable." + video = fetch_video(id, region) + if !region + db.exec("INSERT INTO videos VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING", video.id, video.info.to_json, video.updated) end end - return params + return video end def fetch_video(id, region) - response = YT_POOL.client(region, &.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999")) + response = YT_POOL.client(region, &.get("/watch?v=#{id}&gl=US&hl=en&has_verified=1&bpctr=9999999999")) if md = response.headers["location"]?.try &.match(/v=(?[a-zA-Z0-9_-]{11})/) raise VideoRedirect.new(video_id: md["id"]) end - html = XML.parse_html(response.body) - info = extract_player_config(response.body, html) - info["cookie"] = response.cookies.to_h.map { |name, cookie| "#{name}=#{cookie.value}" }.join("; ") - - allowed_regions = html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).try &.["content"].split(",") - if !allowed_regions || allowed_regions == [""] - allowed_regions = [] of String - end + info = extract_polymer_config(response.body) + info["cookie"] = JSON::Any.new(response.cookies.to_h.transform_values { |v| JSON::Any.new(v.value) }) + allowed_regions = info["microformat"]?.try &.["playerMicroformatRenderer"]["availableCountries"]?.try &.as_a.map &.as_s || [] of String # Check for region-blocks - if info["reason"]? && info["reason"].includes?("your country") + if info["reason"]?.try &.as_s.includes?("your country") bypass_regions = PROXY_LIST.keys & allowed_regions if !bypass_regions.empty? region = bypass_regions[rand(bypass_regions.size)] - response = YT_POOL.client(region, &.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999")) + response = YT_POOL.client(region, &.get("/watch?v=#{id}&gl=US&hl=en&has_verified=1&bpctr=9999999999")) - html = XML.parse_html(response.body) - info = extract_player_config(response.body, html) - - info["region"] = region if region - info["cookie"] = response.cookies.to_h.map { |name, cookie| "#{name}=#{cookie.value}" }.join("; ") + region_info = extract_polymer_config(response.body) + region_info["region"] = JSON::Any.new(region) if region + region_info["cookie"] = JSON::Any.new(response.cookies.to_h.transform_values { |v| JSON::Any.new(v.value) }) + info = region_info if !region_info["reason"]? end end # Try to pull streams from embed URL if info["reason"]? embed_page = YT_POOL.client &.get("/embed/#{id}").body - sts = embed_page.match(/"sts"\s*:\s*(?\d+)/).try &.["sts"]? - sts ||= "" - embed_info = HTTP::Params.parse(YT_POOL.client &.get("/get_video_info?video_id=#{id}&eurl=https://youtube.googleapis.com/v/#{id}&gl=US&hl=en&disable_polymer=1&sts=#{sts}").body) + sts = embed_page.match(/"sts"\s*:\s*(?\d+)/).try &.["sts"]? || "" + embed_info = HTTP::Params.parse(YT_POOL.client &.get("/get_video_info?html5=1&video_id=#{id}&eurl=https://youtube.googleapis.com/v/#{id}&gl=US&hl=en&sts=#{sts}").body) - if !embed_info["reason"]? - embed_info.each do |key, value| - info[key] = value.to_s + if embed_info["player_response"]? + player_response = JSON.parse(embed_info["player_response"]) + {"captions", "microformat", "playabilityStatus", "streamingData", "videoDetails", "storyboards"}.each do |f| + info[f] = player_response[f] if player_response[f]? end - else - raise info["reason"] end + + initial_data = JSON.parse(embed_info["watch_next_response"]) if embed_info["watch_next_response"]? + + info["relatedVideos"] = initial_data.try &.["playerOverlays"]?.try &.["playerOverlayRenderer"]? + .try &.["endScreen"]?.try &.["watchNextEndScreenRenderer"]?.try &.["results"]?.try &.as_a.compact_map { |r| + parse_related r + }.try { |a| JSON::Any.new(a) } || embed_info["rvs"]?.try &.split(",").map { |r| + r = HTTP::Params.parse(r).to_h + JSON::Any.new(Hash.zip(r.keys, r.values.map { |v| JSON::Any.new(v) })) + }.try { |a| JSON::Any.new(a) } || JSON::Any.new([] of JSON::Any) end - if info["reason"]? && !info["player_response"]? - raise info["reason"] - end - - player_json = JSON.parse(info["player_response"]) - if reason = player_json["playabilityStatus"]?.try &.["reason"]?.try &.as_s - raise reason - end - - title = player_json["videoDetails"]["title"].as_s - author = player_json["videoDetails"]["author"]?.try &.as_s || "" - ucid = player_json["videoDetails"]["channelId"]?.try &.as_s || "" - - info["premium"] = html.xpath_node(%q(.//span[text()="Premium"])) ? "true" : "false" - - views = html.xpath_node(%q(//meta[@itemprop="interactionCount"])) - .try &.["content"].to_i64? || 0_i64 - - likes = html.xpath_node(%q(//button[@title="I like this"]/span)) - .try &.content.delete(",").try &.to_i? || 0 - - dislikes = html.xpath_node(%q(//button[@title="I dislike this"]/span)) - .try &.content.delete(",").try &.to_i? || 0 - - avg_rating = (likes.to_f/(likes.to_f + dislikes.to_f) * 4 + 1) - avg_rating = avg_rating.nan? ? 0.0 : avg_rating - info["avg_rating"] = "#{avg_rating}" - - description_html = html.xpath_node(%q(//p[@id="eow-description"])).try &.to_xml(options: XML::SaveOptions::NO_DECL) || "

" - wilson_score = ci_lower_bound(likes, likes + dislikes) - - published = html.xpath_node(%q(//meta[@itemprop="datePublished"])).try &.["content"] - published ||= Time.utc.to_s("%Y-%m-%d") - published = Time.parse(published, "%Y-%m-%d", Time::Location.local) - - is_family_friendly = html.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).try &.["content"] == "True" - is_family_friendly ||= true - - genre = html.xpath_node(%q(//meta[@itemprop="genre"])).try &.["content"] - genre ||= "" - - genre_url = html.xpath_node(%(//ul[contains(@class, "watch-info-tag-list")]/li/a[text()="#{genre}"])).try &.["href"]? - genre_url ||= "" - - # YouTube provides invalid URLs for some genres, so we fix that here - case genre - when "Comedy" - genre_url = "/channel/UCQZ43c4dAA9eXCQuXWu9aTw" - when "Education" - genre_url = "/channel/UCdxpofrI-dO6oYfsqHDHphw" - when "Gaming" - genre_url = "/channel/UCOpNcN46UbXVtpKMrmU4Abg" - when "Movies" - genre_url = "/channel/UClgRkhTL3_hImCAmdLfDE4g" - when "Nonprofits & Activism" - genre_url = "/channel/UCfFyYRYslvuhwMDnx6KjUvw" - when "Trailers" - genre_url = "/channel/UClgRkhTL3_hImCAmdLfDE4g" - else nil # Ignore - end - - license = html.xpath_node(%q(//h4[contains(text(),"License")]/parent::*/ul/li)).try &.content || "" - sub_count_text = html.xpath_node(%q(//span[contains(@class, "subscriber-count")])).try &.["title"]? || "0" - author_thumbnail = html.xpath_node(%(//span[@class="yt-thumb-clip"]/img)).try &.["data-thumb"]?.try &.gsub(/^\/\//, "https://") || "" - - video = Video.new(id, info, Time.utc, title, views, likes, dislikes, wilson_score, published, description_html, - nil, author, ucid, allowed_regions, is_family_friendly, genre, genre_url, license, sub_count_text, author_thumbnail) + raise info["reason"]?.try &.as_s || "" if !info["videoDetails"]? + video = Video.new(id, info, Time.utc) return video end -def itag_to_metadata?(itag : String) - return VIDEO_FORMATS[itag]? +def itag_to_metadata?(itag : JSON::Any) + return VIDEO_FORMATS[itag.to_s]? end def process_continuation(db, query, plid, id) diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index e9baba2c..0c19fc1b 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -85,7 +85,7 @@

- <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp && item.premiere_timestamp.not_nil! > Time.utc %> + <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>
<%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %>
<% elsif Time.utc - item.published > 1.minute %>
<%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %>
@@ -144,7 +144,7 @@

- <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp && item.premiere_timestamp.not_nil! > Time.utc %> + <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>
<%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %>
<% elsif Time.utc - item.published > 1.minute %>
<%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %>
diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index 3c30f69e..6b01d25f 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -3,23 +3,23 @@ <% if params.autoplay %>autoplay<% end %> <% if params.video_loop %>loop<% end %> <% if params.controls %>controls<% end %>> - <% if hlsvp && !CONFIG.disabled?("livestreams") %> - + <% if (hlsvp = video.hls_manifest_url) && !CONFIG.disabled?("livestreams") %> + <% else %> <% if params.listen %> <% audio_streams.each_with_index do |fmt, i| %> - <% if params.local %>&local=true<% end %>" type='<%= fmt["type"] %>' label="<%= fmt["bitrate"] %>k" selected="<%= i == 0 ? true : false %>"> + <% if params.local %>&local=true<% end %>" type='<%= fmt["mimeType"] %>' label="<%= fmt["bitrate"] %>k" selected="<%= i == 0 ? true : false %>"> <% end %> <% else %> <% if params.quality == "dash" %> - + <% end %> <% fmt_stream.each_with_index do |fmt, i| %> <% if params.quality %> - <% if params.local %>&local=true<% end %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= params.quality == fmt["label"].split(" - ")[0] %>"> + <% if params.local %>&local=true<% end %>" type='<%= fmt["mimeType"] %>' label="<%= fmt["quality"] %>" selected="<%= params.quality == fmt["quality"] %>"> <% else %> - <% if params.local %>&local=true<% end %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= i == 0 ? true : false %>"> + <% if params.local %>&local=true<% end %>" type='<%= fmt["mimeType"] %>' label="<%= fmt["quality"] %>" selected="<%= i == 0 ? true : false %>"> <% end %> <% end %> <% end %> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index ae6341e0..9a1e6c32 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -33,8 +33,8 @@ "index" => continuation, "plid" => plid, "length_seconds" => video.length_seconds.to_f, - "play_next" => !rvs.empty? && !plid && params.continue, - "next_video" => rvs.select { |rv| rv["id"]? }[0]?.try &.["id"], + "play_next" => !video.related_videos.empty? && !plid && params.continue, + "next_video" => video.related_videos.select { |rv| rv["id"]? }[0]?.try &.["id"], "youtube_comments_text" => HTML.escape(translate(locale, "View YouTube comments")), "reddit_comments_text" => HTML.escape(translate(locale, "View Reddit comments")), "reddit_permalink_text" => HTML.escape(translate(locale, "View more comments on Reddit")), @@ -72,13 +72,13 @@
<% end %> - <% if !reason.empty? %> + <% if video.reason %>

- <%= reason %> + <%= video.reason %>

- <% elsif video.premiere_timestamp %> + <% elsif video.premiere_timestamp.try &.> Time.utc %>

- <%= translate(locale, "Premieres in `x`", recode_date((video.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %> + <%= video.premiere_timestamp.try { |t| translate(locale, "Premieres in `x`", recode_date((t - Time.utc).ago, locale)) } %>

<% end %>
@@ -137,18 +137,18 @@ <% {"", "youtube", "reddit"}.each do |option| %> - + <% end %> <% end %> @@ -79,7 +79,7 @@ <% preferences.captions.each_with_index do |caption, index| %> <% end %> @@ -119,7 +119,7 @@
@@ -139,7 +139,7 @@
@@ -149,7 +149,7 @@ <% (feed_options.size - 1).times do |index| %> <% end %> @@ -211,7 +211,7 @@
@@ -221,7 +221,7 @@ <% (feed_options.size - 1).times do |index| %> <% end %> From de777907f2835dc79cb1955fa623928ae3a47aaa Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Mon, 16 Nov 2020 04:19:41 +0100 Subject: [PATCH 0093/2881] Apply dark theme immediately Themes are now controlled with a class on the body element. If a preference is set the body element will have either "dark-theme" or "light-theme" class. If no preference is set or the preference is empty the class will be "no-theme". "dark-theme" and "light-theme" are handled by darktheme.css and lighttheme.css respectively. "no-theme" is handled by default.css where depending on the value of "prefers-color-scheme" the styles corresponding to "dark-theme" or "light-theme" are applied. Unfortunately this means that both themes are duplicated, once in the theme .css and once in default.css. --- assets/css/darktheme.css | 28 ++++++++------ assets/css/default.css | 65 ++++++++++++++++++++++++++++++++ assets/css/lighttheme.css | 18 ++++++--- assets/js/themes.js | 15 +++++--- src/invidious/views/template.ecr | 7 ++-- 5 files changed, 108 insertions(+), 25 deletions(-) diff --git a/assets/css/darktheme.css b/assets/css/darktheme.css index 92da15b6..45a267cd 100644 --- a/assets/css/darktheme.css +++ b/assets/css/darktheme.css @@ -1,37 +1,43 @@ -a:hover, -a:active { +/* + * Dark theme + * Same as (prefers-color-scheme: dark) in default.css + */ + +.dark-theme a:hover, +.dark-theme a:active { color: rgb(0, 182, 240); } -a { +.dark-theme a { color: #a0a0a0; text-decoration: none; } -body { +body.dark-theme { background-color: rgba(35, 35, 35, 1); color: #f0f0f0; } -.pure-form legend { +.dark-theme .pure-form legend { color: #f0f0f0; } -.pure-menu-heading { +.dark-theme .pure-menu-heading { color: #f0f0f0; } -input, -select, -textarea { +.dark-theme input, +.dark-theme select, +.dark-theme textarea { color: rgba(35, 35, 35, 1); } -.pure-form input[type="file"] { +.dark-theme .pure-form input[type="file"] { color: #f0f0f0; } -.navbar > .searchbar input { +.dark-theme .navbar > .searchbar input { background-color: inherit; color: inherit; } + diff --git a/assets/css/default.css b/assets/css/default.css index b7a77be6..ccdd7660 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -485,3 +485,68 @@ video.video-js { margin-top: -0.81666em; margin-left: -1.5em; } + +/* + * Automatic theme support + * Same as lighttheme.css and darktheme.css + */ + +@media (prefers-color-scheme: light) { + .no-theme a:hover, + .no-theme a:active { + color: #167ac6 !important; + } + + .no-theme a { + color: #61809b; + text-decoration: none; + } + + /* All links that do not fit with the default color goes here */ + .no-theme a:not([data-id]) > .icon, + .no-theme .pure-u-lg-1-5 > .h-box > a[href^="/watch?"], + .no-theme .playlist-restricted > ol > li > a { + color: #303030; + } +} + +@media (prefers-color-scheme: dark) { + .no-theme a:hover, + .no-theme a:active { + color: rgb(0, 182, 240); + } + + .no-theme a { + color: #a0a0a0; + text-decoration: none; + } + + body.no-theme { + background-color: rgba(35, 35, 35, 1); + color: #f0f0f0; + } + + .no-theme .pure-form legend { + color: #f0f0f0; + } + + .no-theme .pure-menu-heading { + color: #f0f0f0; + } + + .no-theme input, + .no-theme select, + .no-theme textarea { + color: rgba(35, 35, 35, 1); + } + + .no-theme .pure-form input[type="file"] { + color: #f0f0f0; + } + + .no-theme .navbar > .searchbar input { + background-color: inherit; + color: inherit; + } +} + diff --git a/assets/css/lighttheme.css b/assets/css/lighttheme.css index 73706bb7..7452180b 100644 --- a/assets/css/lighttheme.css +++ b/assets/css/lighttheme.css @@ -1,16 +1,22 @@ -a:hover, -a:active { +/* + * Light theme + * Same as (prefers-color-scheme: light) in default.css + */ + +.light-theme a:hover, +.light-theme a:active { color: #167ac6 !important; } -a { +.light-theme a { color: #61809b; text-decoration: none; } /* All links that do not fit with the default color goes here */ -a:not([data-id]) > .icon, -.pure-u-lg-1-5 > .h-box > a[href^="/watch?"], -.playlist-restricted > ol > li > a { +.light-theme a:not([data-id]) > .icon, +.light-theme .pure-u-lg-1-5 > .h-box > a[href^="/watch?"], +.light-theme .playlist-restricted > ol > li > a { color: #303030; } + diff --git a/assets/js/themes.js b/assets/js/themes.js index c600073d..543b849e 100644 --- a/assets/js/themes.js +++ b/assets/js/themes.js @@ -2,7 +2,7 @@ var toggle_theme = document.getElementById('toggle_theme'); toggle_theme.href = 'javascript:void(0);'; toggle_theme.addEventListener('click', function () { - var dark_mode = document.getElementById('dark_theme').media === 'none'; + var dark_mode = document.body.classList.contains("light-theme"); var url = '/toggle_theme?redirect=false'; var xhr = new XMLHttpRequest(); @@ -22,7 +22,7 @@ window.addEventListener('storage', function (e) { } }); -window.addEventListener('load', function () { +window.addEventListener('DOMContentLoaded', function () { window.localStorage.setItem('dark_mode', document.getElementById('dark_mode_pref').textContent); // Update localStorage if dark mode preference changed on preferences page update_mode(window.localStorage.dark_mode); @@ -50,13 +50,18 @@ function scheme_switch (e) { } function set_mode (bool) { - document.getElementById('dark_theme').media = !bool ? 'none' : ''; - document.getElementById('light_theme').media = bool ? 'none' : ''; - if (bool) { + // dark toggle_theme.children[0].setAttribute('class', 'icon ion-ios-sunny'); + document.body.classList.remove('no-theme'); + document.body.classList.remove('light-theme'); + document.body.classList.add('dark-theme'); } else { + // light toggle_theme.children[0].setAttribute('class', 'icon ion-ios-moon'); + document.body.classList.remove('no-theme'); + document.body.classList.remove('dark-theme'); + document.body.classList.add('light-theme'); } } diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 61cf5c3a..558f896e 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -18,13 +18,14 @@ - media="none"<% end %>> - media="none"<% end %>> + + <% locale = LOCALES[env.get("preferences").as(Preferences).locale]? %> +<% dark_mode = env.get("preferences").as(Preferences).dark_mode %> - +-theme">
From ff46c1816483472c2b478a570dceb3b75be6e783 Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Tue, 17 Nov 2020 22:53:45 +0100 Subject: [PATCH 0094/2881] Move themes into default.css Now that themes are controlled with a class instead of setting media="none" on the stylesheet link and both themes already being duplicated in default.css for the automatic themeing it makes sense to have all theme related CSS in the same place. This commit also fixes the missing dark theme on embeds. --- assets/css/darktheme.css | 43 ---------------------- assets/css/default.css | 62 ++++++++++++++++++++++++++++++-- assets/css/lighttheme.css | 22 ------------ src/invidious/views/embed.ecr | 3 +- src/invidious/views/template.ecr | 2 -- 5 files changed, 61 insertions(+), 71 deletions(-) delete mode 100644 assets/css/darktheme.css delete mode 100644 assets/css/lighttheme.css diff --git a/assets/css/darktheme.css b/assets/css/darktheme.css deleted file mode 100644 index 45a267cd..00000000 --- a/assets/css/darktheme.css +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Dark theme - * Same as (prefers-color-scheme: dark) in default.css - */ - -.dark-theme a:hover, -.dark-theme a:active { - color: rgb(0, 182, 240); -} - -.dark-theme a { - color: #a0a0a0; - text-decoration: none; -} - -body.dark-theme { - background-color: rgba(35, 35, 35, 1); - color: #f0f0f0; -} - -.dark-theme .pure-form legend { - color: #f0f0f0; -} - -.dark-theme .pure-menu-heading { - color: #f0f0f0; -} - -.dark-theme input, -.dark-theme select, -.dark-theme textarea { - color: rgba(35, 35, 35, 1); -} - -.dark-theme .pure-form input[type="file"] { - color: #f0f0f0; -} - -.dark-theme .navbar > .searchbar input { - background-color: inherit; - color: inherit; -} - diff --git a/assets/css/default.css b/assets/css/default.css index ccdd7660..75e16841 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -487,10 +487,26 @@ video.video-js { } /* - * Automatic theme support - * Same as lighttheme.css and darktheme.css + * Light theme */ +.light-theme a:hover, +.light-theme a:active { + color: #167ac6 !important; +} + +.light-theme a { + color: #61809b; + text-decoration: none; +} + +/* All links that do not fit with the default color goes here */ +.light-theme a:not([data-id]) > .icon, +.light-theme .pure-u-lg-1-5 > .h-box > a[href^="/watch?"], +.light-theme .playlist-restricted > ol > li > a { + color: #303030; +} + @media (prefers-color-scheme: light) { .no-theme a:hover, .no-theme a:active { @@ -510,6 +526,48 @@ video.video-js { } } +/* + * Dark theme + */ + +.dark-theme a:hover, +.dark-theme a:active { + color: rgb(0, 182, 240); +} + +.dark-theme a { + color: #a0a0a0; + text-decoration: none; +} + +body.dark-theme { + background-color: rgba(35, 35, 35, 1); + color: #f0f0f0; +} + +.dark-theme .pure-form legend { + color: #f0f0f0; +} + +.dark-theme .pure-menu-heading { + color: #f0f0f0; +} + +.dark-theme input, +.dark-theme select, +.dark-theme textarea { + color: rgba(35, 35, 35, 1); +} + +.dark-theme .pure-form input[type="file"] { + color: #f0f0f0; +} + +.dark-theme .navbar > .searchbar input { + background-color: inherit; + color: inherit; +} + @media (prefers-color-scheme: dark) { .no-theme a:hover, .no-theme a:active { diff --git a/assets/css/lighttheme.css b/assets/css/lighttheme.css deleted file mode 100644 index 7452180b..00000000 --- a/assets/css/lighttheme.css +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Light theme - * Same as (prefers-color-scheme: light) in default.css - */ - -.light-theme a:hover, -.light-theme a:active { - color: #167ac6 !important; -} - -.light-theme a { - color: #61809b; - text-decoration: none; -} - -/* All links that do not fit with the default color goes here */ -.light-theme a:not([data-id]) > .icon, -.light-theme .pure-u-lg-1-5 > .h-box > a[href^="/watch?"], -.light-theme .playlist-restricted > ol > li > a { - color: #303030; -} - diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr index 48dbc55f..dbb86009 100644 --- a/src/invidious/views/embed.ecr +++ b/src/invidious/views/embed.ecr @@ -9,12 +9,11 @@ - <%= HTML.escape(video.title) %> - Invidious - + From 420ceffbb0cebed6c7b7d976828d6619f2aefe9d Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Sun, 27 Dec 2020 05:14:33 +0100 Subject: [PATCH 0157/2881] Rename threads to fibers The config and command line options haven't been changed. --- src/invidious/jobs/refresh_channels_job.cr | 16 ++++++++-------- src/invidious/jobs/refresh_feeds_job.cr | 10 +++++----- src/invidious/jobs/subscribe_to_feeds_job.cr | 12 ++++++------ 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index bbf55ff3..f4dee063 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -7,9 +7,9 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob end def begin - max_threads = config.channel_threads - lim_threads = max_threads - active_threads = 0 + max_fibers = config.channel_threads + lim_fibers = max_fibers + active_fibers = 0 active_channel = Channel(Bool).new backoff = 1.seconds @@ -19,26 +19,26 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob rs.each do id = rs.read(String) - if active_threads >= lim_threads + if active_fibers >= lim_fibers if active_channel.receive - active_threads -= 1 + active_fibers -= 1 end end - active_threads += 1 + active_fibers += 1 spawn do begin logger.trace("RefreshChannelsJob: Fetching channel #{id}") channel = fetch_channel(id, db, config.full_refresh) - lim_threads = max_threads + lim_fibers = max_fibers db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.utc, channel.author, id) rescue ex logger.error("RefreshChannelsJob: #{id} : #{ex.message}") if ex.message == "Deleted or invalid channel" db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.utc, id) else - lim_threads = 1 + lim_fibers = 1 logger.error("RefreshChannelsJob: #{id} : backing off for #{backoff}s") sleep backoff if backoff < 1.days diff --git a/src/invidious/jobs/refresh_feeds_job.cr b/src/invidious/jobs/refresh_feeds_job.cr index 5dd47639..208569b8 100644 --- a/src/invidious/jobs/refresh_feeds_job.cr +++ b/src/invidious/jobs/refresh_feeds_job.cr @@ -7,8 +7,8 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob end def begin - max_threads = config.feed_threads - active_threads = 0 + max_fibers = config.feed_threads + active_fibers = 0 active_channel = Channel(Bool).new loop do @@ -17,13 +17,13 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob email = rs.read(String) view_name = "subscriptions_#{sha256(email)}" - if active_threads >= max_threads + if active_fibers >= max_fibers if active_channel.receive - active_threads -= 1 + active_fibers -= 1 end end - active_threads += 1 + active_fibers += 1 spawn do begin # Drop outdated views diff --git a/src/invidious/jobs/subscribe_to_feeds_job.cr b/src/invidious/jobs/subscribe_to_feeds_job.cr index 3bb31299..2255730d 100644 --- a/src/invidious/jobs/subscribe_to_feeds_job.cr +++ b/src/invidious/jobs/subscribe_to_feeds_job.cr @@ -8,12 +8,12 @@ class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob end def begin - max_threads = 1 + max_fibers = 1 if config.use_pubsub_feeds.is_a?(Int32) - max_threads = config.use_pubsub_feeds.as(Int32) + max_fibers = config.use_pubsub_feeds.as(Int32) end - active_threads = 0 + active_fibers = 0 active_channel = Channel(Bool).new loop do @@ -21,13 +21,13 @@ class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob rs.each do ucid = rs.read(String) - if active_threads >= max_threads.as(Int32) + if active_fibers >= max_fibers.as(Int32) if active_channel.receive - active_threads -= 1 + active_fibers -= 1 end end - active_threads += 1 + active_fibers += 1 spawn do begin From c4ef055248781b36c57d99b97e798673a097ea73 Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Sun, 27 Dec 2020 05:20:33 +0100 Subject: [PATCH 0158/2881] Add RefreshChannelsJob traces Traces can be enabled with `-l trace`. The problem with subscriptions is that sometimes requests to YouTube never finish. As soon as that happens `channel-threads` times subscriptions stop being refreshed. This is most likely a problem with the lsquick bindings. --- src/invidious.cr | 20 ++++++------- src/invidious/channels.cr | 33 ++++++++++++++++------ src/invidious/jobs/refresh_channels_job.cr | 17 +++++++---- src/invidious/routes/login.cr | 2 +- src/invidious/users.cr | 10 +++---- 5 files changed, 53 insertions(+), 29 deletions(-) diff --git a/src/invidious.cr b/src/invidious.cr index 91798129..7184145a 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -256,7 +256,7 @@ before_all do |env| headers["Cookie"] = env.request.headers["Cookie"] begin - user, sid = get_user(sid, headers, PG_DB, false) + user, sid = get_user(sid, headers, PG_DB, logger, false) csrf_token = generate_response(sid, { ":authorize_token", ":playlist_ajax", @@ -526,7 +526,7 @@ post "/subscription_ajax" do |env| case action when "action_create_subscription_to_channel" if !user.subscriptions.includes? channel_id - get_channel(channel_id, PG_DB, false, false) + get_channel(channel_id, PG_DB, logger, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions, $1) WHERE email = $2", channel_id, email) end when "action_remove_subscriptions" @@ -561,7 +561,7 @@ get "/subscription_manager" do |env| headers = HTTP::Headers.new headers["Cookie"] = env.request.headers["Cookie"] - user, sid = get_user(sid, headers, PG_DB) + user, sid = get_user(sid, headers, PG_DB, logger) end action_takeout = env.params.query["action_takeout"]?.try &.to_i? @@ -685,7 +685,7 @@ post "/data_control" do |env| user.subscriptions += body["subscriptions"].as_a.map { |a| a.as_s } user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) end @@ -754,7 +754,7 @@ post "/data_control" do |env| end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) when "import_freetube" @@ -763,7 +763,7 @@ post "/data_control" do |env| end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) when "import_newpipe_subscriptions" @@ -782,7 +782,7 @@ post "/data_control" do |env| end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) when "import_newpipe" @@ -801,7 +801,7 @@ post "/data_control" do |env| user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map { |url| url.lchop("https://www.youtube.com/channel/") } user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) @@ -1197,7 +1197,7 @@ get "/feed/subscriptions" do |env| headers["Cookie"] = env.request.headers["Cookie"] if !user.password - user, sid = get_user(sid, headers, PG_DB) + user, sid = get_user(sid, headers, PG_DB, logger) end max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) @@ -2811,7 +2811,7 @@ post "/api/v1/auth/subscriptions/:ucid" do |env| ucid = env.params.url["ucid"] if !user.subscriptions.includes? ucid - get_channel(ucid, PG_DB, false, false) + get_channel(ucid, PG_DB, logger, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email) end diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr index 444a6eda..659dc599 100644 --- a/src/invidious/channels.cr +++ b/src/invidious/channels.cr @@ -144,7 +144,7 @@ class ChannelRedirect < Exception end end -def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10) +def get_batch_channels(channels, db, logger, refresh = false, pull_all_videos = true, max_threads = 10) finished_channel = Channel(String | Nil).new spawn do @@ -160,7 +160,7 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma active_threads += 1 spawn do begin - get_channel(ucid, db, refresh, pull_all_videos) + get_channel(ucid, db, logger, refresh, pull_all_videos) finished_channel.send(ucid) rescue ex finished_channel.send(nil) @@ -181,10 +181,10 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma return final end -def get_channel(id, db, refresh = true, pull_all_videos = true) +def get_channel(id, db, logger, refresh = true, pull_all_videos = true) if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel) if refresh && Time.utc - channel.updated > 10.minutes - channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) + channel = fetch_channel(id, db, logger, pull_all_videos: pull_all_videos) channel_array = channel.to_a args = arg_array(channel_array) @@ -192,7 +192,7 @@ def get_channel(id, db, refresh = true, pull_all_videos = true) ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", args: channel_array) end else - channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) + channel = fetch_channel(id, db, logger, pull_all_videos: pull_all_videos) channel_array = channel.to_a args = arg_array(channel_array) @@ -202,8 +202,12 @@ def get_channel(id, db, refresh = true, pull_all_videos = true) return channel end -def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) +def fetch_channel(ucid, db, logger, pull_all_videos = true, locale = nil) + logger.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}") + + logger.trace("fetch_channel: #{ucid} : Downloading RSS feed") rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body + logger.trace("fetch_channel: #{ucid} : Parsing RSS feed") rss = XML.parse_html(rss) author = rss.xpath_node(%q(//feed/title)) @@ -219,14 +223,19 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) auto_generated = true end + logger.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}") + page = 1 + logger.trace("fetch_channel: #{ucid} : Downloading channel videos page") response = get_channel_videos_response(ucid, page, auto_generated: auto_generated) videos = [] of SearchVideo begin initial_data = JSON.parse(response.body).as_a.find &.["response"]? raise InfoException.new("Could not extract channel JSON") if !initial_data + + logger.trace("fetch_channel: #{ucid} : Extracting videos from channel videos page initial_data") videos = extract_videos(initial_data.as_h, author, ucid) rescue ex if response.body.includes?("To continue with your YouTube experience, please fill out the form below.") || @@ -236,6 +245,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) raise ex end + logger.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") rss.xpath_nodes("//feed/entry").each do |entry| video_id = entry.xpath_node("videoid").not_nil!.content title = entry.xpath_node("title").not_nil!.content @@ -269,6 +279,8 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) views: views, }) + logger.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video") + # We don't include the 'premiere_timestamp' here because channel pages don't include them, # meaning the above timestamp is always null was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ @@ -276,8 +288,13 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) updated = $4, ucid = $5, author = $6, length_seconds = $7, \ live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) - db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert + if was_insert + logger.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") + db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ + feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) + else + logger.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") + end end if pull_all_videos diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index f4dee063..6c858afa 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -20,18 +20,23 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob id = rs.read(String) if active_fibers >= lim_fibers + logger.trace("RefreshChannelsJob: Fiber limit reached, waiting...") if active_channel.receive + logger.trace("RefreshChannelsJob: Fiber limit ok, continuing") active_fibers -= 1 end end + logger.trace("RefreshChannelsJob: #{id} : Spawning fiber") active_fibers += 1 spawn do begin - logger.trace("RefreshChannelsJob: Fetching channel #{id}") - channel = fetch_channel(id, db, config.full_refresh) + logger.trace("RefreshChannelsJob: #{id} fiber : Fetching channel") + channel = fetch_channel(id, db, logger, config.full_refresh) lim_fibers = max_fibers + + logger.trace("RefreshChannelsJob: #{id} fiber : Updating DB") db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.utc, channel.author, id) rescue ex logger.error("RefreshChannelsJob: #{id} : #{ex.message}") @@ -39,7 +44,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.utc, id) else lim_fibers = 1 - logger.error("RefreshChannelsJob: #{id} : backing off for #{backoff}s") + logger.error("RefreshChannelsJob: #{id} fiber : backing off for #{backoff}s") sleep backoff if backoff < 1.days backoff += backoff @@ -47,13 +52,15 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob backoff = 1.days end end + ensure + logger.trace("RefreshChannelsJob: #{id} fiber : Done") + active_channel.send(true) end - - active_channel.send(true) end end end + logger.debug("RefreshChannelsJob: Done, sleeping for one minute") sleep 1.minute Fiber.yield end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 45a6d4d8..42fb4676 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -267,7 +267,7 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute raise "Couldn't get SID." end - user, sid = get_user(sid, headers, PG_DB) + user, sid = get_user(sid, headers, PG_DB, logger) # We are now logged in traceback << "done.
" diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 6a3ca5c1..38767a23 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -269,12 +269,12 @@ struct Preferences end end -def get_user(sid, headers, db, refresh = true) +def get_user(sid, headers, db, logger, refresh = true) if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User) if refresh && Time.utc - user.updated > 1.minute - user, sid = fetch_user(sid, headers, db) + user, sid = fetch_user(sid, headers, db, logger) user_array = user.to_a user_array[4] = user_array[4].to_json # User preferences args = arg_array(user_array) @@ -292,7 +292,7 @@ def get_user(sid, headers, db, refresh = true) end end else - user, sid = fetch_user(sid, headers, db) + user, sid = fetch_user(sid, headers, db, logger) user_array = user.to_a user_array[4] = user_array[4].to_json # User preferences args = arg_array(user.to_a) @@ -313,7 +313,7 @@ def get_user(sid, headers, db, refresh = true) return user, sid end -def fetch_user(sid, headers, db) +def fetch_user(sid, headers, db, logger) feed = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) feed = XML.parse_html(feed.body) @@ -326,7 +326,7 @@ def fetch_user(sid, headers, db) end end - channels = get_batch_channels(channels, db, false, false) + channels = get_batch_channels(channels, db, logger, false, false) email = feed.xpath_node(%q(//a[@class="yt-masthead-picker-header yt-masthead-picker-active-account"])) if email From 198dfffaeb91c03889440a9f8141657bd53734d6 Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Sun, 27 Dec 2020 06:12:43 +0100 Subject: [PATCH 0159/2881] Add `popular-enabled` option This is similar to the removed `top-enabled` option but for the Popular feed. The instance needs to be restarted if the feed was enabled. Editing admin options on the preferences page is also fixed. The handling of the feed pages now only happens in a single place. Instead of redirecting: - The Top feed now displays a message that it was removed from Invidious. - The Popular feed now displays a message that it was disabled if it was. --- src/invidious.cr | 25 ++++++++++++++++++++---- src/invidious/helpers/helpers.cr | 3 ++- src/invidious/routes/home.cr | 12 +++--------- src/invidious/routes/user_preferences.cr | 4 ++++ src/invidious/views/message.ecr | 12 ++++++++++++ src/invidious/views/preferences.ecr | 6 ++++++ 6 files changed, 48 insertions(+), 14 deletions(-) create mode 100644 src/invidious/views/message.ecr diff --git a/src/invidious.cr b/src/invidious.cr index 40acd9df..af36e25c 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -162,13 +162,16 @@ end Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB, logger, config) Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB, logger, config) Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, logger, config, HMAC_KEY) -Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) Invidious::Jobs.register Invidious::Jobs::UpdateDecryptFunctionJob.new if config.statistics_enabled Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, config, SOFTWARE) end +if config.popular_enabled + Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) +end + if config.captcha_key Invidious::Jobs.register Invidious::Jobs::BypassCaptchaJob.new(logger, config) end @@ -1140,13 +1143,20 @@ end get "/feed/top" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? - env.redirect "/" + + message = translate(locale, "The Top feed has been removed from Invidious.") + templated "message" end get "/feed/popular" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? - templated "popular" + if config.popular_enabled + templated "popular" + else + message = translate(locale, "The Popular feed has been disabled by the administrator.") + templated "message" + end end get "/feed/trending" do |env| @@ -2210,6 +2220,12 @@ get "/api/v1/popular" do |env| env.response.content_type = "application/json" + if !config.popular_enabled + error_message = {"error" => "Administrator has disabled this endpoint."}.to_json + env.response.status_code = 400 + next error_message + end + JSON.build do |json| json.array do popular_videos.each do |video| @@ -2223,7 +2239,8 @@ get "/api/v1/top" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? env.response.content_type = "application/json" - "[]" + env.response.status_code = 400 + {"error" => "The Top feed has been removed from Invidious."}.to_json end get "/api/v1/channels/:ucid" do |env| diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index dc68fb5c..29fd6a86 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -60,7 +60,7 @@ struct ConfigPreferences end end -struct Config +class Config include YAML::Serializable property channel_threads : Int32 # Number of threads to use for crawling videos from channels (for updating subscriptions) @@ -71,6 +71,7 @@ struct Config property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions property domain : String? # Domain to be used for links to resources on the site where an absolute URL is required property use_pubsub_feeds : Bool | Int32 = false # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) + property popular_enabled : Bool = true property captcha_enabled : Bool = true property login_enabled : Bool = true property registration_enabled : Bool = true diff --git a/src/invidious/routes/home.cr b/src/invidious/routes/home.cr index 9b1bf61b..486a7344 100644 --- a/src/invidious/routes/home.cr +++ b/src/invidious/routes/home.cr @@ -5,30 +5,24 @@ class Invidious::Routes::Home < Invidious::Routes::BaseRoute user = env.get? "user" case preferences.default_home - when "" - templated "empty" when "Popular" - templated "popular" + env.redirect "/feed/popular" when "Trending" env.redirect "/feed/trending" when "Subscriptions" if user env.redirect "/feed/subscriptions" else - templated "popular" + env.redirect "/feed/popular" end when "Playlists" if user env.redirect "/view_all_playlists" else - templated "popular" + env.redirect "/feed/popular" end else templated "empty" end end - - private def popular_videos - Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get - end end diff --git a/src/invidious/routes/user_preferences.cr b/src/invidious/routes/user_preferences.cr index 3e4ec330..a0125ed5 100644 --- a/src/invidious/routes/user_preferences.cr +++ b/src/invidious/routes/user_preferences.cr @@ -154,6 +154,10 @@ class Invidious::Routes::UserPreferences < Invidious::Routes::BaseRoute end config.default_user_preferences.feed_menu = admin_feed_menu + popular_enabled = env.params.body["popular_enabled"]?.try &.as(String) + popular_enabled ||= "off" + config.popular_enabled = popular_enabled == "on" + captcha_enabled = env.params.body["captcha_enabled"]?.try &.as(String) captcha_enabled ||= "off" config.captcha_enabled = captcha_enabled == "on" diff --git a/src/invidious/views/message.ecr b/src/invidious/views/message.ecr new file mode 100644 index 00000000..8c7bf611 --- /dev/null +++ b/src/invidious/views/message.ecr @@ -0,0 +1,12 @@ +<% content_for "header" do %> +"> + + Invidious + +<% end %> + +<%= rendered "components/feed_menu" %> + +

+ <%= message %> +

diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index aebdab9e..c05a902c 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -227,6 +227,12 @@ <% end %>
+
+ + checked<% end %>> +
+ +
checked<% end %>> From e0d25ff8878c4e9749dc921839a2277aa29f0ebb Mon Sep 17 00:00:00 2001 From: Andrew Zhao Date: Wed, 23 Dec 2020 00:52:23 -0500 Subject: [PATCH 0160/2881] Close http clients after using The crystal http client maintains a keepalive connection to the other server which stays alive for some time. This should be closed if the client instance is not used again to avoid hogging resources --- src/invidious.cr | 18 ++++++++++-------- src/invidious/comments.cr | 2 ++ src/invidious/helpers/proxy.cr | 6 +++++- src/invidious/helpers/utils.cr | 11 ++++++++++- src/invidious/jobs/bypass_captcha_job.cr | 2 ++ src/invidious/users.cr | 2 +- 6 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/invidious.cr b/src/invidious.cr index 91798129..9e6f4797 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -2123,14 +2123,13 @@ get "/api/v1/annotations/:id" do |env| file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml") - client = make_client(ARCHIVE_URL) - location = client.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}") + location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}")) if !location.headers["Location"]? env.response.status_code = location.status_code end - response = make_client(URI.parse(location.headers["Location"])).get(location.headers["Location"]) + response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"])) if response.body.empty? env.response.status_code = 404 @@ -3481,8 +3480,12 @@ get "/videoplayback" do |env| location = URI.parse(response.headers["Location"]) env.response.headers["Access-Control-Allow-Origin"] = "*" - host = "#{location.scheme}://#{location.host}" - client = make_client(URI.parse(host), region) + new_host = "#{location.scheme}://#{location.host}" + if new_host != host + host = new_host + client.close + client = make_client(URI.parse(new_host), region) + end url = "#{location.full_path}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" else @@ -3513,7 +3516,6 @@ get "/videoplayback" do |env| end begin - client = make_client(URI.parse(host), region) client.get(url, headers) do |response| response.headers.each do |key, value| if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) @@ -3554,8 +3556,6 @@ get "/videoplayback" do |env| chunk_end = chunk_start + HTTP_CHUNK_SIZE - 1 end - client = make_client(URI.parse(host), region) - # TODO: Record bytes written so we can restart after a chunk fails while true if !range_end && content_length @@ -3619,6 +3619,7 @@ get "/videoplayback" do |env| if ex.message != "Error reading socket: Connection reset by peer" break else + client.close client = make_client(URI.parse(host), region) end end @@ -3628,6 +3629,7 @@ get "/videoplayback" do |env| first_chunk = false end end + client.close end get "/ggpht/*" do |env| diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 9f9edca0..8849c87f 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -269,6 +269,8 @@ def fetch_reddit_comments(id, sort_by = "confidence") raise InfoException.new("Could not fetch comments") end + client.close + comments = result[1].data.as(RedditListing).children return comments, thread end diff --git a/src/invidious/helpers/proxy.cr b/src/invidious/helpers/proxy.cr index 4f415ba0..7a42ef41 100644 --- a/src/invidious/helpers/proxy.cr +++ b/src/invidious/helpers/proxy.cr @@ -108,7 +108,9 @@ def filter_proxies(proxies) proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port]) client.set_proxy(proxy) - client.head("/").status_code == 200 + status_ok = client.head("/").status_code == 200 + client.close + status_ok rescue ex false end @@ -132,6 +134,7 @@ def get_nova_proxies(country_code = "US") headers["Referer"] = "https://www.proxynova.com/proxy-server-list/country-#{country_code}/" response = client.get("/proxy-server-list/country-#{country_code}/", headers) + client.close document = XML.parse_html(response.body) proxies = [] of {ip: String, port: Int32, score: Float64} @@ -177,6 +180,7 @@ def get_spys_proxies(country_code = "US") } response = client.post("/free-proxy-list/#{country_code}/", headers, form: body) + client.close 20.times do if response.status_code == 200 break diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index bb9a35ea..f068b5f2 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -101,6 +101,15 @@ def make_client(url : URI, region = nil) return client end +def make_client(url : URI, region = nil, &block) + client = make_client(url, region) + begin + yield client + ensure + client.close + end +end + def decode_length_seconds(string) length_seconds = string.gsub(/[^0-9:]/, "").split(":").map &.to_i length_seconds = [0] * (3 - length_seconds.size) + length_seconds @@ -361,7 +370,7 @@ def subscribe_pubsub(topic, key, config) "hub.secret" => key.to_s, } - return make_client(PUBSUB_URL).post("/subscribe", form: body) + return make_client(PUBSUB_URL, &.post("/subscribe", form: body)) end def parse_range(range) diff --git a/src/invidious/jobs/bypass_captcha_job.cr b/src/invidious/jobs/bypass_captcha_job.cr index daba64d5..61f8eaf3 100644 --- a/src/invidious/jobs/bypass_captcha_job.cr +++ b/src/invidious/jobs/bypass_captcha_job.cr @@ -91,6 +91,8 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob }, }.to_json).body) + captcha_client.close + raise response["error"].as_s if response["error"]? task_id = response["taskId"].as_i diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 6a3ca5c1..6bc2acd2 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -427,7 +427,7 @@ def generate_captcha(key, db) end def generate_text_captcha(key, db) - response = make_client(TEXTCAPTCHA_URL).get("/omarroth@protonmail.com.json").body + response = make_client(TEXTCAPTCHA_URL, &.get("/omarroth@protonmail.com.json").body) response = JSON.parse(response) tokens = response["a"].as_a.map do |answer| From dfd5e3001584c49b662f3a99470d63e88053dfc7 Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Tue, 29 Dec 2020 01:22:56 +0100 Subject: [PATCH 0161/2881] Fix Video Mode Button The query params that get edited for `embed_params` are now deep copied instead of shallow copied, preventing the originals from being changed. --- src/invidious/routes/watch.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 7225c17f..a5c05c00 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -30,7 +30,7 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute embed_link = "/embed/#{id}" if env.params.query.size > 1 - embed_params = env.params.query.dup + embed_params = HTTP::Params.parse(env.params.query.to_s) embed_params.delete_all("v") embed_link += "?" embed_link += embed_params.to_s From ac0ed14eae9fb90d29959a3b4a7017e4d7a03de4 Mon Sep 17 00:00:00 2001 From: Andrew Zhao Date: Tue, 29 Dec 2020 17:58:24 -0500 Subject: [PATCH 0162/2881] do not add local to xhr when in videoplayback --- assets/js/player.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/player.js b/assets/js/player.js index fcba43d8..6d143ed0 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -42,7 +42,7 @@ var shareOptions = { } videojs.Hls.xhr.beforeRequest = function(options) { - if (options.uri.indexOf('local=true') === -1) { + if (options.uri.indexOf('videoplayback') === -1 && options.uri.indexOf('local=true') === -1) { options.uri = options.uri + '?local=true'; } return options; From 608b9e66f4faa438d38a021c1c1885e789a431e2 Mon Sep 17 00:00:00 2001 From: bopol Date: Wed, 30 Dec 2020 00:27:55 +0100 Subject: [PATCH 0163/2881] fix channel/ID/community endpoint fixes https://github.com/iv-org/invidious/issues/1611 --- src/invidious/channels.cr | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr index 444a6eda..0f493eec 100644 --- a/src/invidious/channels.cr +++ b/src/invidious/channels.cr @@ -634,7 +634,8 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) else video_id = attachment["videoId"].as_s - json.field "title", attachment["title"]["simpleText"].as_s + video_title = attachment["title"]["simpleText"]? || attachment["title"]["runs"]?.try &.[0]?.try &.["text"]? + json.field "title", video_title json.field "videoId", video_id json.field "videoThumbnails" do generate_thumbnails(json, video_id) @@ -656,7 +657,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) json.field "published", published.to_unix json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) - view_count = attachment["viewCountText"]["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64 + view_count = attachment["viewCountText"]?.try &.["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64 json.field "viewCount", view_count json.field "viewCountText", translate(locale, "`x` views", number_to_short_text(view_count)) From c5136ca4d65dd97426101aa68aedc697e3f1df6a Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Sun, 3 Jan 2021 19:23:54 +0100 Subject: [PATCH 0164/2881] Download liblsquic.a from iv-org/liblsquic-static-alpine This only affects Docker installs. Regular builds still use the binary shipped with `lsquic.cr`. --- docker/Dockerfile | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index d93f2868..ce4cc765 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -4,12 +4,7 @@ WORKDIR /invidious COPY ./shard.yml ./shard.yml COPY ./shard.lock ./shard.lock RUN shards install && \ - # TODO: Document build instructions - # See https://github.com/omarroth/boringssl-alpine/blob/master/APKBUILD, - # https://github.com/omarroth/lsquic-alpine/blob/master/APKBUILD, - # https://github.com/omarroth/lsquic.cr/issues/1#issuecomment-631610081 - # for details building static lib - curl -Lo ./lib/lsquic/src/lsquic/ext/liblsquic.a https://omar.yt/lsquic/liblsquic-v2.18.1.a + curl -Lo ./lib/lsquic/src/lsquic/ext/liblsquic.a https://github.com/iv-org/lsquic-static-alpine/releases/download/v2.18.1/liblsquic.a COPY ./src/ ./src/ # TODO: .git folder is required for building – this is destructive. # See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. From 3a2bd4e928382a4fbb70d1f978912c72b8f889fe Mon Sep 17 00:00:00 2001 From: vhuynh3000 Date: Sun, 27 Sep 2020 10:19:44 -0700 Subject: [PATCH 0165/2881] add config to decrypt on demand instead of polling --- src/invidious.cr | 8 +- src/invidious/helpers/helpers.cr | 1 + src/invidious/helpers/signatures.cr | 100 +++++++++++------- .../jobs/update_decrypt_function_job.cr | 12 +-- src/invidious/videos.cr | 4 +- 5 files changed, 74 insertions(+), 51 deletions(-) diff --git a/src/invidious.cr b/src/invidious.cr index 8d4c2e58..dd862e23 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -168,7 +168,11 @@ end Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB, logger, config) Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB, logger, config) Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, logger, config, HMAC_KEY) -Invidious::Jobs.register Invidious::Jobs::UpdateDecryptFunctionJob.new + +DECRYPT_FUNCTION = DecryptFunction.new(CONFIG.decrypt_polling) +if config.decrypt_polling + Invidious::Jobs.register Invidious::Jobs::UpdateDecryptFunctionJob.new(logger) +end if config.statistics_enabled Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, config, SOFTWARE) @@ -191,8 +195,6 @@ def popular_videos Invidious::Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get end -DECRYPT_FUNCTION = Invidious::Jobs::UpdateDecryptFunctionJob::DECRYPT_FUNCTION - before_all do |env| preferences = begin Preferences.from_json(env.request.cookies["PREFS"]?.try &.value || "{}") diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 2da49abb..a6651a31 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -67,6 +67,7 @@ class Config property channel_threads : Int32 # Number of threads to use for crawling videos from channels (for updating subscriptions) property feed_threads : Int32 # Number of threads to use for updating feeds property db : DBConfig # Database configuration + property decrypt_polling : Bool = true # Use polling to keep decryption function up to date property full_refresh : Bool # Used for crawling channels: threads should check all videos uploaded by a channel property https_only : Bool? # Used to tell Invidious it is behind a proxy, so links to resources should be https:// property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index f811500f..d8b1de65 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -1,53 +1,73 @@ alias SigProc = Proc(Array(String), Int32, Array(String)) -def fetch_decrypt_function(id = "CvFH_6DNRCY") - document = YT_POOL.client &.get("/watch?v=#{id}&gl=US&hl=en").body - url = document.match(/src="(?\/s\/player\/[^\/]+\/player_ias[^\/]+\/en_US\/base.js)"/).not_nil!["url"] - player = YT_POOL.client &.get(url).body +struct DecryptFunction + @decrypt_function = [] of {SigProc, Int32} + @decrypt_time = Time.monotonic - function_name = player.match(/^(?[^=]+)=function\(\w\){\w=\w\.split\(""\);[^\. ]+\.[^( ]+/m).not_nil!["name"] - function_body = player.match(/^#{Regex.escape(function_name)}=function\(\w\){(?[^}]+)}/m).not_nil!["body"] - function_body = function_body.split(";")[1..-2] + def initialize(@use_polling = true) + end - var_name = function_body[0][0, 2] - var_body = player.delete("\n").match(/var #{Regex.escape(var_name)}={(?(.*?))};/).not_nil!["body"] + def update_decrypt_function + @decrypt_function = fetch_decrypt_function + end - operations = {} of String => SigProc - var_body.split("},").each do |operation| - op_name = operation.match(/^[^:]+/).not_nil![0] - op_body = operation.match(/\{[^}]+/).not_nil![0] + private def fetch_decrypt_function(id = "CvFH_6DNRCY") + document = YT_POOL.client &.get("/watch?v=#{id}&gl=US&hl=en").body + url = document.match(/src="(?\/s\/player\/[^\/]+\/player_ias[^\/]+\/en_US\/base.js)"/).not_nil!["url"] + player = YT_POOL.client &.get(url).body - case op_body - when "{a.reverse()" - operations[op_name] = ->(a : Array(String), b : Int32) { a.reverse } - when "{a.splice(0,b)" - operations[op_name] = ->(a : Array(String), b : Int32) { a.delete_at(0..(b - 1)); a } - else - operations[op_name] = ->(a : Array(String), b : Int32) { c = a[0]; a[0] = a[b % a.size]; a[b % a.size] = c; a } + function_name = player.match(/^(?[^=]+)=function\(\w\){\w=\w\.split\(""\);[^\. ]+\.[^( ]+/m).not_nil!["name"] + function_body = player.match(/^#{Regex.escape(function_name)}=function\(\w\){(?[^}]+)}/m).not_nil!["body"] + function_body = function_body.split(";")[1..-2] + + var_name = function_body[0][0, 2] + var_body = player.delete("\n").match(/var #{Regex.escape(var_name)}={(?(.*?))};/).not_nil!["body"] + + operations = {} of String => SigProc + var_body.split("},").each do |operation| + op_name = operation.match(/^[^:]+/).not_nil![0] + op_body = operation.match(/\{[^}]+/).not_nil![0] + + case op_body + when "{a.reverse()" + operations[op_name] = ->(a : Array(String), b : Int32) { a.reverse } + when "{a.splice(0,b)" + operations[op_name] = ->(a : Array(String), b : Int32) { a.delete_at(0..(b - 1)); a } + else + operations[op_name] = ->(a : Array(String), b : Int32) { c = a[0]; a[0] = a[b % a.size]; a[b % a.size] = c; a } + end end + + decrypt_function = [] of {SigProc, Int32} + function_body.each do |function| + function = function.lchop(var_name).delete("[].") + + op_name = function.match(/[^\(]+/).not_nil![0] + value = function.match(/\(\w,(?[\d]+)\)/).not_nil!["value"].to_i + + decrypt_function << {operations[op_name], value} + end + + return decrypt_function end - decrypt_function = [] of {SigProc, Int32} - function_body.each do |function| - function = function.lchop(var_name).delete("[].") + def decrypt_signature(fmt : Hash(String, JSON::Any)) + return "" if !fmt["s"]? || !fmt["sp"]? - op_name = function.match(/[^\(]+/).not_nil![0] - value = function.match(/\(\w,(?[\d]+)\)/).not_nil!["value"].to_i + sp = fmt["sp"].as_s + sig = fmt["s"].as_s.split("") + if !@use_polling + now = Time.monotonic + if now - @decrypt_time > 60.seconds || @decrypt_function.size == 0 + @decrypt_function = fetch_decrypt_function + @decrypt_time = Time.monotonic + end + end - decrypt_function << {operations[op_name], value} + @decrypt_function.each do |proc, value| + sig = proc.call(sig, value) + end + + return "&#{sp}=#{sig.join("")}" end - - return decrypt_function -end - -def decrypt_signature(fmt : Hash(String, JSON::Any)) - return "" if !fmt["s"]? || !fmt["sp"]? - - sp = fmt["sp"].as_s - sig = fmt["s"].as_s.split("") - DECRYPT_FUNCTION.each do |proc, value| - sig = proc.call(sig, value) - end - - return "&#{sp}=#{sig.join("")}" end diff --git a/src/invidious/jobs/update_decrypt_function_job.cr b/src/invidious/jobs/update_decrypt_function_job.cr index 5332c672..0a6c09c5 100644 --- a/src/invidious/jobs/update_decrypt_function_job.cr +++ b/src/invidious/jobs/update_decrypt_function_job.cr @@ -1,15 +1,15 @@ class Invidious::Jobs::UpdateDecryptFunctionJob < Invidious::Jobs::BaseJob - DECRYPT_FUNCTION = [] of {SigProc, Int32} + private getter logger : Invidious::LogHandler + + def initialize(@logger) + end def begin loop do begin - decrypt_function = fetch_decrypt_function - DECRYPT_FUNCTION.clear - decrypt_function.each { |df| DECRYPT_FUNCTION << df } + DECRYPT_FUNCTION.update_decrypt_function rescue ex - # TODO: Log error - next + logger.error("UpdateDecryptFunctionJob : #{ex.message}") ensure sleep 1.minute Fiber.yield diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 4a831110..74edc156 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -580,7 +580,7 @@ struct Video s.each do |k, v| fmt[k] = JSON::Any.new(v) end - fmt["url"] = JSON::Any.new("#{fmt["url"]}#{decrypt_signature(fmt)}") + fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}") end fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}") @@ -599,7 +599,7 @@ struct Video s.each do |k, v| fmt[k] = JSON::Any.new(v) end - fmt["url"] = JSON::Any.new("#{fmt["url"]}#{decrypt_signature(fmt)}") + fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}") end fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}") From 8b56a038b7c30cb41dbaf67dd42566d84787d79f Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Mon, 4 Jan 2021 05:24:08 +0100 Subject: [PATCH 0166/2881] Set content type for HTML error helpers This fixes `Unexpected char '<' at 1:1` errors caused by content type mismatch. --- src/invidious/helpers/errors.cr | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index 4487ff8c..2c62d44b 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -26,6 +26,7 @@ def error_template_helper(env : HTTP::Server::Context, config : Config, locale : if exception.is_a?(InfoException) return error_template_helper(env, config, locale, status_code, exception.message || "") end + env.response.content_type = "text/html" env.response.status_code = status_code issue_template = %(Title: `#{exception.message} (#{exception.class})`) issue_template += %(\nDate: `#{Time::Format::ISO_8601_DATE_TIME.format(Time.utc)}`) @@ -43,6 +44,7 @@ def error_template_helper(env : HTTP::Server::Context, config : Config, locale : end def error_template_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String) + env.response.content_type = "text/html" env.response.status_code = status_code error_message = translate(locale, message) return templated "error" From 36e9fb9d6835dc3738e6142d1c91d7a2bd249d35 Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Mon, 4 Jan 2021 05:35:59 +0100 Subject: [PATCH 0167/2881] Fix `watch_videos` endpoint Playlists created by `watch_videos` do not have an author which caused a crash previously. --- src/invidious.cr | 2 ++ src/invidious/playlists.cr | 24 ++++++++++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/invidious.cr b/src/invidious.cr index 8d4c2e58..1d2125cb 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -2621,6 +2621,8 @@ end begin playlist = get_playlist(PG_DB, plid, locale) + rescue ex : InfoException + next error_json(404, ex) rescue ex next error_json(404, "Playlist does not exist.") end diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index d5b41caa..25797a36 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -365,9 +365,13 @@ def fetch_playlist(plid, locale) end initial_data = extract_initial_data(response.body) - playlist_info = initial_data["sidebar"]?.try &.["playlistSidebarRenderer"]?.try &.["items"]?.try &.[0]["playlistSidebarPrimaryInfoRenderer"]? + playlist_sidebar_renderer = initial_data["sidebar"]?.try &.["playlistSidebarRenderer"]?.try &.["items"]? + raise InfoException.new("Could not extract playlistSidebarRenderer.") if !playlist_sidebar_renderer + + playlist_info = playlist_sidebar_renderer[0]["playlistSidebarPrimaryInfoRenderer"]? raise InfoException.new("Could not extract playlist info") if !playlist_info + title = playlist_info["title"]?.try &.["runs"][0]?.try &.["text"]?.try &.as_s || "" desc_item = playlist_info["description"]? @@ -392,14 +396,18 @@ def fetch_playlist(plid, locale) end end - author_info = initial_data["sidebar"]?.try &.["playlistSidebarRenderer"]?.try &.["items"]?.try &.[1]["playlistSidebarSecondaryInfoRenderer"]? - .try &.["videoOwner"]["videoOwnerRenderer"]? + if playlist_sidebar_renderer.size < 2 + author = "" + author_thumbnail = "" + ucid = "" + else + author_info = playlist_sidebar_renderer[1]["playlistSidebarSecondaryInfoRenderer"]?.try &.["videoOwner"]["videoOwnerRenderer"]? + raise InfoException.new("Could not extract author info") if !author_info - raise InfoException.new("Could not extract author info") if !author_info - - author_thumbnail = author_info["thumbnail"]["thumbnails"][0]["url"]?.try &.as_s || "" - author = author_info["title"]["runs"][0]["text"]?.try &.as_s || "" - ucid = author_info["title"]["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"]?.try &.as_s || "" + author = author_info["title"]["runs"][0]["text"]?.try &.as_s || "" + author_thumbnail = author_info["thumbnail"]["thumbnails"][0]["url"]?.try &.as_s || "" + ucid = author_info["title"]["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"]?.try &.as_s || "" + end return Playlist.new({ title: title, From 7a8620a570f1004f7db3f018f68fd0364f77bef6 Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Mon, 4 Jan 2021 16:05:15 +0100 Subject: [PATCH 0168/2881] Add CLI arguments to config file The log level can now be set with `log_level` (accepts ints and strings). The log file can now be set with `output` (also accepts `STDOUT`). --- src/invidious.cr | 20 ++++++++++++-------- src/invidious/helpers/helpers.cr | 8 +++++--- src/invidious/helpers/logger.cr | 16 ++++++++-------- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/invidious.cr b/src/invidious.cr index 5d19acf1..e32cb896 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -107,8 +107,6 @@ LOCALES = { YT_POOL = QUICPool.new(YT_URL, capacity: CONFIG.pool_size, timeout: 2.0) config = CONFIG -output = STDOUT -loglvl = LogLevel::Debug Kemal.config.extra_options do |parser| parser.banner = "Usage: invidious [arguments]" @@ -128,12 +126,11 @@ Kemal.config.extra_options do |parser| exit end end - parser.on("-o OUTPUT", "--output=OUTPUT", "Redirect output (default: STDOUT)") do |output_arg| - FileUtils.mkdir_p(File.dirname(output_arg)) - output = File.open(output_arg, mode: "a") + parser.on("-o OUTPUT", "--output=OUTPUT", "Redirect output (default: #{config.output})") do |output| + config.output = output end - parser.on("-l LEVEL", "--log-level=LEVEL", "Log level, one of #{LogLevel.values} (default: #{loglvl})") do |loglvl_arg| - loglvl = LogLevel.parse(loglvl_arg) + parser.on("-l LEVEL", "--log-level=LEVEL", "Log level, one of #{LogLevel.values} (default: #{config.log_level})") do |log_level| + config.log_level = LogLevel.parse(log_level) end parser.on("-v", "--version", "Print version") do puts SOFTWARE.to_pretty_json @@ -143,7 +140,14 @@ end Kemal::CLI.new ARGV -logger = Invidious::LogHandler.new(output, loglvl) +if config.output.upcase == "STDOUT" + output = STDOUT +else + FileUtils.mkdir_p(File.dirname(config.output)) + output = File.open(config.output, mode: "a") +end + +logger = Invidious::LogHandler.new(output, config.log_level) # Check table integrity if CONFIG.check_tables diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index a6651a31..1866f8e5 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -64,11 +64,13 @@ end class Config include YAML::Serializable - property channel_threads : Int32 # Number of threads to use for crawling videos from channels (for updating subscriptions) - property feed_threads : Int32 # Number of threads to use for updating feeds + property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions) + property feed_threads : Int32 = 1 # Number of threads to use for updating feeds + property output : String = "STDOUT" # Log file path or STDOUT + property log_level : LogLevel = LogLevel::Debug # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr property db : DBConfig # Database configuration property decrypt_polling : Bool = true # Use polling to keep decryption function up to date - property full_refresh : Bool # Used for crawling channels: threads should check all videos uploaded by a channel + property full_refresh : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel property https_only : Bool? # Used to tell Invidious it is behind a proxy, so links to resources should be https:// property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions property domain : String? # Domain to be used for links to resources on the site where an absolute URL is required diff --git a/src/invidious/helpers/logger.cr b/src/invidious/helpers/logger.cr index 4e4d7306..7c5b0247 100644 --- a/src/invidious/helpers/logger.cr +++ b/src/invidious/helpers/logger.cr @@ -1,14 +1,14 @@ require "logger" enum LogLevel - All - Trace - Debug - Info - Warn - Error - Fatal - Off + All = 0 + Trace = 1 + Debug = 2 + Info = 3 + Warn = 4 + Error = 5 + Fatal = 6 + Off = 7 end class Invidious::LogHandler < Kemal::BaseLogHandler From 6365ee7487d86d94f385bfab72a2cc3260cb6690 Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Mon, 4 Jan 2021 16:51:06 +0100 Subject: [PATCH 0169/2881] Make logger a constant Instead of passing around `logger` there is now the global `LOGGER`. --- src/invidious.cr | 84 +++++++++---------- src/invidious/channels.cr | 32 +++---- src/invidious/helpers/helpers.cr | 24 +++--- src/invidious/jobs/bypass_captcha_job.cr | 5 +- src/invidious/jobs/refresh_channels_job.cr | 25 +++--- src/invidious/jobs/refresh_feeds_job.cr | 13 ++- src/invidious/jobs/subscribe_to_feeds_job.cr | 7 +- .../jobs/update_decrypt_function_job.cr | 7 +- src/invidious/routes/base_route.cr | 3 +- src/invidious/routes/login.cr | 2 +- src/invidious/routes/watch.cr | 2 +- src/invidious/routing.cr | 4 +- src/invidious/users.cr | 10 +-- 13 files changed, 103 insertions(+), 115 deletions(-) diff --git a/src/invidious.cr b/src/invidious.cr index e32cb896..2b915350 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -106,31 +106,30 @@ LOCALES = { YT_POOL = QUICPool.new(YT_URL, capacity: CONFIG.pool_size, timeout: 2.0) -config = CONFIG - +# CLI Kemal.config.extra_options do |parser| parser.banner = "Usage: invidious [arguments]" - parser.on("-c THREADS", "--channel-threads=THREADS", "Number of threads for refreshing channels (default: #{config.channel_threads})") do |number| + parser.on("-c THREADS", "--channel-threads=THREADS", "Number of threads for refreshing channels (default: #{CONFIG.channel_threads})") do |number| begin - config.channel_threads = number.to_i + CONFIG.channel_threads = number.to_i rescue ex puts "THREADS must be integer" exit end end - parser.on("-f THREADS", "--feed-threads=THREADS", "Number of threads for refreshing feeds (default: #{config.feed_threads})") do |number| + parser.on("-f THREADS", "--feed-threads=THREADS", "Number of threads for refreshing feeds (default: #{CONFIG.feed_threads})") do |number| begin - config.feed_threads = number.to_i + CONFIG.feed_threads = number.to_i rescue ex puts "THREADS must be integer" exit end end - parser.on("-o OUTPUT", "--output=OUTPUT", "Redirect output (default: #{config.output})") do |output| - config.output = output + parser.on("-o OUTPUT", "--output=OUTPUT", "Redirect output (default: #{CONFIG.output})") do |output| + CONFIG.output = output end - parser.on("-l LEVEL", "--log-level=LEVEL", "Log level, one of #{LogLevel.values} (default: #{config.log_level})") do |log_level| - config.log_level = LogLevel.parse(log_level) + parser.on("-l LEVEL", "--log-level=LEVEL", "Log level, one of #{LogLevel.values} (default: #{CONFIG.log_level})") do |log_level| + CONFIG.log_level = LogLevel.parse(log_level) end parser.on("-v", "--version", "Print version") do puts SOFTWARE.to_pretty_json @@ -140,42 +139,41 @@ end Kemal::CLI.new ARGV -if config.output.upcase == "STDOUT" - output = STDOUT -else - FileUtils.mkdir_p(File.dirname(config.output)) - output = File.open(config.output, mode: "a") +if CONFIG.output.upcase != "STDOUT" + FileUtils.mkdir_p(File.dirname(CONFIG.output)) end +OUTPUT = CONFIG.output.upcase == "STDOUT" ? STDOUT : File.open(CONFIG.output, mode: "a") +LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level) -logger = Invidious::LogHandler.new(output, config.log_level) +config = CONFIG # Check table integrity if CONFIG.check_tables - check_enum(PG_DB, logger, "privacy", PlaylistPrivacy) + check_enum(PG_DB, "privacy", PlaylistPrivacy) - check_table(PG_DB, logger, "channels", InvidiousChannel) - check_table(PG_DB, logger, "channel_videos", ChannelVideo) - check_table(PG_DB, logger, "playlists", InvidiousPlaylist) - check_table(PG_DB, logger, "playlist_videos", PlaylistVideo) - check_table(PG_DB, logger, "nonces", Nonce) - check_table(PG_DB, logger, "session_ids", SessionId) - check_table(PG_DB, logger, "users", User) - check_table(PG_DB, logger, "videos", Video) + check_table(PG_DB, "channels", InvidiousChannel) + check_table(PG_DB, "channel_videos", ChannelVideo) + check_table(PG_DB, "playlists", InvidiousPlaylist) + check_table(PG_DB, "playlist_videos", PlaylistVideo) + check_table(PG_DB, "nonces", Nonce) + check_table(PG_DB, "session_ids", SessionId) + check_table(PG_DB, "users", User) + check_table(PG_DB, "videos", Video) if CONFIG.cache_annotations - check_table(PG_DB, logger, "annotations", Annotation) + check_table(PG_DB, "annotations", Annotation) end end # Start jobs -Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB, logger, config) -Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB, logger, config) -Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, logger, config, HMAC_KEY) +Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB, config) +Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB, config) +Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, config, HMAC_KEY) DECRYPT_FUNCTION = DecryptFunction.new(CONFIG.decrypt_polling) if config.decrypt_polling - Invidious::Jobs.register Invidious::Jobs::UpdateDecryptFunctionJob.new(logger) + Invidious::Jobs.register Invidious::Jobs::UpdateDecryptFunctionJob.new end if config.statistics_enabled @@ -187,7 +185,7 @@ if config.popular_enabled end if config.captcha_key - Invidious::Jobs.register Invidious::Jobs::BypassCaptchaJob.new(logger, config) + Invidious::Jobs.register Invidious::Jobs::BypassCaptchaJob.new(config) end connection_channel = Channel({Bool, Channel(PQ::Notification)}).new(32) @@ -265,7 +263,7 @@ before_all do |env| headers["Cookie"] = env.request.headers["Cookie"] begin - user, sid = get_user(sid, headers, PG_DB, logger, false) + user, sid = get_user(sid, headers, PG_DB, false) csrf_token = generate_response(sid, { ":authorize_token", ":playlist_ajax", @@ -535,7 +533,7 @@ post "/subscription_ajax" do |env| case action when "action_create_subscription_to_channel" if !user.subscriptions.includes? channel_id - get_channel(channel_id, PG_DB, logger, false, false) + get_channel(channel_id, PG_DB, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions, $1) WHERE email = $2", channel_id, email) end when "action_remove_subscriptions" @@ -570,7 +568,7 @@ get "/subscription_manager" do |env| headers = HTTP::Headers.new headers["Cookie"] = env.request.headers["Cookie"] - user, sid = get_user(sid, headers, PG_DB, logger) + user, sid = get_user(sid, headers, PG_DB) end action_takeout = env.params.query["action_takeout"]?.try &.to_i? @@ -694,7 +692,7 @@ post "/data_control" do |env| user.subscriptions += body["subscriptions"].as_a.map { |a| a.as_s } user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) end @@ -763,7 +761,7 @@ post "/data_control" do |env| end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) when "import_freetube" @@ -772,7 +770,7 @@ post "/data_control" do |env| end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) when "import_newpipe_subscriptions" @@ -791,7 +789,7 @@ post "/data_control" do |env| end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) when "import_newpipe" @@ -810,7 +808,7 @@ post "/data_control" do |env| user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map { |url| url.lchop("https://www.youtube.com/channel/") } user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) @@ -1213,7 +1211,7 @@ get "/feed/subscriptions" do |env| headers["Cookie"] = env.request.headers["Cookie"] if !user.password - user, sid = get_user(sid, headers, PG_DB, logger) + user, sid = get_user(sid, headers, PG_DB) end max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) @@ -1517,7 +1515,7 @@ post "/feed/webhook/:token" do |env| signature = env.request.headers["X-Hub-Signature"].lchop("sha1=") if signature != OpenSSL::HMAC.hexdigest(:sha1, HMAC_KEY, body) - logger.error("/feed/webhook/#{token} : Invalid signature") + LOGGER.error("/feed/webhook/#{token} : Invalid signature") env.response.status_code = 200 next end @@ -2835,7 +2833,7 @@ post "/api/v1/auth/subscriptions/:ucid" do |env| ucid = env.params.url["ucid"] if !user.subscriptions.includes? ucid - get_channel(ucid, PG_DB, logger, false, false) + get_channel(ucid, PG_DB, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email) end @@ -3929,7 +3927,7 @@ add_context_storage_type(Array(String)) add_context_storage_type(Preferences) add_context_storage_type(User) -Kemal.config.logger = logger +Kemal.config.logger = LOGGER Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port Kemal.run diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr index 6907ff3d..274e86f9 100644 --- a/src/invidious/channels.cr +++ b/src/invidious/channels.cr @@ -144,7 +144,7 @@ class ChannelRedirect < Exception end end -def get_batch_channels(channels, db, logger, refresh = false, pull_all_videos = true, max_threads = 10) +def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10) finished_channel = Channel(String | Nil).new spawn do @@ -160,7 +160,7 @@ def get_batch_channels(channels, db, logger, refresh = false, pull_all_videos = active_threads += 1 spawn do begin - get_channel(ucid, db, logger, refresh, pull_all_videos) + get_channel(ucid, db, refresh, pull_all_videos) finished_channel.send(ucid) rescue ex finished_channel.send(nil) @@ -181,10 +181,10 @@ def get_batch_channels(channels, db, logger, refresh = false, pull_all_videos = return final end -def get_channel(id, db, logger, refresh = true, pull_all_videos = true) +def get_channel(id, db, refresh = true, pull_all_videos = true) if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel) if refresh && Time.utc - channel.updated > 10.minutes - channel = fetch_channel(id, db, logger, pull_all_videos: pull_all_videos) + channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) channel_array = channel.to_a args = arg_array(channel_array) @@ -192,7 +192,7 @@ def get_channel(id, db, logger, refresh = true, pull_all_videos = true) ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", args: channel_array) end else - channel = fetch_channel(id, db, logger, pull_all_videos: pull_all_videos) + channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) channel_array = channel.to_a args = arg_array(channel_array) @@ -202,12 +202,12 @@ def get_channel(id, db, logger, refresh = true, pull_all_videos = true) return channel end -def fetch_channel(ucid, db, logger, pull_all_videos = true, locale = nil) - logger.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}") +def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) + LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}") - logger.trace("fetch_channel: #{ucid} : Downloading RSS feed") + LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed") rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body - logger.trace("fetch_channel: #{ucid} : Parsing RSS feed") + LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed") rss = XML.parse_html(rss) author = rss.xpath_node(%q(//feed/title)) @@ -223,11 +223,11 @@ def fetch_channel(ucid, db, logger, pull_all_videos = true, locale = nil) auto_generated = true end - logger.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}") + LOGGER.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}") page = 1 - logger.trace("fetch_channel: #{ucid} : Downloading channel videos page") + LOGGER.trace("fetch_channel: #{ucid} : Downloading channel videos page") response = get_channel_videos_response(ucid, page, auto_generated: auto_generated) videos = [] of SearchVideo @@ -235,7 +235,7 @@ def fetch_channel(ucid, db, logger, pull_all_videos = true, locale = nil) initial_data = JSON.parse(response.body).as_a.find &.["response"]? raise InfoException.new("Could not extract channel JSON") if !initial_data - logger.trace("fetch_channel: #{ucid} : Extracting videos from channel videos page initial_data") + LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel videos page initial_data") videos = extract_videos(initial_data.as_h, author, ucid) rescue ex if response.body.includes?("To continue with your YouTube experience, please fill out the form below.") || @@ -245,7 +245,7 @@ def fetch_channel(ucid, db, logger, pull_all_videos = true, locale = nil) raise ex end - logger.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") + LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") rss.xpath_nodes("//feed/entry").each do |entry| video_id = entry.xpath_node("videoid").not_nil!.content title = entry.xpath_node("title").not_nil!.content @@ -279,7 +279,7 @@ def fetch_channel(ucid, db, logger, pull_all_videos = true, locale = nil) views: views, }) - logger.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video") + LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video") # We don't include the 'premiere_timestamp' here because channel pages don't include them, # meaning the above timestamp is always null @@ -289,11 +289,11 @@ def fetch_channel(ucid, db, logger, pull_all_videos = true, locale = nil) live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) if was_insert - logger.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") + LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) else - logger.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") + LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") end end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 1866f8e5..cf965dad 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -336,11 +336,11 @@ def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : Stri items end -def check_enum(db, logger, enum_name, struct_type = nil) +def check_enum(db, enum_name, struct_type = nil) return # TODO if !db.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool) - logger.info("check_enum: CREATE TYPE #{enum_name}") + LOGGER.info("check_enum: CREATE TYPE #{enum_name}") db.using_connection do |conn| conn.as(PG::Connection).exec_all(File.read("config/sql/#{enum_name}.sql")) @@ -348,12 +348,12 @@ def check_enum(db, logger, enum_name, struct_type = nil) end end -def check_table(db, logger, table_name, struct_type = nil) +def check_table(db, table_name, struct_type = nil) # Create table if it doesn't exist begin db.exec("SELECT * FROM #{table_name} LIMIT 0") rescue ex - logger.info("check_table: check_table: CREATE TABLE #{table_name}") + LOGGER.info("check_table: check_table: CREATE TABLE #{table_name}") db.using_connection do |conn| conn.as(PG::Connection).exec_all(File.read("config/sql/#{table_name}.sql")) @@ -373,7 +373,7 @@ def check_table(db, logger, table_name, struct_type = nil) if name != column_array[i]? if !column_array[i]? new_column = column_types.select { |line| line.starts_with? name }[0] - logger.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") next end @@ -391,29 +391,29 @@ def check_table(db, logger, table_name, struct_type = nil) # There's a column we didn't expect if !new_column - logger.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}") + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}") db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") column_array = get_column_array(db, table_name) next end - logger.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - logger.info("check_table: UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") + LOGGER.info("check_table: UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") db.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") - logger.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - logger.info("check_table: ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") + LOGGER.info("check_table: ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") db.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") column_array = get_column_array(db, table_name) end else - logger.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") end end @@ -423,7 +423,7 @@ def check_table(db, logger, table_name, struct_type = nil) column_array.each do |column| if !struct_array.includes? column - logger.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") end end diff --git a/src/invidious/jobs/bypass_captcha_job.cr b/src/invidious/jobs/bypass_captcha_job.cr index 61f8eaf3..22c54036 100644 --- a/src/invidious/jobs/bypass_captcha_job.cr +++ b/src/invidious/jobs/bypass_captcha_job.cr @@ -1,8 +1,7 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob - private getter logger : Invidious::LogHandler private getter config : Config - def initialize(@logger, @config) + def initialize(@config) end def begin @@ -127,7 +126,7 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob end end rescue ex - logger.error("BypassCaptchaJob: #{ex.message}") + LOGGER.error("BypassCaptchaJob: #{ex.message}") ensure sleep 1.minute Fiber.yield diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index 6c858afa..0c6ef79e 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -1,9 +1,8 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob private getter db : DB::Database - private getter logger : Invidious::LogHandler private getter config : Config - def initialize(@db, @logger, @config) + def initialize(@db, @config) end def begin @@ -14,37 +13,37 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob backoff = 1.seconds loop do - logger.debug("RefreshChannelsJob: Refreshing all channels") + LOGGER.debug("RefreshChannelsJob: Refreshing all channels") db.query("SELECT id FROM channels ORDER BY updated") do |rs| rs.each do id = rs.read(String) if active_fibers >= lim_fibers - logger.trace("RefreshChannelsJob: Fiber limit reached, waiting...") + LOGGER.trace("RefreshChannelsJob: Fiber limit reached, waiting...") if active_channel.receive - logger.trace("RefreshChannelsJob: Fiber limit ok, continuing") + LOGGER.trace("RefreshChannelsJob: Fiber limit ok, continuing") active_fibers -= 1 end end - logger.trace("RefreshChannelsJob: #{id} : Spawning fiber") + LOGGER.trace("RefreshChannelsJob: #{id} : Spawning fiber") active_fibers += 1 spawn do begin - logger.trace("RefreshChannelsJob: #{id} fiber : Fetching channel") - channel = fetch_channel(id, db, logger, config.full_refresh) + LOGGER.trace("RefreshChannelsJob: #{id} fiber : Fetching channel") + channel = fetch_channel(id, db, config.full_refresh) lim_fibers = max_fibers - logger.trace("RefreshChannelsJob: #{id} fiber : Updating DB") + LOGGER.trace("RefreshChannelsJob: #{id} fiber : Updating DB") db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.utc, channel.author, id) rescue ex - logger.error("RefreshChannelsJob: #{id} : #{ex.message}") + LOGGER.error("RefreshChannelsJob: #{id} : #{ex.message}") if ex.message == "Deleted or invalid channel" db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.utc, id) else lim_fibers = 1 - logger.error("RefreshChannelsJob: #{id} fiber : backing off for #{backoff}s") + LOGGER.error("RefreshChannelsJob: #{id} fiber : backing off for #{backoff}s") sleep backoff if backoff < 1.days backoff += backoff @@ -53,14 +52,14 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob end end ensure - logger.trace("RefreshChannelsJob: #{id} fiber : Done") + LOGGER.trace("RefreshChannelsJob: #{id} fiber : Done") active_channel.send(true) end end end end - logger.debug("RefreshChannelsJob: Done, sleeping for one minute") + LOGGER.debug("RefreshChannelsJob: Done, sleeping for one minute") sleep 1.minute Fiber.yield end diff --git a/src/invidious/jobs/refresh_feeds_job.cr b/src/invidious/jobs/refresh_feeds_job.cr index 208569b8..7b4ccdea 100644 --- a/src/invidious/jobs/refresh_feeds_job.cr +++ b/src/invidious/jobs/refresh_feeds_job.cr @@ -1,9 +1,8 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob private getter db : DB::Database - private getter logger : Invidious::LogHandler private getter config : Config - def initialize(@db, @logger, @config) + def initialize(@db, @config) end def begin @@ -30,14 +29,14 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob column_array = get_column_array(db, view_name) ChannelVideo.type_array.each_with_index do |name, i| if name != column_array[i]? - logger.info("RefreshFeedsJob: DROP MATERIALIZED VIEW #{view_name}") + LOGGER.info("RefreshFeedsJob: DROP MATERIALIZED VIEW #{view_name}") db.exec("DROP MATERIALIZED VIEW #{view_name}") raise "view does not exist" end end if !db.query_one("SELECT pg_get_viewdef('#{view_name}')", as: String).includes? "WHERE ((cv.ucid = ANY (u.subscriptions))" - logger.info("RefreshFeedsJob: Materialized view #{view_name} is out-of-date, recreating...") + LOGGER.info("RefreshFeedsJob: Materialized view #{view_name} is out-of-date, recreating...") db.exec("DROP MATERIALIZED VIEW #{view_name}") end @@ -49,18 +48,18 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob legacy_view_name = "subscriptions_#{sha256(email)[0..7]}" db.exec("SELECT * FROM #{legacy_view_name} LIMIT 0") - logger.info("RefreshFeedsJob: RENAME MATERIALIZED VIEW #{legacy_view_name}") + LOGGER.info("RefreshFeedsJob: RENAME MATERIALIZED VIEW #{legacy_view_name}") db.exec("ALTER MATERIALIZED VIEW #{legacy_view_name} RENAME TO #{view_name}") rescue ex begin # While iterating through, we may have an email stored from a deleted account if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool) - logger.info("RefreshFeedsJob: CREATE #{view_name}") + LOGGER.info("RefreshFeedsJob: CREATE #{view_name}") db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(email)}") db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email) end rescue ex - logger.error("RefreshFeedJobs: REFRESH #{email} : #{ex.message}") + LOGGER.error("RefreshFeedJobs: REFRESH #{email} : #{ex.message}") end end end diff --git a/src/invidious/jobs/subscribe_to_feeds_job.cr b/src/invidious/jobs/subscribe_to_feeds_job.cr index 2255730d..750aceb8 100644 --- a/src/invidious/jobs/subscribe_to_feeds_job.cr +++ b/src/invidious/jobs/subscribe_to_feeds_job.cr @@ -1,10 +1,9 @@ class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob private getter db : DB::Database - private getter logger : Invidious::LogHandler private getter hmac_key : String private getter config : Config - def initialize(@db, @logger, @config, @hmac_key) + def initialize(@db, @config, @hmac_key) end def begin @@ -34,10 +33,10 @@ class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob response = subscribe_pubsub(ucid, hmac_key, config) if response.status_code >= 400 - logger.error("SubscribeToFeedsJob: #{ucid} : #{response.body}") + LOGGER.error("SubscribeToFeedsJob: #{ucid} : #{response.body}") end rescue ex - logger.error("SubscribeToFeedsJob: #{ucid} : #{ex.message}") + LOGGER.error("SubscribeToFeedsJob: #{ucid} : #{ex.message}") end active_channel.send(true) diff --git a/src/invidious/jobs/update_decrypt_function_job.cr b/src/invidious/jobs/update_decrypt_function_job.cr index 0a6c09c5..6fa0ae1b 100644 --- a/src/invidious/jobs/update_decrypt_function_job.cr +++ b/src/invidious/jobs/update_decrypt_function_job.cr @@ -1,15 +1,10 @@ class Invidious::Jobs::UpdateDecryptFunctionJob < Invidious::Jobs::BaseJob - private getter logger : Invidious::LogHandler - - def initialize(@logger) - end - def begin loop do begin DECRYPT_FUNCTION.update_decrypt_function rescue ex - logger.error("UpdateDecryptFunctionJob : #{ex.message}") + LOGGER.error("UpdateDecryptFunctionJob : #{ex.message}") ensure sleep 1.minute Fiber.yield diff --git a/src/invidious/routes/base_route.cr b/src/invidious/routes/base_route.cr index 2852cb04..37624267 100644 --- a/src/invidious/routes/base_route.cr +++ b/src/invidious/routes/base_route.cr @@ -1,7 +1,6 @@ abstract class Invidious::Routes::BaseRoute private getter config : Config - private getter logger : Invidious::LogHandler - def initialize(@config, @logger) + def initialize(@config) end end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 42fb4676..45a6d4d8 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -267,7 +267,7 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute raise "Couldn't get SID." end - user, sid = get_user(sid, headers, PG_DB, logger) + user, sid = get_user(sid, headers, PG_DB) # We are now logged in traceback << "done.
" diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index a5c05c00..65604a88 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -62,7 +62,7 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute rescue ex : VideoRedirect return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex - logger.error("get_video: #{id} : #{ex.message}") + LOGGER.error("get_video: #{id} : #{ex.message}") return error_template(500, ex) end diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 602e6ae5..593c7372 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -1,14 +1,14 @@ module Invidious::Routing macro get(path, controller, method = :handle) get {{ path }} do |env| - controller_instance = {{ controller }}.new(config, logger) + controller_instance = {{ controller }}.new(config) controller_instance.{{ method.id }}(env) end end macro post(path, controller, method = :handle) post {{ path }} do |env| - controller_instance = {{ controller }}.new(config, logger) + controller_instance = {{ controller }}.new(config) controller_instance.{{ method.id }}(env) end end diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 5dc16edd..6bc2acd2 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -269,12 +269,12 @@ struct Preferences end end -def get_user(sid, headers, db, logger, refresh = true) +def get_user(sid, headers, db, refresh = true) if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User) if refresh && Time.utc - user.updated > 1.minute - user, sid = fetch_user(sid, headers, db, logger) + user, sid = fetch_user(sid, headers, db) user_array = user.to_a user_array[4] = user_array[4].to_json # User preferences args = arg_array(user_array) @@ -292,7 +292,7 @@ def get_user(sid, headers, db, logger, refresh = true) end end else - user, sid = fetch_user(sid, headers, db, logger) + user, sid = fetch_user(sid, headers, db) user_array = user.to_a user_array[4] = user_array[4].to_json # User preferences args = arg_array(user.to_a) @@ -313,7 +313,7 @@ def get_user(sid, headers, db, logger, refresh = true) return user, sid end -def fetch_user(sid, headers, db, logger) +def fetch_user(sid, headers, db) feed = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) feed = XML.parse_html(feed.body) @@ -326,7 +326,7 @@ def fetch_user(sid, headers, db, logger) end end - channels = get_batch_channels(channels, db, logger, false, false) + channels = get_batch_channels(channels, db, false, false) email = feed.xpath_node(%q(//a[@class="yt-masthead-picker-header yt-masthead-picker-active-account"])) if email From df9e7f284c95c151a006eedf6be2ead49987e778 Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Mon, 4 Jan 2021 17:05:45 +0100 Subject: [PATCH 0170/2881] Adjust log verbosity The default log level has been changed from `debug` to `info`. The `debug` log level is now more verbose. `debug` now gives a general overview of what is happening (where implemented) while `trace` gives all available details. --- src/invidious/channels.cr | 1 + src/invidious/helpers/helpers.cr | 2 +- src/invidious/jobs/refresh_channels_job.cr | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr index 274e86f9..9986fe1b 100644 --- a/src/invidious/channels.cr +++ b/src/invidious/channels.cr @@ -203,6 +203,7 @@ def get_channel(id, db, refresh = true, pull_all_videos = true) end def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) + LOGGER.debug("fetch_channel: #{ucid}") LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}") LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed") diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index cf965dad..e41f8317 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -67,7 +67,7 @@ class Config property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions) property feed_threads : Int32 = 1 # Number of threads to use for updating feeds property output : String = "STDOUT" # Log file path or STDOUT - property log_level : LogLevel = LogLevel::Debug # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr + property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr property db : DBConfig # Database configuration property decrypt_polling : Bool = true # Use polling to keep decryption function up to date property full_refresh : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index 0c6ef79e..3e94a56e 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -26,7 +26,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob end end - LOGGER.trace("RefreshChannelsJob: #{id} : Spawning fiber") + LOGGER.debug("RefreshChannelsJob: #{id} : Spawning fiber") active_fibers += 1 spawn do begin @@ -52,7 +52,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob end end ensure - LOGGER.trace("RefreshChannelsJob: #{id} fiber : Done") + LOGGER.debug("RefreshChannelsJob: #{id} fiber : Done") active_channel.send(true) end end From 4d512d908dfaf9b726602c77bcf555d36445f88f Mon Sep 17 00:00:00 2001 From: HackerNCoder Date: Thu, 7 Jan 2021 19:01:13 +0100 Subject: [PATCH 0171/2881] Remove some mentions of omarroth --- kubernetes/Chart.yaml | 4 ++-- kubernetes/values.yaml | 2 +- shard.yml | 1 + src/invidious/helpers/helpers.cr | 2 +- src/invidious/users.cr | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/kubernetes/Chart.yaml b/kubernetes/Chart.yaml index bb0838ad..9e4b793e 100644 --- a/kubernetes/Chart.yaml +++ b/kubernetes/Chart.yaml @@ -9,9 +9,9 @@ keywords: - video - privacy home: https://invidio.us/ -icon: https://raw.githubusercontent.com/omarroth/invidious/05988c1c49851b7d0094fca16aeaf6382a7f64ab/assets/favicon-32x32.png +icon: https://raw.githubusercontent.com/iv-org/invidious/05988c1c49851b7d0094fca16aeaf6382a7f64ab/assets/favicon-32x32.png sources: -- https://github.com/omarroth/invidious +- https://github.com/iv-org/invidious maintainers: - name: Leon Klingele email: mail@leonklingele.de diff --git a/kubernetes/values.yaml b/kubernetes/values.yaml index 4d037022..08def6e4 100644 --- a/kubernetes/values.yaml +++ b/kubernetes/values.yaml @@ -1,7 +1,7 @@ name: invidious image: - repository: omarroth/invidious + repository: iv-org/invidious tag: latest pullPolicy: Always diff --git a/shard.yml b/shard.yml index 2b59786e..e0fa1d25 100644 --- a/shard.yml +++ b/shard.yml @@ -3,6 +3,7 @@ version: 0.20.1 authors: - Omar Roth + - Invidous team targets: invidious: diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index e41f8317..a4101ef1 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -95,7 +95,7 @@ class Config property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument) property host_binding : String = "0.0.0.0" # Host to bind (overrided by command line argument) property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) - property admin_email : String = "omarroth@protonmail.com" # Email for bug reports + property admin_email : String = "FIXME" # Email for bug reports @[YAML::Field(converter: Preferences::StringToCookies)] property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 6bc2acd2..4b16affc 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -427,7 +427,7 @@ def generate_captcha(key, db) end def generate_text_captcha(key, db) - response = make_client(TEXTCAPTCHA_URL, &.get("/omarroth@protonmail.com.json").body) + response = make_client(TEXTCAPTCHA_URL, &.get("/FIXME.json").body) response = JSON.parse(response) tokens = response["a"].as_a.map do |answer| From b0b8ba7000e76e7d5632e9edfebf5c8c4aa44111 Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Thu, 7 Jan 2021 20:15:26 +0100 Subject: [PATCH 0172/2881] Respect `use_pubsub_feeds` config Setting `use_pubsub_feeds: false` now properly disables it. --- src/invidious.cr | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/invidious.cr b/src/invidious.cr index 2b915350..800ea956 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -169,7 +169,6 @@ end Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB, config) Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB, config) -Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, config, HMAC_KEY) DECRYPT_FUNCTION = DecryptFunction.new(CONFIG.decrypt_polling) if config.decrypt_polling @@ -180,6 +179,10 @@ if config.statistics_enabled Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, config, SOFTWARE) end +if (config.use_pubsub_feeds.is_a?(Bool) && config.use_pubsub_feeds.as(Bool)) || (config.use_pubsub_feeds.is_a?(Int32) && config.use_pubsub_feeds.as(Int32) > 0) + Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, config, HMAC_KEY) +end + if config.popular_enabled Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) end From 606dd11b4f9fb6fc2aaca85e18e6a2d750d5ad6c Mon Sep 17 00:00:00 2001 From: HackerNCoder Date: Thu, 7 Jan 2021 21:09:24 +0100 Subject: [PATCH 0173/2881] Remove admin_email. Use repos url for captcha ID and reddit header. Add note about not updating changelog --- CHANGELOG.md | 2 ++ src/invidious/comments.cr | 2 +- src/invidious/helpers/helpers.cr | 1 - src/invidious/users.cr | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 314a134f..8aa416ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +# Note: This is no longer updated and links to omarroths repo, which doesn't exist anymore. + # 0.20.0 (2019-011-06) # Version 0.20.0: Custom Playlists diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 8849c87f..0ac99ba5 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -242,7 +242,7 @@ end def fetch_reddit_comments(id, sort_by = "confidence") client = make_client(REDDIT_URL) - headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by /u/omarroth)"} + headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"} # TODO: Use something like #479 for a static list of instances to use here query = "(url:3D#{id}%20OR%20url:#{id})%20(site:invidio.us%20OR%20site:youtube.com%20OR%20site:youtu.be)" diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index a4101ef1..1f56ec92 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -95,7 +95,6 @@ class Config property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument) property host_binding : String = "0.0.0.0" # Host to bind (overrided by command line argument) property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) - property admin_email : String = "FIXME" # Email for bug reports @[YAML::Field(converter: Preferences::StringToCookies)] property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 4b16affc..153e3b6a 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -427,7 +427,7 @@ def generate_captcha(key, db) end def generate_text_captcha(key, db) - response = make_client(TEXTCAPTCHA_URL, &.get("/FIXME.json").body) + response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body) response = JSON.parse(response) tokens = response["a"].as_a.map do |answer| From c81ca187f8b31a04b1027c63fef99711cbda0556 Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Sat, 9 Jan 2021 20:40:01 +0100 Subject: [PATCH 0174/2881] Fix downloads The `itag` is now converted to a number, matching the `itag` of `Video.adaptive_fmts` and `Video.fmt_stream`. --- src/invidious.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/invidious.cr b/src/invidious.cr index 800ea956..5235d826 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -3372,7 +3372,7 @@ get "/latest_version" do |env| env.redirect "/api/v1/captions/#{id}?label=#{label}&title=#{title}" next else - itag = download_widget["itag"].as_s + itag = download_widget["itag"].as_s.to_i local = "true" end end From 5a08dfa72f26a250b09c643c47cd4abc18e3843c Mon Sep 17 00:00:00 2001 From: Andrew Zhao Date: Sun, 10 Jan 2021 17:05:08 -0500 Subject: [PATCH 0175/2881] fix incorrect use of setinterval in js --- assets/js/player.js | 4 ++-- assets/js/watch.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/assets/js/player.js b/assets/js/player.js index fcba43d8..932de581 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -69,7 +69,7 @@ if (location.pathname.startsWith('/embed/')) { player.on('error', function (event) { if (player.error().code === 2 || player.error().code === 4) { - setInterval(setTimeout(function (event) { + setTimeout(function (event) { console.log('An error occured in the player, reloading...'); var currentTime = player.currentTime(); @@ -88,7 +88,7 @@ player.on('error', function (event) { if (!paused) { player.play(); } - }, 5000), 5000); + }, 5000); } }); diff --git a/assets/js/watch.js b/assets/js/watch.js index 05530f3d..eb493bf3 100644 --- a/assets/js/watch.js +++ b/assets/js/watch.js @@ -272,7 +272,7 @@ function get_reddit_comments(retries) { xhr.onerror = function () { console.log('Pulling comments failed... ' + retries + '/5'); - setInterval(function () { get_reddit_comments(retries - 1) }, 1000); + setTimeout(function () { get_reddit_comments(retries - 1) }, 1000); } xhr.ontimeout = function () { @@ -346,7 +346,7 @@ function get_youtube_comments(retries) { comments.innerHTML = '

'; console.log('Pulling comments failed... ' + retries + '/5'); - setInterval(function () { get_youtube_comments(retries - 1) }, 1000); + setTimeout(function () { get_youtube_comments(retries - 1) }, 1000); } xhr.ontimeout = function () { From b7fe1db89a7b33ab2f9240e69acb3dfcd5210bd5 Mon Sep 17 00:00:00 2001 From: Andrew Zhao Date: Sun, 10 Jan 2021 18:00:45 -0500 Subject: [PATCH 0176/2881] save host when using dash manifest --- src/invidious.cr | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/invidious.cr b/src/invidious.cr index 5235d826..deb24ac3 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -3190,7 +3190,8 @@ get "/api/manifest/dash/id/:id" do |env| url = url.rchop("") if local - url = URI.parse(url).full_path + uri = URI.parse(url) + url = "#{uri.full_path}host/#{uri.host}/" end "#{url}" From 8584654f11b6596a4b0cd0219b699eb4f85df5c5 Mon Sep 17 00:00:00 2001 From: Perflyst Date: Sat, 16 Jan 2021 22:18:22 +0100 Subject: [PATCH 0177/2881] Add container CI release --- .github/workflows/release.yml | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..77877080 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Build and release container + +on: + push: + branches: + - "master" + +jobs: + release: + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to registry + uses: docker/login-action@v1 + with: + registry: quay.io + username: ${{ secrets.QUAY_USERNAME }} + password: ${{ secrets.QUAY_PASSWORD }} + + - name: Build and push + uses: docker/build-push-action@v2 + with: + push: true + tags: quay.io/invidious/invidious:latest + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} From ffa9e5dfab7a1d7ea84d88007107f9f40295c50a Mon Sep 17 00:00:00 2001 From: Andre Borie Date: Sun, 17 Jan 2021 01:44:31 +0000 Subject: [PATCH 0178/2881] Make migrations (mostly) idempotent. --- config/sql/annotations.sql | 4 ++-- config/sql/channel_videos.sql | 6 +++--- config/sql/channels.sql | 6 +++--- config/sql/nonces.sql | 6 +++--- config/sql/playlist_videos.sql | 4 ++-- config/sql/playlists.sql | 4 ++-- config/sql/session_ids.sql | 6 +++--- config/sql/users.sql | 6 +++--- config/sql/videos.sql | 6 +++--- docker/init-invidious-db.sh | 18 +++++++++--------- 10 files changed, 33 insertions(+), 33 deletions(-) diff --git a/config/sql/annotations.sql b/config/sql/annotations.sql index 4ea077e7..3705829d 100644 --- a/config/sql/annotations.sql +++ b/config/sql/annotations.sql @@ -2,11 +2,11 @@ -- DROP TABLE public.annotations; -CREATE TABLE public.annotations +CREATE TABLE IF NOT EXISTS public.annotations ( id text NOT NULL, annotations xml, CONSTRAINT annotations_id_key UNIQUE (id) ); -GRANT ALL ON TABLE public.annotations TO kemal; +GRANT ALL ON TABLE public.annotations TO current_user; diff --git a/config/sql/channel_videos.sql b/config/sql/channel_videos.sql index cec57cd4..cd4e0ffd 100644 --- a/config/sql/channel_videos.sql +++ b/config/sql/channel_videos.sql @@ -2,7 +2,7 @@ -- DROP TABLE public.channel_videos; -CREATE TABLE public.channel_videos +CREATE TABLE IF NOT EXISTS public.channel_videos ( id text NOT NULL, title text, @@ -17,13 +17,13 @@ CREATE TABLE public.channel_videos CONSTRAINT channel_videos_id_key UNIQUE (id) ); -GRANT ALL ON TABLE public.channel_videos TO kemal; +GRANT ALL ON TABLE public.channel_videos TO current_user; -- Index: public.channel_videos_ucid_idx -- DROP INDEX public.channel_videos_ucid_idx; -CREATE INDEX channel_videos_ucid_idx +CREATE INDEX IF NOT EXISTS channel_videos_ucid_idx ON public.channel_videos USING btree (ucid COLLATE pg_catalog."default"); diff --git a/config/sql/channels.sql b/config/sql/channels.sql index b5a29b8f..55772da6 100644 --- a/config/sql/channels.sql +++ b/config/sql/channels.sql @@ -2,7 +2,7 @@ -- DROP TABLE public.channels; -CREATE TABLE public.channels +CREATE TABLE IF NOT EXISTS public.channels ( id text NOT NULL, author text, @@ -12,13 +12,13 @@ CREATE TABLE public.channels CONSTRAINT channels_id_key UNIQUE (id) ); -GRANT ALL ON TABLE public.channels TO kemal; +GRANT ALL ON TABLE public.channels TO current_user; -- Index: public.channels_id_idx -- DROP INDEX public.channels_id_idx; -CREATE INDEX channels_id_idx +CREATE INDEX IF NOT EXISTS channels_id_idx ON public.channels USING btree (id COLLATE pg_catalog."default"); diff --git a/config/sql/nonces.sql b/config/sql/nonces.sql index 7b8ce9f2..644ac32a 100644 --- a/config/sql/nonces.sql +++ b/config/sql/nonces.sql @@ -2,20 +2,20 @@ -- DROP TABLE public.nonces; -CREATE TABLE public.nonces +CREATE TABLE IF NOT EXISTS public.nonces ( nonce text, expire timestamp with time zone, CONSTRAINT nonces_id_key UNIQUE (nonce) ); -GRANT ALL ON TABLE public.nonces TO kemal; +GRANT ALL ON TABLE public.nonces TO current_user; -- Index: public.nonces_nonce_idx -- DROP INDEX public.nonces_nonce_idx; -CREATE INDEX nonces_nonce_idx +CREATE INDEX IF NOT EXISTS nonces_nonce_idx ON public.nonces USING btree (nonce COLLATE pg_catalog."default"); diff --git a/config/sql/playlist_videos.sql b/config/sql/playlist_videos.sql index b2b8d5c4..eedccbad 100644 --- a/config/sql/playlist_videos.sql +++ b/config/sql/playlist_videos.sql @@ -2,7 +2,7 @@ -- DROP TABLE public.playlist_videos; -CREATE TABLE playlist_videos +CREATE TABLE IF NOT EXISTS playlist_videos ( title text, id text, @@ -16,4 +16,4 @@ CREATE TABLE playlist_videos PRIMARY KEY (index,plid) ); -GRANT ALL ON TABLE public.playlist_videos TO kemal; +GRANT ALL ON TABLE public.playlist_videos TO current_user; diff --git a/config/sql/playlists.sql b/config/sql/playlists.sql index 468496cb..83efce48 100644 --- a/config/sql/playlists.sql +++ b/config/sql/playlists.sql @@ -13,7 +13,7 @@ CREATE TYPE public.privacy AS ENUM -- DROP TABLE public.playlists; -CREATE TABLE public.playlists +CREATE TABLE IF NOT EXISTS public.playlists ( title text, id text primary key, @@ -26,4 +26,4 @@ CREATE TABLE public.playlists index int8[] ); -GRANT ALL ON public.playlists TO kemal; +GRANT ALL ON public.playlists TO current_user; diff --git a/config/sql/session_ids.sql b/config/sql/session_ids.sql index afbabb67..c493769a 100644 --- a/config/sql/session_ids.sql +++ b/config/sql/session_ids.sql @@ -2,7 +2,7 @@ -- DROP TABLE public.session_ids; -CREATE TABLE public.session_ids +CREATE TABLE IF NOT EXISTS public.session_ids ( id text NOT NULL, email text, @@ -10,13 +10,13 @@ CREATE TABLE public.session_ids CONSTRAINT session_ids_pkey PRIMARY KEY (id) ); -GRANT ALL ON TABLE public.session_ids TO kemal; +GRANT ALL ON TABLE public.session_ids TO current_user; -- Index: public.session_ids_id_idx -- DROP INDEX public.session_ids_id_idx; -CREATE INDEX session_ids_id_idx +CREATE INDEX IF NOT EXISTS session_ids_id_idx ON public.session_ids USING btree (id COLLATE pg_catalog."default"); diff --git a/config/sql/users.sql b/config/sql/users.sql index 0f2cdba2..ad002ec2 100644 --- a/config/sql/users.sql +++ b/config/sql/users.sql @@ -2,7 +2,7 @@ -- DROP TABLE public.users; -CREATE TABLE public.users +CREATE TABLE IF NOT EXISTS public.users ( updated timestamp with time zone, notifications text[], @@ -16,13 +16,13 @@ CREATE TABLE public.users CONSTRAINT users_email_key UNIQUE (email) ); -GRANT ALL ON TABLE public.users TO kemal; +GRANT ALL ON TABLE public.users TO current_user; -- Index: public.email_unique_idx -- DROP INDEX public.email_unique_idx; -CREATE UNIQUE INDEX email_unique_idx +CREATE UNIQUE INDEX IF NOT EXISTS email_unique_idx ON public.users USING btree (lower(email) COLLATE pg_catalog."default"); diff --git a/config/sql/videos.sql b/config/sql/videos.sql index 8def2f83..7040703c 100644 --- a/config/sql/videos.sql +++ b/config/sql/videos.sql @@ -2,7 +2,7 @@ -- DROP TABLE public.videos; -CREATE TABLE public.videos +CREATE TABLE IF NOT EXISTS public.videos ( id text NOT NULL, info text, @@ -10,13 +10,13 @@ CREATE TABLE public.videos CONSTRAINT videos_pkey PRIMARY KEY (id) ); -GRANT ALL ON TABLE public.videos TO kemal; +GRANT ALL ON TABLE public.videos TO current_user; -- Index: public.id_idx -- DROP INDEX public.id_idx; -CREATE UNIQUE INDEX id_idx +CREATE UNIQUE INDEX IF NOT EXISTS id_idx ON public.videos USING btree (id COLLATE pg_catalog."default"); diff --git a/docker/init-invidious-db.sh b/docker/init-invidious-db.sh index 3808e673..cc0e1c3f 100755 --- a/docker/init-invidious-db.sh +++ b/docker/init-invidious-db.sh @@ -5,12 +5,12 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-E CREATE USER postgres; EOSQL -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channels.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/videos.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channel_videos.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/users.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/session_ids.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/nonces.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/annotations.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlists.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlist_videos.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channels.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/videos.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channel_videos.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/users.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/session_ids.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/nonces.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/annotations.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlists.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlist_videos.sql From 3046350cb2efcac907d6ce9fa4b3a08bda25c128 Mon Sep 17 00:00:00 2001 From: Andre Borie Date: Sun, 17 Jan 2021 01:43:36 +0000 Subject: [PATCH 0179/2881] Fix DASH playback bug. --- assets/js/player.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/assets/js/player.js b/assets/js/player.js index 5d045391..04326631 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -19,6 +19,11 @@ var options = { 'playbackRateMenuButton', 'fullscreenToggle' ] + }, + html5: { + hls: { + overrideNative: true + } } } From 15ba3325d9eee4e8b30828bf6d239f4999a36e0b Mon Sep 17 00:00:00 2001 From: Andrew Zhao Date: Sat, 2 Jan 2021 19:35:31 -0500 Subject: [PATCH 0180/2881] add ui for searching --- assets/css/default.css | 3 + assets/js/search.js | 13 +++++ locales/en-US.json | 27 +++++++++ src/invidious/routes/playlists.cr | 2 +- src/invidious/routes/search.cr | 8 ++- src/invidious/search.cr | 2 +- src/invidious/views/search.ecr | 97 +++++++++++++++++++++++++++++++ 7 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 assets/js/search.js diff --git a/assets/css/default.css b/assets/css/default.css index e403e606..793295d9 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -628,3 +628,6 @@ body.dark-theme { } } +#filters { + display: none; +} \ No newline at end of file diff --git a/assets/js/search.js b/assets/js/search.js new file mode 100644 index 00000000..36edd053 --- /dev/null +++ b/assets/js/search.js @@ -0,0 +1,13 @@ +function toggle_comments(event) { + var target = event.target; + var body = document.getElementById('filters'); + if (body.style.display === 'flex') { + target.innerHTML = '[ + ]'; + body.style.display = 'none'; + } else { + target.innerHTML = '[ - ]'; + body.style.display = 'flex'; + } +} + +document.getElementById('togglefilters').onclick = toggle_comments; \ No newline at end of file diff --git a/locales/en-US.json b/locales/en-US.json index acd2b667..66e71bb6 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -383,5 +383,32 @@ "Videos": "Videos", "Playlists": "Playlists", "Community": "Community", + "relevance": "Relevance", + "rating": "Rating", + "date": "Upload date", + "views": "View count", + "content_type": "Type", + "duration": "Duration", + "features": "Features", + "sort": "Sort By", + "hour": "Last Hour", + "today": "Today", + "week": "This week", + "month": "This month", + "year": "This year", + "video": "Video", + "channel": "Channel", + "playlist": "Playlist", + "movie": "Movie", + "show": "Show", + "hd": "HD", + "subtitles": "Subtitles/CC", + "creative_commons": "Creative Commons", + "3d": "3D", + "live": "Live", + "4k": "4K", + "location": "Location", + "hdr": "HDR", + "filter": "Filter", "Current version: ": "Current version: " } \ No newline at end of file diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 6c899054..c5023c08 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -267,7 +267,7 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute query = env.params.query["q"]? if query begin - search_query, count, items = process_search_query(query, page, user, region: nil) + search_query, count, items, operators = process_search_query(query, page, user, region: nil) videos = items.select { |item| item.is_a? SearchVideo }.map { |item| item.as(SearchVideo) } rescue ex videos = [] of SearchVideo diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 48446161..a993a17a 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -48,11 +48,17 @@ class Invidious::Routes::Search < Invidious::Routes::BaseRoute user = env.get? "user" begin - search_query, count, videos = process_search_query(query, page, user, region: nil) + search_query, count, videos, operators = process_search_query(query, page, user, region: nil) rescue ex return error_template(500, ex) end + operator_hash = {} of String => String + operators.each do |operator| + key, value = operator.downcase.split(":") + operator_hash[key] = value + end + env.set "search", query templated "search" end diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 85fd024a..1c4bc74e 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -445,5 +445,5 @@ def process_search_query(query, page, user, region) count, items = search(search_query, page, search_params, region).as(Tuple) end - {search_query, count, items} + {search_query, count, items, operators} end diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index bc13b7ea..3fa9242b 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -2,6 +2,102 @@ <%= search_query.not_nil!.size > 30 ? HTML.escape(query.not_nil![0,30].rstrip(".") + "...") : HTML.escape(query.not_nil!) %> - Invidious <% end %> +

+ [ + ] + <%= translate(locale, "filter") %> +

+ + +
+
+ <%= translate(locale, "date") %> +
+ <% ["hour", "today", "week", "month", "year"].each do |date| %> +
+ <% if operator_hash.fetch("date", "all") == date %> + <%= translate(locale, date) %> + <% else %> + &page=<%= page %>"> + <%= translate(locale, date) %> + + <% end %> +
+ <% end %> +
+
+ <%= translate(locale, "content_type") %> +
+ <% ["video", "channel", "playlist", "movie", "show"].each do |content_type| %> +
+ <% if operator_hash.fetch("content_type", "all") == content_type %> + <%= translate(locale, content_type) %> + <% else %> + &page=<%= page %>"> + <%= translate(locale, content_type) %> + + <% end %> +
+ <% end %> +
+
+ <%= translate(locale, "duration") %> +
+ <% ["short", "long"].each do |duration| %> +
+ <% if operator_hash.fetch("duration", "all") == duration %> + <%= translate(locale, duration) %> + <% else %> + &page=<%= page %>"> + <%= translate(locale, duration) %> + + <% end %> +
+ <% end %> +
+
+ <%= translate(locale, "features") %> +
+ <% ["hd", "subtitles", "creative_commons", "3d", "live", "purchased", "4k", "360", "location", "hdr"].each do |feature| %> +
+ <% if operator_hash.fetch("features", "all").includes?(feature) %> + <%= translate(locale, feature) %> + <% elsif operator_hash.has_key?("features") %> + &page=<%= page %>"> + <%= translate(locale, feature) %> + + <% else %> + &page=<%= page %>"> + <%= translate(locale, feature) %> + + <% end %> +
+ <% end %> +
+
+ <%= translate(locale, "sort") %> +
+ <% ["relevance", "rating", "date", "views"].each do |sort| %> +
+ <% if operator_hash.fetch("sort", "relevance") == sort %> + <%= translate(locale, sort) %> + <% else %> + &page=<%= page %>"> + <%= translate(locale, sort) %> + + <% end %> +
+ <% end %> +
+
+ +
+
<% if page > 1 %> @@ -45,3 +141,4 @@ <% end %>
+ \ No newline at end of file From d4ddd7204d7f260574bb6c572e90bcdc99e7dbd0 Mon Sep 17 00:00:00 2001 From: Perflyst Date: Wed, 20 Jan 2021 12:44:01 +0100 Subject: [PATCH 0181/2881] Rename release.yml to container-release.yml --- .github/workflows/{release.yml => container-release.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{release.yml => container-release.yml} (100%) diff --git a/.github/workflows/release.yml b/.github/workflows/container-release.yml similarity index 100% rename from .github/workflows/release.yml rename to .github/workflows/container-release.yml From a1aa40f500a9a70f58d1a7d43ef17b37f9cce1df Mon Sep 17 00:00:00 2001 From: Perflyst Date: Thu, 21 Jan 2021 07:45:43 +0100 Subject: [PATCH 0182/2881] Fix container build --- .github/workflows/container-release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index 77877080..b21123d2 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -30,6 +30,8 @@ jobs: - name: Build and push uses: docker/build-push-action@v2 with: + context: . + file: docker/Dockerfile push: true tags: quay.io/invidious/invidious:latest From a00453e1510e6d00d7f109080b37352647a3a83e Mon Sep 17 00:00:00 2001 From: Perflyst Date: Thu, 21 Jan 2021 23:51:54 +0100 Subject: [PATCH 0183/2881] Build latest only on master, add commit sha tag Close #1688 --- .github/workflows/container-release.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index b21123d2..3d61928f 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -4,6 +4,8 @@ on: push: branches: - "master" + pull_request: + branches: "*" jobs: release: @@ -27,7 +29,8 @@ jobs: username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_PASSWORD }} - - name: Build and push + - name: Build and push latest tag + if: github.ref == 'refs/heads/master' uses: docker/build-push-action@v2 with: context: . @@ -35,5 +38,13 @@ jobs: push: true tags: quay.io/invidious/invidious:latest + - name: Build and push commit sha tag + uses: docker/build-push-action@v2 + with: + context: . + file: docker/Dockerfile + push: true + tags: quay.io/invidious/invidious:${{ github.sha }} + - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} From 5ce0b1c18ee3ea722f2f1b1919509bf16c6ba1f6 Mon Sep 17 00:00:00 2001 From: Perflyst Date: Thu, 21 Jan 2021 23:54:31 +0100 Subject: [PATCH 0184/2881] Remove image digest output Does not work with two images at the same time --- .github/workflows/container-release.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index 3d61928f..c8b2e56a 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -45,6 +45,3 @@ jobs: file: docker/Dockerfile push: true tags: quay.io/invidious/invidious:${{ github.sha }} - - - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} From 380b64071eb79ad92ab5d608c1b0b69ffca5d7d6 Mon Sep 17 00:00:00 2001 From: Perflyst Date: Fri, 22 Jan 2021 00:03:09 +0100 Subject: [PATCH 0185/2881] Expire sha images after 6 weeks --- .github/workflows/container-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index c8b2e56a..b1a04f26 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -43,5 +43,6 @@ jobs: with: context: . file: docker/Dockerfile + labels: quay.expires-after=6w push: true tags: quay.io/invidious/invidious:${{ github.sha }} From 40a257982164c70cb12d421f835220abd41ea3c9 Mon Sep 17 00:00:00 2001 From: FireMasterK <20838718+FireMasterK@users.noreply.github.com> Date: Fri, 22 Jan 2021 09:20:17 +0530 Subject: [PATCH 0186/2881] different steps depending on event. --- .github/workflows/container-release.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index b1a04f26..1859f082 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -9,7 +9,6 @@ on: jobs: release: - runs-on: ubuntu-latest steps: @@ -29,16 +28,18 @@ jobs: username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_PASSWORD }} - - name: Build and push latest tag + - name: Build and push for Push Event if: github.ref == 'refs/heads/master' uses: docker/build-push-action@v2 with: context: . file: docker/Dockerfile + labels: quay.expires-after=12w push: true - tags: quay.io/invidious/invidious:latest + tags: quay.io/invidious/invidious:${{ github.sha }},quay.io/invidious/invidious:latest - - name: Build and push commit sha tag + - name: Build and push for Pull Request + if: github.ref != 'refs/heads/master' uses: docker/build-push-action@v2 with: context: . From c86e9dfc8a4f2dddc511f08f19fdeba97ab11143 Mon Sep 17 00:00:00 2001 From: FireMasterK <20838718+FireMasterK@users.noreply.github.com> Date: Fri, 22 Jan 2021 16:54:49 +0530 Subject: [PATCH 0187/2881] build image daily at 0:00 GMT --- .github/workflows/container-release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index 1859f082..137308d0 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -6,6 +6,8 @@ on: - "master" pull_request: branches: "*" + schedule: + - cron: 0 0 * * * jobs: release: From 799f97e847fe92ce2463847b821a499ca54c8e31 Mon Sep 17 00:00:00 2001 From: TheFrenchGhosty Date: Sat, 23 Jan 2021 18:07:55 +0100 Subject: [PATCH 0188/2881] Make invidious use all the translation files --- src/invidious.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/invidious.cr b/src/invidious.cr index deb24ac3..10c23dac 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -84,9 +84,9 @@ LOCALES = { "en-US" => load_locale("en-US"), "eo" => load_locale("eo"), "es" => load_locale("es"), - "eu" => load_locale("eu"), + "fa" => load_locale("fa"), "fr" => load_locale("fr"), - "hu" => load_locale("hu-HU"), + "hr" => load_locale("hr"), "is" => load_locale("is"), "it" => load_locale("it"), "ja" => load_locale("ja"), From 1996e6afaad350e5205135919461b96fe04e12ac Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 23 Jan 2021 18:10:51 +0100 Subject: [PATCH 0189/2881] Translations update from Weblate (#1696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update German translation * Add Bengali (Bangladesh) translation * Update Bengali (Bangladesh) translation * Update Portuguese (Portugal) translation * Update Icelandic translation * Update Bengali (Bangladesh) translation * Update Bengali (Bangladesh) translation * Add Catalan translation * Update Norwegian Bokmål translation * Add French (Canada) translation * Update German translation * Update Polish translation * Update Dutch translation * Update French translation * Update Italian translation * Update Greek translation * Update French (Canada) translation * Update Turkish translation * Update Norwegian Bokmål translation * Update Esperanto translation * Update Esperanto translation * Add Slovak translation * Update Slovak translation * Update Serbian (cyrillic) translation * Update Slovak translation * Update Esperanto translation * Add Persian translation * Update Persian translation * Add Kannada translation * Update Kannada translation * Update Bengali (Bangladesh) translation * Update Spanish translation * Update Portuguese (Brazil) translation * Update Chinese (Traditional) translation * Update Swedish translation * Update Portuguese (Portugal) translation * Add Finnish translation * Update Dutch translation * Update Finnish translation * Add Indonesian translation * Add Portuguese translation * Update Portuguese (Portugal) translation * Update Indonesian translation * Update Portuguese translation * Update Polish translation * Update Polish translation * Update Portuguese (Brazil) translation * Update Chinese (Simplified) translation * Add Croatian translation * Update Croatian translation * Update Norwegian Bokmål translation * Update Swedish translation * Update Croatian translation * Update Japanese translation * Update Indonesian translation * Add Danish translation * Update Bengali (Bangladesh) translation * Update Danish translation * Update Chinese (Simplified) translation * Update French (Canada) translation * Add Malayalam translation * Update Swedish translation * Update Greek translation * Update Greek translation * Update Portuguese (Brazil) translation * Delete Catalan translation * Delete Malayalam translation * Delete Kannada translation * Delete French (Canada) translation * Delete Portuguese translation Co-authored-by: Jeannette L Co-authored-by: Oymate Co-authored-by: Paulo Marinho Co-authored-by: recette-lemon <854qskawygnrtcdo@protonmail.com> Co-authored-by: Adolfo Jayme Barrientos Co-authored-by: Allan Nordhøy Co-authored-by: Oğuz Ersen Co-authored-by: Attila Farkas Co-authored-by: bongo bongo Co-authored-by: nathgit Co-authored-by: Kevin Scruff Co-authored-by: Yogesh Co-authored-by: The Cats Co-authored-by: FeiYang Co-authored-by: Luna Jernberg Co-authored-by: ssantos Co-authored-by: Unihuppio Co-authored-by: Joachim Opdenakker Co-authored-by: ziasukmana Co-authored-by: Atrate Co-authored-by: Karol Kosek Co-authored-by: André Marcelo Alvarenga Co-authored-by: Eric Co-authored-by: Milo Ivir Co-authored-by: Petter Reinholdtsen Co-authored-by: Y. Sakamoto Co-authored-by: Nimityx Co-authored-by: HackerNCoder Co-authored-by: vachan-maker Co-authored-by: fresh Co-authored-by: TheFrenchGhosty --- locales/bn_BD.json | 353 +++++++++++++++++++++++++++++++++++++++ locales/da.json | 387 +++++++++++++++++++++++++++++++++++++++++++ locales/de.json | 24 +-- locales/el.json | 148 +++++++---------- locales/eo.json | 14 +- locales/es.json | 6 +- locales/fa.json | 353 +++++++++++++++++++++++++++++++++++++++ locales/fi.json | 353 +++++++++++++++++++++++++++++++++++++++ locales/fr.json | 44 ++--- locales/hr.json | 387 +++++++++++++++++++++++++++++++++++++++++++ locales/id.json | 387 +++++++++++++++++++++++++++++++++++++++++++ locales/is.json | 66 ++++---- locales/it.json | 102 ++++-------- locales/ja.json | 6 +- locales/nb-NO.json | 144 ++++++++-------- locales/nl.json | 20 +-- locales/pl.json | 6 +- locales/pt-BR.json | 136 +++++++-------- locales/pt-PT.json | 124 +++++--------- locales/sk.json | 353 +++++++++++++++++++++++++++++++++++++++ locales/sr_Cyrl.json | 206 +++++++++++------------ locales/sv-SE.json | 216 ++++++++++++------------ locales/tr.json | 4 +- locales/zh-CN.json | 92 +++++----- locales/zh-TW.json | 92 ++++------ 25 files changed, 3236 insertions(+), 787 deletions(-) create mode 100644 locales/bn_BD.json create mode 100644 locales/da.json create mode 100644 locales/fa.json create mode 100644 locales/fi.json create mode 100644 locales/hr.json create mode 100644 locales/id.json create mode 100644 locales/sk.json diff --git a/locales/bn_BD.json b/locales/bn_BD.json new file mode 100644 index 00000000..8356424f --- /dev/null +++ b/locales/bn_BD.json @@ -0,0 +1,353 @@ +{ + "`x` subscribers.([^.,0-9]|^)1([^.,0-9]|$)": "`x` সাবস্ক্রাইবার।([^.,0-9]|^)1([^.,0-9]|$)", + "`x` subscribers.": "`x` সাবস্ক্রাইবার।", + "`x` videos.([^.,0-9]|^)1([^.,0-9]|$)": "`x` ভিডিও।([^.,0-9]|^)1([^.,0-9]|$)", + "`x` videos.": "`x` ভিডিও।", + "`x` playlists.([^.,0-9]|^)1([^.,0-9]|$)": "`x` প্লেলিস্ট।[^.,0-9]|^)1([^.,0-9]|$)", + "`x` playlists.": "`x` প্লেলিস্ট।", + "LIVE": "লাইভ", + "Shared `x` ago": "`x` আগে শেয়ার করা হয়েছে", + "Unsubscribe": "আনসাবস্ক্রাইব", + "Subscribe": "সাবস্ক্রাইব", + "View channel on YouTube": "ইউটিউবে চ্যানেল দেখুন", + "View playlist on YouTube": "ইউটিউবে প্লেলিস্ট দেখুন", + "newest": "সর্ব-নতুন", + "oldest": "পুরানতম", + "popular": "জনপ্রিয়", + "last": "শেষটা", + "Next page": "পরের পৃষ্ঠা", + "Previous page": "আগের পৃষ্ঠা", + "Clear watch history?": "দেখার ইতিহাস সাফ করবেন?", + "New password": "নতুন পাসওয়ার্ড", + "New passwords must match": "নতুন পাসওয়ার্ড অবশ্যই মিলতে হবে", + "Cannot change password for Google accounts": "গুগল অ্যাকাউন্টগুলোর জন্য পাসওয়ার্ড পরিবর্তন করা যায় না", + "Authorize token?": "টোকেন অনুমোদন করবেন?", + "Authorize token for `x`?": "`x` -এর জন্য টোকেন অনুমোদন?", + "Yes": "হ্যাঁ", + "No": "না", + "Import and Export Data": "তথ্য আমদানি ও রপ্তানি", + "Import": "আমদানি", + "Import Invidious data": "ইনভিডিয়াস তথ্য আমদানি", + "Import YouTube subscriptions": "ইউটিউব সাবস্ক্রিপশন আনুন", + "Import FreeTube subscriptions (.db)": "ফ্রিটিউব সাবস্ক্রিপশন (.db) আনুন", + "Import NewPipe subscriptions (.json)": "নতুন পাইপ সাবস্ক্রিপশন আনুন (.json)", + "Import NewPipe data (.zip)": "নিউপাইপ তথ্য আনুন (.zip)", + "Export": "তথ্য বের করুন", + "Export subscriptions as OPML": "সাবস্ক্রিপশন OPML হিসাবে আনুন", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML-এ সাবস্ক্রিপশন বের করুন(নিউ পাইপ এবং ফ্রিউটিউব এর জন্য)", + "Export data as JSON": "JSON হিসাবে তথ্য বের করুন", + "Delete account?": "অ্যাকাউন্ট মুছে ফেলবেন?", + "History": "ইতিহাস", + "An alternative front-end to YouTube": "ইউটিউবের একটি বিকল্পস্বরূপ সম্মুখ-প্রান্ত", + "JavaScript license information": "জাভাস্ক্রিপ্ট লাইসেন্সের তথ্য", + "source": "সূত্র", + "Log in": "লগ ইন", + "Log in/register": "লগ ইন/রেজিস্টার", + "Log in with Google": "গুগল দিয়ে লগ ইন করুন", + "User ID": "ইউজার আইডি", + "Password": "পাসওয়ার্ড", + "Time (h:mm:ss):": "সময় (ঘণ্টা:মিনিট:সেকেন্ড):", + "Text CAPTCHA": "টেক্সট ক্যাপচা", + "Image CAPTCHA": "চিত্র ক্যাপচা", + "Sign In": "সাইন ইন", + "Register": "নিবন্ধন", + "E-mail": "ই-মেইল", + "Google verification code": "গুগল যাচাইকরণ কোড", + "Preferences": "পছন্দসমূহ", + "Player preferences": "প্লেয়ারের পছন্দসমূহ", + "Always loop: ": "সর্বদা লুপ: ", + "Autoplay: ": "স্বয়ংক্রিয় চালু: ", + "Play next by default: ": "ডিফল্টভাবে পরবর্তী চালাও: ", + "Autoplay next video: ": "পরবর্তী ভিডিও স্বয়ংক্রিয়ভাবে চালাও: ", + "Listen by default: ": "", + "Proxy videos: ": "", + "Default speed: ": "", + "Preferred video quality: ": "", + "Player volume: ": "", + "Default comments: ": "", + "youtube": "", + "reddit": "", + "Default captions: ": "", + "Fallback captions: ": "", + "Show related videos: ": "", + "Show annotations by default: ": "", + "Visual preferences": "", + "Player style: ": "", + "Dark mode: ": "", + "Theme: ": "", + "dark": "", + "light": "", + "Thin mode: ": "", + "Subscription preferences": "", + "Show annotations by default for subscribed channels: ": "", + "Redirect homepage to feed: ": "", + "Number of videos shown in feed: ": "", + "Sort videos by: ": "", + "published": "", + "published - reverse": "", + "alphabetically": "", + "alphabetically - reverse": "", + "channel name": "", + "channel name - reverse": "", + "Only show latest video from channel: ": "", + "Only show latest unwatched video from channel: ": "", + "Only show unwatched: ": "", + "Only show notifications (if there are any): ": "", + "Enable web notifications": "", + "`x` uploaded a video": "", + "`x` is live": "", + "Data preferences": "", + "Clear watch history": "", + "Import/export data": "", + "Change password": "", + "Manage subscriptions": "", + "Manage tokens": "", + "Watch history": "", + "Delete account": "", + "Administrator preferences": "", + "Default homepage: ": "", + "Feed menu: ": "", + "Top enabled: ": "", + "CAPTCHA enabled: ": "", + "Login enabled: ": "", + "Registration enabled: ": "", + "Report statistics: ": "", + "Save preferences": "", + "Subscription manager": "", + "Token manager": "", + "Token": "", + "`x` subscriptions.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` subscriptions.": "", + "`x` tokens.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` tokens.": "", + "Import/export": "", + "unsubscribe": "", + "revoke": "", + "Subscriptions": "", + "`x` unseen notifications.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` unseen notifications.": "", + "search": "", + "Log out": "", + "Released under the AGPLv3 by Omar Roth.": "", + "Source available here.": "", + "View JavaScript license information.": "", + "View privacy policy.": "", + "Trending": "", + "Public": "", + "Unlisted": "", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", + "Watch on YouTube": "", + "Hide annotations": "", + "Show annotations": "", + "Genre: ": "", + "License: ": "", + "Family friendly? ": "", + "Wilson score: ": "", + "Engagement: ": "", + "Whitelisted regions: ": "", + "Blacklisted regions: ": "", + "Shared `x`": "", + "`x` views.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` views.": "", + "Premieres in `x`": "", + "Premieres `x`": "", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "", + "View YouTube comments": "", + "View more comments on Reddit": "", + "View `x` comments.([^.,0-9]|^)1([^.,0-9]|$)": "", + "View `x` comments.": "", + "View Reddit comments": "", + "Hide replies": "", + "Show replies": "", + "Incorrect password": "", + "Quota exceeded, try again in a few hours": "", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "", + "Invalid TFA code": "", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "", + "Wrong answer": "", + "Erroneous CAPTCHA": "", + "CAPTCHA is a required field": "", + "User ID is a required field": "", + "Password is a required field": "", + "Wrong username or password": "", + "Please sign in using 'Log in with Google'": "", + "Password cannot be empty": "", + "Password cannot be longer than 55 characters": "", + "Please log in": "", + "Invidious Private Feed for `x`": "", + "channel:`x`": "", + "Deleted or invalid channel": "", + "This channel does not exist.": "", + "Could not get channel info.": "", + "Could not fetch comments": "", + "View `x` replies.([^.,0-9]|^)1([^.,0-9]|$)": "", + "View `x` replies.": "", + "`x` ago": "", + "Load more": "", + "`x` points.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` points.": "", + "Could not create mix.": "", + "Empty playlist": "", + "Not a playlist.": "", + "Playlist does not exist.": "", + "Could not pull trending pages.": "", + "Hidden field \"challenge\" is a required field": "", + "Hidden field \"token\" is a required field": "", + "Erroneous challenge": "", + "Erroneous token": "", + "No such user": "", + "Token is expired, please try again": "", + "English": "", + "English (auto-generated)": "", + "Afrikaans": "", + "Albanian": "", + "Amharic": "", + "Arabic": "", + "Armenian": "", + "Azerbaijani": "", + "Bangla": "", + "Basque": "", + "Belarusian": "", + "Bosnian": "", + "Bulgarian": "", + "Burmese": "", + "Catalan": "", + "Cebuano": "", + "Chinese (Simplified)": "", + "Chinese (Traditional)": "", + "Corsican": "", + "Croatian": "", + "Czech": "", + "Danish": "", + "Dutch": "", + "Esperanto": "", + "Estonian": "", + "Filipino": "", + "Finnish": "", + "French": "", + "Galician": "", + "Georgian": "", + "German": "", + "Greek": "", + "Gujarati": "", + "Haitian Creole": "", + "Hausa": "", + "Hawaiian": "", + "Hebrew": "", + "Hindi": "", + "Hmong": "", + "Hungarian": "", + "Icelandic": "", + "Igbo": "", + "Indonesian": "", + "Irish": "", + "Italian": "", + "Japanese": "", + "Javanese": "", + "Kannada": "", + "Kazakh": "", + "Khmer": "", + "Korean": "", + "Kurdish": "", + "Kyrgyz": "", + "Lao": "", + "Latin": "", + "Latvian": "", + "Lithuanian": "", + "Luxembourgish": "", + "Macedonian": "", + "Malagasy": "", + "Malay": "", + "Malayalam": "", + "Maltese": "", + "Maori": "", + "Marathi": "", + "Mongolian": "", + "Nepali": "", + "Norwegian Bokmål": "", + "Nyanja": "", + "Pashto": "", + "Persian": "", + "Polish": "", + "Portuguese": "", + "Punjabi": "", + "Romanian": "", + "Russian": "", + "Samoan": "", + "Scottish Gaelic": "", + "Serbian": "", + "Shona": "", + "Sindhi": "", + "Sinhala": "", + "Slovak": "", + "Slovenian": "", + "Somali": "", + "Southern Sotho": "", + "Spanish": "", + "Spanish (Latin America)": "", + "Sundanese": "", + "Swahili": "", + "Swedish": "", + "Tajik": "", + "Tamil": "", + "Telugu": "", + "Thai": "", + "Turkish": "", + "Ukrainian": "", + "Urdu": "", + "Uzbek": "", + "Vietnamese": "", + "Welsh": "", + "Western Frisian": "", + "Xhosa": "", + "Yiddish": "", + "Yoruba": "", + "Zulu": "", + "`x` years.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` years.": "", + "`x` months.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` months.": "", + "`x` weeks.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` weeks.": "", + "`x` days.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` days.": "", + "`x` hours.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` hours.": "", + "`x` minutes.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` minutes.": "", + "`x` seconds.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` seconds.": "", + "Fallback comments: ": "", + "Popular": "", + "Top": "", + "About": "", + "Rating: ": "", + "Language: ": "", + "View as playlist": "", + "Default": "", + "Music": "", + "Gaming": "", + "News": "", + "Movies": "", + "Download": "", + "Download as: ": "", + "%A %B %-d, %Y": "", + "(edited)": "", + "YouTube comment permalink": "", + "permalink": "", + "`x` marked it with a ❤": "", + "Audio mode": "", + "Video mode": "", + "Videos": "", + "Playlists": "", + "Community": "", + "Current version: ": "" +} diff --git a/locales/da.json b/locales/da.json new file mode 100644 index 00000000..1944e47b --- /dev/null +++ b/locales/da.json @@ -0,0 +1,387 @@ +{ + "`x` subscribers": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnenter.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` abonnenter." + }, + "`x` videos": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videoer.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` videoer." + }, + "`x` playlists": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` afspilningslister.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` afspilningslister." + }, + "LIVE": "DIREKTE", + "Shared `x` ago": "Delt for `x` siden", + "Unsubscribe": "", + "Subscribe": "Abonner", + "View channel on YouTube": "Vis kanal på YouTube", + "View playlist on YouTube": "Vis afspilningsliste på YouTube", + "newest": "nyeste", + "oldest": "ældste", + "popular": "populært", + "last": "sidste", + "Next page": "Næste side", + "Previous page": "Forrige side", + "Clear watch history?": "Ryd afspilningshistorik?", + "New password": "Nyt kodeord", + "New passwords must match": "Nye kodeord skal matche", + "Cannot change password for Google accounts": "Kan ikke skifte kodeord til Google-konti", + "Authorize token?": "Godkend token?", + "Authorize token for `x`?": "Godkende token til `x`?", + "Yes": "Ja", + "No": "Nej", + "Import and Export Data": "Importer og Eksporter Data", + "Import": "Importer", + "Import Invidious data": "Importer Invidious data", + "Import YouTube subscriptions": "Importer Youtube abonnementer", + "Import FreeTube subscriptions (.db)": "Importer FreeTube abonnementer (.db)", + "Import NewPipe subscriptions (.json)": "Importer NewPipe abonnementer (.json)", + "Import NewPipe data (.zip)": "Importer NewPipe data (.zip)", + "Export": "Exporter", + "Export subscriptions as OPML": "Exporter abonnementer som OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter abonnementer som OPML (til NewPipe & FreeTube)", + "Export data as JSON": "Exporter data som JSON", + "Delete account?": "Slet konto?", + "History": "Historik", + "An alternative front-end to YouTube": "", + "JavaScript license information": "JavaScript licens information", + "source": "kilde", + "Log in": "Log på", + "Log in/register": "Log på/registrer", + "Log in with Google": "Log på med Google", + "User ID": "Bruger ID", + "Password": "Kodeord", + "Time (h:mm:ss):": "Tid (t:mm:ss):", + "Text CAPTCHA": "Tekst CAPTCHA", + "Image CAPTCHA": "Billede CAPTCHA", + "Sign In": "Log ind", + "Register": "Registrer", + "E-mail": "E-mail", + "Google verification code": "Google verifications kode", + "Preferences": "Præferencer", + "Player preferences": "", + "Always loop: ": "Altid gentag: ", + "Autoplay: ": "Auto afspil: ", + "Play next by default: ": "Afspil næste som standard: ", + "Autoplay next video: ": "Auto afspil næste video: ", + "Listen by default: ": "Lyt som standard: ", + "Proxy videos: ": "Proxy videoer: ", + "Default speed: ": "Standard hastighed: ", + "Preferred video quality: ": "Foretrukken video kvalitet: ", + "Player volume: ": "Lydstyrke: ", + "Default comments: ": "Standard kommentarer: ", + "youtube": "youtube", + "reddit": "reddit", + "Default captions: ": "", + "Fallback captions: ": "", + "Show related videos: ": "", + "Show annotations by default: ": "", + "Visual preferences": "", + "Player style: ": "", + "Dark mode: ": "", + "Theme: ": "", + "dark": "", + "light": "", + "Thin mode: ": "", + "Subscription preferences": "", + "Show annotations by default for subscribed channels: ": "", + "Redirect homepage to feed: ": "", + "Number of videos shown in feed: ": "", + "Sort videos by: ": "", + "published": "", + "published - reverse": "", + "alphabetically": "", + "alphabetically - reverse": "", + "channel name": "", + "channel name - reverse": "", + "Only show latest video from channel: ": "", + "Only show latest unwatched video from channel: ": "", + "Only show unwatched: ": "", + "Only show notifications (if there are any): ": "", + "Enable web notifications": "", + "`x` uploaded a video": "", + "`x` is live": "", + "Data preferences": "", + "Clear watch history": "", + "Import/export data": "", + "Change password": "", + "Manage subscriptions": "", + "Manage tokens": "", + "Watch history": "", + "Delete account": "", + "Administrator preferences": "", + "Default homepage: ": "", + "Feed menu: ": "", + "Top enabled: ": "", + "CAPTCHA enabled: ": "", + "Login enabled: ": "", + "Registration enabled: ": "", + "Report statistics: ": "", + "Save preferences": "", + "Subscription manager": "", + "Token manager": "", + "Token": "", + "`x` subscriptions": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` tokens": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Import/export": "", + "unsubscribe": "", + "revoke": "", + "Subscriptions": "", + "`x` unseen notifications": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "search": "", + "Log out": "", + "Released under the AGPLv3 by Omar Roth.": "", + "Source available here.": "", + "View JavaScript license information.": "", + "View privacy policy.": "", + "Trending": "", + "Public": "", + "Unlisted": "", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", + "Watch on YouTube": "", + "Hide annotations": "", + "Show annotations": "", + "Genre: ": "", + "License: ": "", + "Family friendly? ": "", + "Wilson score: ": "", + "Engagement: ": "", + "Whitelisted regions: ": "", + "Blacklisted regions: ": "", + "Shared `x`": "", + "`x` views": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Premieres in `x`": "", + "Premieres `x`": "", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "", + "View YouTube comments": "", + "View more comments on Reddit": "", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "View Reddit comments": "", + "Hide replies": "", + "Show replies": "", + "Incorrect password": "", + "Quota exceeded, try again in a few hours": "", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "", + "Invalid TFA code": "", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "", + "Wrong answer": "", + "Erroneous CAPTCHA": "", + "CAPTCHA is a required field": "", + "User ID is a required field": "", + "Password is a required field": "", + "Wrong username or password": "", + "Please sign in using 'Log in with Google'": "", + "Password cannot be empty": "", + "Password cannot be longer than 55 characters": "", + "Please log in": "", + "Invidious Private Feed for `x`": "", + "channel:`x`": "", + "Deleted or invalid channel": "", + "This channel does not exist.": "", + "Could not get channel info.": "", + "Could not fetch comments": "", + "View `x` replies": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` ago": "", + "Load more": "", + "`x` points": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Could not create mix.": "", + "Empty playlist": "", + "Not a playlist.": "", + "Playlist does not exist.": "", + "Could not pull trending pages.": "", + "Hidden field \"challenge\" is a required field": "", + "Hidden field \"token\" is a required field": "", + "Erroneous challenge": "", + "Erroneous token": "", + "No such user": "", + "Token is expired, please try again": "", + "English": "", + "English (auto-generated)": "", + "Afrikaans": "", + "Albanian": "", + "Amharic": "", + "Arabic": "", + "Armenian": "", + "Azerbaijani": "", + "Bangla": "", + "Basque": "", + "Belarusian": "", + "Bosnian": "", + "Bulgarian": "", + "Burmese": "", + "Catalan": "", + "Cebuano": "", + "Chinese (Simplified)": "", + "Chinese (Traditional)": "", + "Corsican": "", + "Croatian": "", + "Czech": "", + "Danish": "", + "Dutch": "", + "Esperanto": "", + "Estonian": "", + "Filipino": "", + "Finnish": "", + "French": "", + "Galician": "", + "Georgian": "", + "German": "", + "Greek": "", + "Gujarati": "", + "Haitian Creole": "", + "Hausa": "", + "Hawaiian": "", + "Hebrew": "", + "Hindi": "", + "Hmong": "", + "Hungarian": "", + "Icelandic": "", + "Igbo": "", + "Indonesian": "", + "Irish": "", + "Italian": "", + "Japanese": "", + "Javanese": "", + "Kannada": "", + "Kazakh": "", + "Khmer": "", + "Korean": "", + "Kurdish": "", + "Kyrgyz": "", + "Lao": "", + "Latin": "", + "Latvian": "", + "Lithuanian": "", + "Luxembourgish": "", + "Macedonian": "", + "Malagasy": "", + "Malay": "", + "Malayalam": "", + "Maltese": "", + "Maori": "", + "Marathi": "", + "Mongolian": "", + "Nepali": "", + "Norwegian Bokmål": "", + "Nyanja": "", + "Pashto": "", + "Persian": "", + "Polish": "", + "Portuguese": "", + "Punjabi": "", + "Romanian": "", + "Russian": "", + "Samoan": "", + "Scottish Gaelic": "", + "Serbian": "", + "Shona": "", + "Sindhi": "", + "Sinhala": "", + "Slovak": "", + "Slovenian": "", + "Somali": "", + "Southern Sotho": "", + "Spanish": "", + "Spanish (Latin America)": "", + "Sundanese": "", + "Swahili": "", + "Swedish": "", + "Tajik": "", + "Tamil": "", + "Telugu": "", + "Thai": "", + "Turkish": "", + "Ukrainian": "", + "Urdu": "", + "Uzbek": "", + "Vietnamese": "", + "Welsh": "", + "Western Frisian": "", + "Xhosa": "", + "Yiddish": "", + "Yoruba": "", + "Zulu": "", + "`x` years": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` months": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` weeks": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` days": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` hours": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` minutes": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` seconds": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Fallback comments: ": "", + "Popular": "", + "Top": "", + "About": "", + "Rating: ": "", + "Language: ": "", + "View as playlist": "", + "Default": "", + "Music": "", + "Gaming": "", + "News": "", + "Movies": "", + "Download": "", + "Download as: ": "", + "%A %B %-d, %Y": "", + "(edited)": "", + "YouTube comment permalink": "", + "permalink": "", + "`x` marked it with a ❤": "", + "Audio mode": "", + "Video mode": "", + "Videos": "", + "Playlists": "", + "Community": "", + "Current version: ": "" +} diff --git a/locales/de.json b/locales/de.json index b685a842..b995baac 100644 --- a/locales/de.json +++ b/locales/de.json @@ -33,14 +33,14 @@ "Export subscriptions as OPML": "Abonnements als OPML exportieren", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Abonnements als OPML exportieren (für NewPipe & FreeTube)", "Export data as JSON": "Daten als JSON exportieren", - "Delete account?": "Account löschen?", + "Delete account?": "Konto löschen?", "History": "Verlauf", "An alternative front-end to YouTube": "Eine alternative Oberfläche für YouTube", "JavaScript license information": "JavaScript Lizenzinformationen", "source": "Quelle", - "Log in": "Einloggen", - "Log in/register": "Einloggen/Registrieren", - "Log in with Google": "Mit Google einloggen", + "Log in": "Anmelden", + "Log in/register": "Anmelden/registrieren", + "Log in with Google": "Mit Google anmelden", "User ID": "Benutzer ID", "Password": "Passwort", "Time (h:mm:ss):": "Zeit (h:mm:ss):", @@ -106,7 +106,7 @@ "Feed menu: ": "Feed-Menü: ", "Top enabled: ": "Top aktiviert? ", "CAPTCHA enabled: ": "CAPTCHA aktiviert? ", - "Login enabled: ": "Login aktiviert? ", + "Login enabled: ": "Anmeldung aktiviert: ", "Registration enabled: ": "Registrierung aktiviert? ", "Report statistics: ": "Statistiken berichten? ", "Save preferences": "Einstellungen speichern", @@ -161,16 +161,16 @@ "Show replies": "Antworten anzeigen", "Incorrect password": "Falsches Passwort", "Quota exceeded, try again in a few hours": "Kontingent überschritten, versuche es in ein paar Stunden erneut", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Login nicht möglich, stellen Sie sicher dass two-factor Authentifikation (Authentifizierung oder SMS) aktiviert ist.", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Anmeldung nicht möglich, stellen Sie sicher, dass die Zwei-Faktor-Authentisierung (Authenticator oder SMS) aktiviert ist.", "Invalid TFA code": "Ungültiger TFA Code", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Login fehlgeschlagen. Das kann daran liegen dass two-factor Authentifizierung in ihrem Account nicht aktiviert ist.", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "Die Anmeldung ist fehlgeschlagen. Dies kann daran liegen, dass die Zwei-Faktor-Authentisierung für Ihr Konto nicht aktiviert ist.", "Wrong answer": "Ungültige Antwort", "Erroneous CAPTCHA": "Ungültiges CAPTCHA", "CAPTCHA is a required field": "CAPTCHA ist eine erforderliche Eingabe", "User ID is a required field": "Benutzer ID ist eine erforderliche Eingabe", "Password is a required field": "Passwort ist eine erforderliche Eingabe", "Wrong username or password": "Ungültiger Benutzername oder Passwort", - "Please sign in using 'Log in with Google'": "Bitte melden sie sich mit 'Mit Google anmelden' an", + "Please sign in using 'Log in with Google'": "Bitte melden Sie sich mit „Mit Google anmelden“ an", "Password cannot be empty": "Passwort darf nicht leer sein", "Password cannot be longer than 55 characters": "Passwort darf nicht länger als 55 Zeichen sein", "Please log in": "Bitte anmelden", @@ -189,8 +189,8 @@ "Not a playlist.": "Ungültige Playlist.", "Playlist does not exist.": "Playlist existiert nicht.", "Could not pull trending pages.": "Trending Seiten konnten nicht geladen werden.", - "Hidden field \"challenge\" is a required field": "Verstecktes Feld \"challenge\" ist eine erforderliche Eingabe", - "Hidden field \"token\" is a required field": "Verstecktes Feld \"token\" ist eine erforderliche Eingabe", + "Hidden field \"challenge\" is a required field": "Verstecktes Feld „challenge“ ist eine erforderliche Eingabe", + "Hidden field \"token\" is a required field": "Verstecktes Feld „token“ ist eine erforderliche Eingabe", "Erroneous challenge": "Ungültiger Test", "Erroneous token": "Ungültiger Token", "No such user": "Ungültiger Benutzer", @@ -322,7 +322,7 @@ "Movies": "Filme", "Download": "Herunterladen", "Download as: ": "Herunterladen als: ", - "%A %B %-d, %Y": "%A %B %-d, %Y", + "%A %B %-d, %Y": "%A %-d %B %Y", "(edited)": "(editiert)", "YouTube comment permalink": "YouTube-Kommentar Permalink", "permalink": "Permalink", @@ -333,4 +333,4 @@ "Playlists": "Wiedergabelisten", "Community": "Gemeinschaft", "Current version: ": "Aktuelle Version: " -} \ No newline at end of file +} diff --git a/locales/el.json b/locales/el.json index f4249ebc..23b3cdf9 100644 --- a/locales/el.json +++ b/locales/el.json @@ -1,19 +1,15 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` συνδρομητής", - "": "`x` συνδρομητές" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` βίντεο", - "": "`x` βίντεο" - }, - "`x` playlists": "", + "`x` subscribers.([^.,0-9]|^)1([^.,0-9]|$)": "`x` συνδρομητής", + "`x` subscribers.": "`x` συνδρομητές.", + "`x` videos.([^.,0-9]|^)1([^.,0-9]|$)": "`x` βίντεο", + "`x` videos.": "`x` βίντεο.", + "`x` playlists": "`x` κατάλογοι αναπαραγωγής", "LIVE": "ΖΩΝΤΑΝΑ", "Shared `x` ago": "Μοιράστηκε πριν `x`", "Unsubscribe": "Απεγγραφή", "Subscribe": "Εγγραφή", "View channel on YouTube": "Προβολή καναλιού στο YouTube", - "View playlist on YouTube": "", + "View playlist on YouTube": "Προβολή καταλόγου αναπαραγωγής στο YouTube", "newest": "νεότερα", "oldest": "παλιότερα", "popular": "δημοφιλή", @@ -54,7 +50,7 @@ "Image CAPTCHA": "Εικόνα CAPTCHA", "Sign In": "Σύνδεση", "Register": "Εγγραφή", - "E-mail": "E-mail", + "E-mail": "Ηλεκτρονικό ταχυδρομείο", "Google verification code": "Κωδικός επαλήθευσης Google", "Preferences": "Προτιμήσεις", "Player preferences": "Προτιμήσεις αναπαραγωγής", @@ -68,18 +64,18 @@ "Preferred video quality: ": "Προτιμώμενη ανάλυση: ", "Player volume: ": "Ένταση αναπαραγωγής: ", "Default comments: ": "Προεπιλεγμένα σχόλια: ", - "youtube": "youtube", + "youtube": "YouTube", "reddit": "reddit", "Default captions: ": "Προεπιλεγμένοι υπότιτλοι: ", "Fallback captions: ": "Εναλλακτικοί υπότιτλοι: ", "Show related videos: ": "Προβολή σχετικών βίντεο; ", - "Show annotations by default: ": "Αυτόματη προβολή σημειώσεων; :", + "Show annotations by default: ": "Αυτόματη προβολή σημειώσεων: ", "Visual preferences": "Προτιμήσεις εμφάνισης", - "Player style: ": "", + "Player style: ": "Τεχνοτροπία της συσκευής αναπαραγωγης: ", "Dark mode: ": "Σκοτεινή λειτουργία: ", - "Theme: ": "", - "dark": "", - "light": "", + "Theme: ": "Θέμα: ", + "dark": "σκοτεινό", + "light": "φωτεινό", "Thin mode: ": "Ελαφριά λειτουργία: ", "Subscription preferences": "Προτιμήσεις συνδρομών", "Show annotations by default for subscribed channels: ": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ", @@ -96,9 +92,9 @@ "Only show latest unwatched video from channel: ": "Προβολή μόνο του τελευταίου μη-προβεβλημένου βίντεο του καναλιού: ", "Only show unwatched: ": "Προβολή μόνο μη-προβεβλημένων: ", "Only show notifications (if there are any): ": "Προβολή μόνο ειδοποιήσεων (αν υπάρχουν): ", - "Enable web notifications": "", - "`x` uploaded a video": "", - "`x` is live": "", + "Enable web notifications": "Ενεργοποίηση ειδοποιήσεων δικτύου", + "`x` uploaded a video": "`x` κοινοποίησε ένα βίντεο", + "`x` is live": "`x` κάνει live", "Data preferences": "Προτιμήσεις δεδομένων", "Clear watch history": "Εκκαθάριση ιστορικού προβολής", "Import/export data": "Εισαγωγή/εξαγωγή δεδομένων", @@ -119,22 +115,16 @@ "Subscription manager": "Διαχειριστής συνδρομών", "Token manager": "Διαχειριστής διασυνδέσεων", "Token": "Διασύνδεση", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` συνδρομή", - "": "`x` συνδρομές" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` διασύνδεση", - "": "`x` διασυνδέσεις" - }, + "`x` subscriptions.([^.,0-9]|^)1([^.,0-9]|$)": "`x` συνδρομή", + "`x` subscriptions.": "`x` συνδρομές.", + "`x` tokens.([^.,0-9]|^)1([^.,0-9]|$)": "`x` διασύνδεση", + "`x` tokens.": "`x` διασυνδέσεις.", "Import/export": "Εισαγωγή/εξαγωγή", "unsubscribe": "κατάργηση συνδρομής", "revoke": "ανάκληση", "Subscriptions": "Συνδρομές", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` καινούρια ειδοποίηση", - "": "`x` καινούριες ειδοποιήσεις" - }, + "`x` unseen notifications.([^.,0-9]|^)1([^.,0-9]|$)": "`x` καινούρια ειδοποίηση", + "`x` unseen notifications.": "`x` καινούριες ειδοποιήσεις.", "search": "αναζήτηση", "Log out": "Αποσύνδεση", "Released under the AGPLv3 by Omar Roth.": "Κυκλοφορεί υπό την άδεια AGPLv3 από τον Omar Roth.", @@ -142,35 +132,33 @@ "View JavaScript license information.": "Προβολή πληροφοριών άδειας JavaScript.", "View privacy policy.": "Προβολή πολιτικής απορρήτου.", "Trending": "Τάσεις", - "Public": "", + "Public": "Δημόσιο", "Unlisted": "Κρυφό", - "Private": "", - "View all playlists": "", - "Updated `x` ago": "", - "Delete playlist `x`?": "", - "Delete playlist": "", - "Create playlist": "", - "Title": "", - "Playlist privacy": "", - "Editing playlist `x`": "", + "Private": "Ιδιωτικό", + "View all playlists": "Προβολή όλων των καταλόγων αναπαραγωγής", + "Updated `x` ago": "Ενημερώθηκε πριν από `x`", + "Delete playlist `x`?": "Διαγραφή `x` καταλόγου αναπαραγωγής;", + "Delete playlist": "Διαγραφή καταλόγου αναπαραγωγής", + "Create playlist": "Δημιουργία καταλόγου αναπαραγωγής", + "Title": "Τίτλος", + "Playlist privacy": "Ιδιωτικότητα καταλόγων αναπαραγωγής", + "Editing playlist `x`": "Επεξεργασία `x` καταλόγου αναπαραγωγής", "Watch on YouTube": "Προβολή στο YouTube", "Hide annotations": "Απόκρυψη σημειώσεων", "Show annotations": "Προβολή σημειώσεων", "Genre: ": "Είδος: ", "License: ": "Άδεια: ", "Family friendly? ": "Φιλικό προς την οικογένεια; ", - "Wilson score: ": "Wilson score: ", + "Wilson score: ": "Αποτελέσματα Wilson: ", "Engagement: ": "Ενδιαφέρον: ", "Whitelisted regions: ": "Επιτρεπτές περιοχές: ", "Blacklisted regions: ": "Μη-επιτρεπτές περιοχές: ", "Shared `x`": "Μοιράστηκε το `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` προβολή", - "": "`x` προβολές" - }, + "`x` views.([^.,0-9]|^)1([^.,0-9]|$)": "`x` προβολή", + "`x` views.": "`x` προβολές.", "Premieres in `x`": "Πρώτη προβολή σε `x`", - "Premieres `x`": "", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Γεια! Φαίνεται πως έχετε απενεργοποιήσει το JavaScript. Πατήστε εδώ για προβολή σχολίων, αλλά έχετε υπ'όψιν σας πως ίσως φορτώσουν πιο αργά. ", + "Premieres `x`": "Επίσημη πρώτη παράσταση του `x`", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Γεια! Φαίνεται πως έχετε απενεργοποιήσει το JavaScript. Πατήστε εδώ για προβολή σχολίων, αλλά έχετε υπ'όψιν σας πως ίσως φορτώσουν πιο αργά.", "View YouTube comments": "Προβολή σχολίων από το YouTube", "View more comments on Reddit": "Προβολή περισσότερων σχολίων στο Reddit", "View `x` comments": "Προβολή `x` σχολίων", @@ -198,19 +186,15 @@ "This channel does not exist.": "Αυτό το κανάλι δεν υπάρχει.", "Could not get channel info.": "Αδύναμια εύρεσης πληροφοριών καναλιού.", "Could not fetch comments": "Αδυναμία λήψης σχολίων", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Προβολή `x` απάντησης", - "": "Προβολή `x` απαντήσεων" - }, + "View `x` replies.([^.,0-9]|^)1([^.,0-9]|$)": "Προβολή `x` απάντησης", + "View `x` replies.": "Προβολή `x` απαντήσεων.", "`x` ago": "Πριν `x`", "Load more": "Φόρτωση περισσότερων", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` βαθμός", - "": "`x` βαθμοί" - }, + "`x` points.([^.,0-9]|^)1([^.,0-9]|$)": "`x` βαθμός", + "`x` points.": "`x` βαθμοί.", "Could not create mix.": "Αδυναμία δημιουργίας μίξης.", "Empty playlist": "Κενή λίστα αναπαραγωγής", - "Not a playlist.": "Μη έγκυρη λίστα αναπαραγωγής", + "Not a playlist.": "Μη έγκυρη λίστα αναπαραγωγής.", "Playlist does not exist.": "Μη υπαρκτή λίστα αναπαραγωγής.", "Could not pull trending pages.": "Αδυναμία λήψης σελίδας τάσεων.", "Hidden field \"challenge\" is a required field": "Το Κρυφό πεδίο \"δοκιμασία\" είναι απαραίτητο", @@ -325,34 +309,20 @@ "Yiddish": "Γίντις", "Yoruba": "Γιορούμπα", "Zulu": "Ζουλού", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` χρόνο", - "": "`x` χρόνια" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` μήνα", - "": "`x` μήνες" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` εβδομάδα", - "": "`x` εβδομάδες" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ημέρα", - "": "`x` ημέρες" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ώρα", - "": "`x` ώρες" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` λεπτό", - "": "`x` λεπτά" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` δευτερόλεπτο", - "": "`x` δευτερόλεπτα" - }, + "`x` years.([^.,0-9]|^)1([^.,0-9]|$)": "`x` χρόνο", + "`x` years.": "`x` χρόνια.", + "`x` months.([^.,0-9]|^)1([^.,0-9]|$)": "`x` μήνα", + "`x` months.": "`x` μήνες.", + "`x` weeks.([^.,0-9]|^)1([^.,0-9]|$)": "`x` εβδομάδα", + "`x` weeks.": "`x` εβδομάδες.", + "`x` days.([^.,0-9]|^)1([^.,0-9]|$)": "`x` ημέρα", + "`x` days.": "`x` ημέρες.", + "`x` hours.([^.,0-9]|^)1([^.,0-9]|$)": "`x` ώρα", + "`x` hours.": "`x` ώρες.", + "`x` minutes.([^.,0-9]|^)1([^.,0-9]|$)": "`x` λεπτό", + "`x` minutes.": "`x` λεπτά.", + "`x` seconds.([^.,0-9]|^)1([^.,0-9]|$)": "`x` δευτερόλεπτο", + "`x` seconds.": "`x` δευτερόλεπτα.", "Fallback comments: ": "Εναλλακτικά σχόλια: ", "Popular": "Δημοφιλή", "Top": "Κορυφαία", @@ -370,12 +340,12 @@ "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(τροποποιημένο)", "YouTube comment permalink": "Σύνδεσμος YouTube σχολίου", - "permalink": "", + "permalink": "μόνιμος σύνδεσμος", "`x` marked it with a ❤": "Ο χρηστης `x` έβαλε ❤", "Audio mode": "Λειτουργία ήχου", "Video mode": "Λειτουργία βίντεο", "Videos": "Βίντεο", "Playlists": "Λίστες Αναπαραγωγής", - "Community": "", + "Community": "Κοινότητα", "Current version: ": "Τρέχουσα έκδοση: " -} \ No newline at end of file +} diff --git a/locales/eo.json b/locales/eo.json index ae640e37..3052ac35 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -4,10 +4,10 @@ "`x` playlists": "`x` ludlistoj", "LIVE": "NUNA", "Shared `x` ago": "Konigita antaŭ `x`", - "Unsubscribe": "Malaboni", - "Subscribe": "Aboni", - "View channel on YouTube": "Vidi kanalon en JuTubo", - "View playlist on YouTube": "Vidi ludliston en JuTubo", + "Unsubscribe": "Malabonu", + "Subscribe": "Abonu", + "View channel on YouTube": "Vidu kanalon en JuTubo", + "View playlist on YouTube": "Vidu ludliston en JuTubo", "newest": "pli novaj", "oldest": "pli malnovaj", "popular": "popularaj", @@ -116,7 +116,7 @@ "`x` subscriptions": "`x` abonoj", "`x` tokens": "`x` ĵetonoj", "Import/export": "Importi/Eksporti", - "unsubscribe": "malaboni", + "unsubscribe": "malabonu", "revoke": "senvalidigi", "Subscriptions": "Abonoj", "`x` unseen notifications": "`x` neviditaj sciigoj", @@ -316,7 +316,7 @@ "Language: ": "Lingvo: ", "View as playlist": "Vidi kiel ludlisto", "Default": "Defaŭlte", - "Music": "Musiko", + "Music": "Muziko", "Gaming": "Komputiloludoj", "News": "Novaĵoj", "Movies": "Filmoj", @@ -333,4 +333,4 @@ "Playlists": "Ludlistoj", "Community": "Komunumo", "Current version: ": "Nuna versio: " -} \ No newline at end of file +} diff --git a/locales/es.json b/locales/es.json index 7fc75003..91faef1b 100644 --- a/locales/es.json +++ b/locales/es.json @@ -132,8 +132,8 @@ "Private": "Privado", "View all playlists": "Ver todas las listas de reproducción", "Updated `x` ago": "Actualizado hace `x`", - "Delete playlist `x`?": "¿Eliminar la lista de reproducción `x`?", - "Delete playlist": "Eliminar lista de reproducción", + "Delete playlist `x`?": "¿Borrar la lista de reproducción `x`?", + "Delete playlist": "Borrar lista de reproducción", "Create playlist": "Crear lista de reproducción", "Title": "Título", "Playlist privacy": "Privacidad de la lista de reproducción", @@ -333,4 +333,4 @@ "Playlists": "Listas de reproducción", "Community": "Comunidad", "Current version: ": "Versión actual: " -} \ No newline at end of file +} diff --git a/locales/fa.json b/locales/fa.json new file mode 100644 index 00000000..0f0900a9 --- /dev/null +++ b/locales/fa.json @@ -0,0 +1,353 @@ +{ + "`x` subscribers.([^.,0-9]|^)1([^.,0-9]|$)": "`x` مشترکان.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` subscribers.": "`x` مشترکان.", + "`x` videos.([^.,0-9]|^)1([^.,0-9]|$)": "`x` ویدیو ها.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` videos.": "`x` ویدیو ها.", + "`x` playlists.([^.,0-9]|^)1([^.,0-9]|$)": "`x` لیست های پخش.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` playlists.": "`x` لیست های پخش.", + "LIVE": "زنده", + "Shared `x` ago": "به اشتراک گذاشته شده `x` پیش", + "Unsubscribe": "لغو اشتراک", + "Subscribe": "مشترک شدن", + "View channel on YouTube": "نمایش کانال در یوتیوب", + "View playlist on YouTube": "نمایش لیست پخش در یوتیوب", + "newest": "جدید تر", + "oldest": "قدیمی تر", + "popular": "محبوب", + "last": "آخرین", + "Next page": "صفحه بعد", + "Previous page": "صفحه قبل", + "Clear watch history?": "پاک کردن تاریخچه نمایش؟", + "New password": "گذرواژه جدید", + "New passwords must match": "گذارواژه های جدید باید باهم همخوانی داشته باشند", + "Cannot change password for Google accounts": "نمیتوان گذرواژه را برای حساب های کاربری گوگل تغییر داد", + "Authorize token?": "توکن دسترسی؟", + "Authorize token for `x`?": "توکن دسترسی برای `x`؟", + "Yes": "بله", + "No": "خیر", + "Import and Export Data": "وارد کردن و خارج کردن داده ها", + "Import": "وارد کردن", + "Import Invidious data": "وارد کردن داده Invidious", + "Import YouTube subscriptions": "وارد کردن اشتراک های یوتیوب", + "Import FreeTube subscriptions (.db)": "وارد کردن اشتراک های فری توب (.db)", + "Import NewPipe subscriptions (.json)": "وارد کردن اشتراک های نیو پایپ (.json)", + "Import NewPipe data (.zip)": "وارد کردن داده نیو پایپ (.zip)", + "Export": "خارج کردن", + "Export subscriptions as OPML": "خارج کردن اشتراک ها به عنوان OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "خارج کردن اشتراک ها به عنوان OPML (برای فری توب و نیو پایپ)", + "Export data as JSON": "خارج کردن داده ها به عنوان JSON", + "Delete account?": "حذف حساب کاربری؟", + "History": "تاریخچه", + "An alternative front-end to YouTube": "یک فرانت-اند جایگذین برای یوتیوب", + "JavaScript license information": "اطلاعات مجوز جاوا اسکریپت", + "source": "منبع", + "Log in": "ورود", + "Log in/register": "ورود/ثبت نام", + "Log in with Google": "ورود با گوگل", + "User ID": "شناسه کاربری", + "Password": "گذرواژه", + "Time (h:mm:ss):": "زمان (h:mm:ss):", + "Text CAPTCHA": "متن CAPTCHA", + "Image CAPTCHA": "تصویر CAPTCHA", + "Sign In": "ورود", + "Register": "ثبت نام", + "E-mail": "ایمیل", + "Google verification code": "کد تایید گوگل", + "Preferences": "ترجیحات", + "Player preferences": "ترجیحات نمایش‌دهنده", + "Always loop: ": "همیشه تکرار شنوده: ", + "Autoplay: ": "نمایش خودکار: ", + "Play next by default: ": "پخش بعدی به طور پیشفرض: ", + "Autoplay next video: ": "پخش خودکار ویدیو بعدی: ", + "Listen by default: ": "گوش کردن به طور پیشفرض: ", + "Proxy videos: ": "پروکسی ویدیو ها: ", + "Default speed: ": "سرعت پیشفرض: ", + "Preferred video quality: ": "کیفیت ویدیوی ترجیحی: ", + "Player volume: ": "صدای پخش کننده: ", + "Default comments: ": "نظرات پیشفرض: ", + "youtube": "یوتیوب", + "reddit": "ردیت", + "Default captions: ": "زیرنویس های پیشفرض: ", + "Fallback captions: ": "عقب گرد زیرنویس ها: ", + "Show related videos: ": "نمایش ویدیو های مرتبط: ", + "Show annotations by default: ": "نمایش حاشیه نویسی ها به طور پیشفرض: ", + "Visual preferences": "ترجیحات بصری", + "Player style: ": "حالت پخش کننده: ", + "Dark mode: ": "حالت تاریک: ", + "Theme: ": "تم: ", + "dark": "تاریک", + "light": "روشن", + "Thin mode: ": "حالت نازک: ", + "Subscription preferences": "ترجیحات اشتراک", + "Show annotations by default for subscribed channels: ": "نمایش حاشیه نویسی ها به طور پیشفرض برای کانال های مشترک شده: ", + "Redirect homepage to feed: ": "تغییر مسیر صفحه خانه به خوراک: ", + "Number of videos shown in feed: ": "تعداد ویدیو های نمایش داده شده در خوراک: ", + "Sort videos by: ": "مرتب سازی ویدیو ها بر اساس: ", + "published": "منتشر شده", + "published - reverse": "منتشر شده - معکوس", + "alphabetically": "بر اساس حروف الفبا", + "alphabetically - reverse": "بر اساس حروف الفبا - معکوس", + "channel name": "نام کانال", + "channel name - reverse": "نام کانال - معکوس", + "Only show latest video from channel: ": "تنها نمایش آخرین ویدیو های کانال: ", + "Only show latest unwatched video from channel: ": "تنها نمایش آخرین ویدیو های تماشا نشده از کانال: ", + "Only show unwatched: ": "تنها نمایش ویدیو های تماشا نشده: ", + "Only show notifications (if there are any): ": "تنها نمایش اعلان ها (اگر وجود داشته باشد) ", + "Enable web notifications": "فعال کردن اعلان های وب", + "`x` uploaded a video": "`x` یک ویدیو بارگذاری کرد", + "`x` is live": "`x` زنده است", + "Data preferences": "ترجیحات داده", + "Clear watch history": "پاک‌کردن تاریخچه تماشا", + "Import/export data": "وارد کردن/خارج کردن داده", + "Change password": "تغییر گذرواژه", + "Manage subscriptions": "مدیریت اشتراک ها", + "Manage tokens": "مدیریت توکن ها", + "Watch history": "تاریخچه تماشا", + "Delete account": "حذف حساب کاربری", + "Administrator preferences": "ترجیحات مدیریت", + "Default homepage: ": "صفحه خانه پیشفرض ", + "Feed menu: ": "منو خوراک: ", + "Top enabled: ": "بالا فعال شده: ", + "CAPTCHA enabled: ": "CAPTCHA فعال شده: ", + "Login enabled: ": "ورود فعال شده: ", + "Registration enabled: ": "ثبت نام فعال شده: ", + "Report statistics: ": "گذارش آمار: ", + "Save preferences": "ذخیره ترجیحات", + "Subscription manager": "مدیریت اشتراک", + "Token manager": "مدیر توکن", + "Token": "توکن", + "`x` subscriptions.([^.,0-9]|^)1([^.,0-9]|$)": "`x` اشتراک ها.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` subscriptions.": "`x` اشتراک ها.", + "`x` tokens.([^.,0-9]|^)1([^.,0-9]|$)": "`x` توکن ها.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` tokens.": "`x` توکن ها.", + "Import/export": "وارد کردن/خارج کردن", + "unsubscribe": "لغو اشتراک", + "revoke": "ابطال", + "Subscriptions": "اشتراک ها", + "`x` unseen notifications.([^.,0-9]|^)1([^.,0-9]|$)": "`x` اعلان نادیده.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` unseen notifications.": "`x` اعلان نادیده.", + "search": "جستجو", + "Log out": "خروج", + "Released under the AGPLv3 by Omar Roth.": "منتشر شده تحت مجوز AGPLv3 توسط Omar Roth.", + "Source available here.": "منبع اینجا دردسترس است.", + "View JavaScript license information.": "نمایش اطلاعات مجوز جاوا اسکریپت.", + "View privacy policy.": "نمایش سیاست حفظ حریم خصوصی.", + "Trending": "روند", + "Public": "عمومی", + "Unlisted": "لیست نشده", + "Private": "خصوصی", + "View all playlists": "نمایش همه لیست پخش", + "Updated `x` ago": "بروز شده `x` پیش", + "Delete playlist `x`?": "حذف لیست پخش `x`؟", + "Delete playlist": "حذف لیست پخش", + "Create playlist": "ایجاد لیست پخش", + "Title": "عنوان", + "Playlist privacy": "حریم خصوصی لیست پخش", + "Editing playlist `x`": "تغییر لیست پخش `x`", + "Watch on YouTube": "تماشا در یوتیوب", + "Hide annotations": "مخفی کردن حاشیه نویسی ها", + "Show annotations": "نمایش حاشیه نویسی ها", + "Genre: ": "ژانر: ", + "License: ": "مجوز: ", + "Family friendly? ": "خانواده دوستانه؟ ", + "Wilson score: ": "امتیاز ویلسون: ", + "Engagement: ": "نامزدی: ", + "Whitelisted regions: ": "مناطق لیست سفید: ", + "Blacklisted regions: ": "مناطق لیست سیاه: ", + "Shared `x`": "به اشتراک گذاشته شده `x`", + "`x` views.([^.,0-9]|^)1([^.,0-9]|$)": "`x` بازدید.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` views.": "`x` بازدید.", + "Premieres in `x`": "برای اولین بار در `x`", + "Premieres `x`": "برای اولین بار `x`", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "سلام! مثل اینکه تو جاوا اسکریپت رو خاموش کرده ای. اینجا کلیک کن تا نظرات را ببینی، این رو یادت باشه که ممکنه بارگذاری اونها کمی طول بکشه.", + "View YouTube comments": "نمایش نظرات یوتیوب", + "View more comments on Reddit": "نمایش نظرات بیشتر در ردیت", + "View `x` comments.([^.,0-9]|^)1([^.,0-9]|$)": "نمایش `x` نظرات.([^.,0-9]|^)1([^.,0-9]|$)", + "View `x` comments.": "نمایش `x` نظرات.", + "View Reddit comments": "نمایش نظرات ردیت", + "Hide replies": "مخفی کردن پاسخ ها", + "Show replies": "نمایش پاسخ ها", + "Incorrect password": "گذرواژه نا درست", + "Quota exceeded, try again in a few hours": "سهمیه بیشتر شده است، چند ساعت بعد دوباره تلاش کنید", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "قادر به ورود نیستید، مطمئن شوید احراز تایید-دو‌مرحله (Authenticator یا پیام‌کوتاه) خاموش باشد.", + "Invalid TFA code": "کد TFA نادرست است", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "ورود با خطا مواجه شد. این ممکن است به خاطر احراز تایید-دو‌مرحله باشد که برای حساب کاربری شما فعال نشده است.", + "Wrong answer": "پاسخ غلط", + "Erroneous CAPTCHA": "CAPTCHA نا درست", + "CAPTCHA is a required field": "CAPTCHA یک فیلد ضروری است", + "User ID is a required field": "شناسه کاربری یک فیلد ضروری است", + "Password is a required field": "گذرواژه یک فیلد ضروری است", + "Wrong username or password": "نام کاربری یا گذرواژه غلط است", + "Please sign in using 'Log in with Google'": "لطفا با استفاده از 'ورود توسط گوگل' وارد شوید", + "Password cannot be empty": "گذرواژه نمیتواند خالی باشد", + "Password cannot be longer than 55 characters": "گذر واژه نمیتواند از ۵۵ کاراکتر بیشتر باشد", + "Please log in": "لطفا وارد شوید", + "Invidious Private Feed for `x`": "خوراک خصوصی زشت برای `x`", + "channel:`x`": "کانال: `x`", + "Deleted or invalid channel": "کانال نا معتبر یا پاک شده است", + "This channel does not exist.": "این کانال وجود ندارد.", + "Could not get channel info.": "نمیتوان اطلاعات کانال را دریافت کرد.", + "Could not fetch comments": "نمیتوان نظرات را دریافت کرد", + "View `x` replies.([^.,0-9]|^)1([^.,0-9]|$)": "نمایش `x` پاسخ ها.([^.,0-9]|^)1([^.,0-9]|$)", + "View `x` replies.": "نمایش `x` پاسخ ها.", + "`x` ago": "`x` پیش", + "Load more": "بارگذاری بیشتر", + "`x` points.([^.,0-9]|^)1([^.,0-9]|$)": "`x` نقطه ها.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` points.": "`x` نقطه ها.", + "Could not create mix.": "نمیتوان میکس ساخت.", + "Empty playlist": "لیست پخش خالی", + "Not a playlist.": "یک لیست پخش نیست.", + "Playlist does not exist.": "لیست پخش وجود ندارد.", + "Could not pull trending pages.": "نمیتوان صفحه های پر طرفدار را بکشد.", + "Hidden field \"challenge\" is a required field": "فیلد مخفی \"چالش\" یک فیلد ضروری است", + "Hidden field \"token\" is a required field": "فیلد مخفی \"توکن\" یک فیلد ضروری است", + "Erroneous challenge": "چالش غلط", + "Erroneous token": "توکن غلط", + "No such user": "چنین کاربری وجود ندارد", + "Token is expired, please try again": "توکن ضروری است، لطفا دوباره تلاش کنید", + "English": "انگلیسی", + "English (auto-generated)": "انگلیسی (خودکار-تولید‌شده)", + "Afrikaans": "آفریکانس", + "Albanian": "آلبانیایی", + "Amharic": "امهری", + "Arabic": "عربی", + "Armenian": "ارمنی", + "Azerbaijani": "آذربایجانی", + "Bangla": "بنگالی", + "Basque": "باسکی", + "Belarusian": "بلاروسی", + "Bosnian": "بوسنیایی", + "Bulgarian": "بلغاری", + "Burmese": "برمه‌ای", + "Catalan": "کاتالان", + "Cebuano": "سبوانو", + "Chinese (Simplified)": "چینی (ساده شده)", + "Chinese (Traditional)": "چینی (سنتی)", + "Corsican": "کرس", + "Croatian": "کرواسی", + "Czech": "چکی", + "Danish": "دانمارکی", + "Dutch": "هلندی", + "Esperanto": "اسپرانتو", + "Estonian": "استونیایی", + "Filipino": "فلیپینی", + "Finnish": "فنلاندی", + "French": "فرانسوی", + "Galician": "گالیسی", + "Georgian": "گرجی", + "German": "آلمانی", + "Greek": "یونانی", + "Gujarati": "گجراتی", + "Haitian Creole": "کریول آییسینی", + "Hausa": "هوسه", + "Hawaiian": "هاوائی", + "Hebrew": "عبری", + "Hindi": "هندی", + "Hmong": "همونگ", + "Hungarian": "مجاری", + "Icelandic": "ایسلندی", + "Igbo": "ایگبو", + "Indonesian": "اندونزیایی", + "Irish": "شلتا", + "Italian": "ایتالیایی", + "Japanese": "ژاپنی", + "Javanese": "جاوه‌ای", + "Kannada": "کانارا", + "Kazakh": "قزاقی", + "Khmer": "خمر", + "Korean": "کره‌ای", + "Kurdish": "کردی", + "Kyrgyz": "قرقیزی", + "Lao": "لائو", + "Latin": "لاتین", + "Latvian": "لتونیایی", + "Lithuanian": "لیتوانیایی", + "Luxembourgish": "لوکزامبورگی", + "Macedonian": "مقدونی", + "Malagasy": "مالاگاسی", + "Malay": "مالایی", + "Malayalam": "مالایالم", + "Maltese": "مالتی", + "Maori": "مائوری", + "Marathi": "مراتی", + "Mongolian": "مغولی", + "Nepali": "نپالی", + "Norwegian Bokmål": "بوکمل", + "Nyanja": "چوایی", + "Pashto": "پشتو", + "Persian": "فارسی", + "Polish": "لهستانی", + "Portuguese": "پرتغالی", + "Punjabi": "پنجابی", + "Romanian": "رومانیایی", + "Russian": "روسی", + "Samoan": "ساموآیی", + "Scottish Gaelic": "گیلیک اسکاتلندی", + "Serbian": "صربی", + "Shona": "شونا", + "Sindhi": "سندی", + "Sinhala": "سینهالی", + "Slovak": "اسلواکی", + "Slovenian": "اسلونیایی", + "Somali": "سومالیایی", + "Southern Sotho": "سوتو", + "Spanish": "اسپانیایی", + "Spanish (Latin America)": "اسپانیایی (آمریکای لاتین)", + "Sundanese": "سوندایی", + "Swahili": "سواحلی", + "Swedish": "سوئدی", + "Tajik": "تاجیک", + "Tamil": "تامیلی", + "Telugu": "تلوگو", + "Thai": "تای", + "Turkish": "ترکی", + "Ukrainian": "اوکراینی", + "Urdu": "اردو", + "Uzbek": "ازبکی", + "Vietnamese": "ویتنامی", + "Welsh": "ولزی", + "Western Frisian": "فریسی غربی", + "Xhosa": "خوسایی", + "Yiddish": "ییدیش", + "Yoruba": "یوروبایی", + "Zulu": "زولو", + "`x` years.([^.,0-9]|^)1([^.,0-9]|$)": "`x` سال.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` years.": "`x` سال.", + "`x` months.([^.,0-9]|^)1([^.,0-9]|$)": "`x` ماه.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` months.": "`x` ماه.", + "`x` weeks.([^.,0-9]|^)1([^.,0-9]|$)": "`x` هفته.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` weeks.": "`x` هفته.", + "`x` days.([^.,0-9]|^)1([^.,0-9]|$)": "`x` روز.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` days.": "`x` روز.", + "`x` hours.([^.,0-9]|^)1([^.,0-9]|$)": "`x` ساعت.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` hours.": "`x` ساعت.", + "`x` minutes.([^.,0-9]|^)1([^.,0-9]|$)": "`x` دقیقه.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` minutes.": "`x` دقیقه.", + "`x` seconds.([^.,0-9]|^)1([^.,0-9]|$)": "`x` ثانیه.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` seconds.": "`x` ثانیه.", + "Fallback comments: ": "نظرات عقب گرد: ", + "Popular": "محبوب", + "Top": "بالا", + "About": "درباره", + "Rating: ": "رتبه دهی: ", + "Language: ": "زبان: ", + "View as playlist": "نمایش به عنوان لیست پخش", + "Default": "پیشفرض", + "Music": "موسیقی", + "Gaming": "بازی", + "News": "اخبار", + "Movies": "فیلم‌ها", + "Download": "بارگیری", + "Download as: ": "بارگیری به عنوان: ", + "%A %B %-d, %Y": "%A %B %-d، %Y", + "(edited)": "(ویرایش شده)", + "YouTube comment permalink": "پیوست ثابت نظرات یوتیوب", + "permalink": "پیوست ثابت", + "`x` marked it with a ❤": "`x` نشان گذاری شده با یک ❤", + "Audio mode": "حالت صدا", + "Video mode": "حالت ویدیو", + "Videos": "ویدیو ها", + "Playlists": "لیست های پخش", + "Community": "اجتماع", + "Current version: ": "نسخه فعلی: " +} diff --git a/locales/fi.json b/locales/fi.json new file mode 100644 index 00000000..7de46bf4 --- /dev/null +++ b/locales/fi.json @@ -0,0 +1,353 @@ +{ + "`x` subscribers.([^.,0-9]|^)1([^.,0-9]|$)": "`x` tilaaja", + "`x` subscribers.": "`x` tilaajaa", + "`x` videos.([^.,0-9]|^)1([^.,0-9]|$)": "`x` video", + "`x` videos.": "`x` videota", + "`x` playlists.([^.,0-9]|^)1([^.,0-9]|$)": "`x` soittolista.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` playlists.": "`x` soittolistaa", + "LIVE": "SUORA", + "Shared `x` ago": "Jaettu `x` sitten", + "Unsubscribe": "Peruuta tilaus", + "Subscribe": "Tilaa", + "View channel on YouTube": "Näytä kanava YouTubessa", + "View playlist on YouTube": "Näytä soittolista YouTubessa", + "newest": "uusin", + "oldest": "vanhin", + "popular": "suosittu", + "last": "viimeisin", + "Next page": "Seuraava sivu", + "Previous page": "Edellinen sivu", + "Clear watch history?": "Tyhjennä katseluhistoria?", + "New password": "Uusi salasana", + "New passwords must match": "Uusien salasanojen täytyy täsmätä", + "Cannot change password for Google accounts": "Google-tilien salasanaa ei voi vaihtaa", + "Authorize token?": "Valuutetaanko tunnus?", + "Authorize token for `x`?": "Valtuutetaanko tunnus `x`:lle?", + "Yes": "Kyllä", + "No": "Ei", + "Import and Export Data": "Tuo ja vie tietoja", + "Import": "Tuo", + "Import Invidious data": "Vie Invidious-tietoja", + "Import YouTube subscriptions": "Tuo YouTube-tilaukset", + "Import FreeTube subscriptions (.db)": "Tuo FreeTube-tilaukset (.db)", + "Import NewPipe subscriptions (.json)": "Tuo NewPipe-tilaukset (.json)", + "Import NewPipe data (.zip)": "Tuo NewPipe data (.zip)", + "Export": "Vie", + "Export subscriptions as OPML": "Vie tilaukset muodossa OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Vie tilaukset muodossa OPML (NewPipe ja FreeTube)", + "Export data as JSON": "Vie data muodossa JSON", + "Delete account?": "Poista tili?", + "History": "Historia", + "An alternative front-end to YouTube": "Vaihtoehtoinen käyttöliittymä YouTubelle", + "JavaScript license information": "JavaScript-käyttöoikeustiedot", + "source": "lähde", + "Log in": "Kirjaudu sisään", + "Log in/register": "Kirjaudu sisään / Rekisteröidy", + "Log in with Google": "Kirjaudu sisään Googlella", + "User ID": "Käyttäjätunnus", + "Password": "Salasana", + "Time (h:mm:ss):": "Aika (h:mm:ss):", + "Text CAPTCHA": "Teksti CAPTCHA", + "Image CAPTCHA": "Kuva CAPTCHA", + "Sign In": "Kirjaudu sisään", + "Register": "Rekisteröidy", + "E-mail": "Sähköposti", + "Google verification code": "Google-vahvistuskoodi", + "Preferences": "Asetukset", + "Player preferences": "Soittimen asetukset", + "Always loop: ": "Aina silmukka: ", + "Autoplay: ": "Automaattinen toisto: ", + "Play next by default: ": "Toista seuraava oletuksena: ", + "Autoplay next video: ": "Toista seuraava video automaattisesti: ", + "Listen by default: ": "Kuuntele oletuksena: ", + "Proxy videos: ": "Proxy videot: ", + "Default speed: ": "Oletusnopeus: ", + "Preferred video quality: ": "Ensisijainen videon laatu: ", + "Player volume: ": "Soittimen äänenvoimakkuus: ", + "Default comments: ": "Oletuskommentit: ", + "youtube": "YouTube", + "reddit": "Reddit", + "Default captions: ": "Tekstitykset: ", + "Fallback captions: ": "Toissijaiset tekstitykset: ", + "Show related videos: ": "Näytä aiheeseen liittyviä videoita: ", + "Show annotations by default: ": "Näytä huomautukset oletuksena: ", + "Visual preferences": "Visuaaliset asetukset", + "Player style: ": "Soittimen tyyli: ", + "Dark mode: ": "Tumma tila: ", + "Theme: ": "Teema: ", + "dark": "tumma", + "light": "vaalea", + "Thin mode: ": "Kapea tila ", + "Subscription preferences": "Tilausten asetukset", + "Show annotations by default for subscribed channels: ": "Näytä oletuksena tilattujen kanavien huomautukset: ", + "Redirect homepage to feed: ": "Uudelleenohjaa kotisivu syötteeseen: ", + "Number of videos shown in feed: ": "Syötteessä näytettävien videoiden määrä: ", + "Sort videos by: ": "Videoiden lajitteluperuste: ", + "published": "julkaistu", + "published - reverse": "julkaistu - käänteinen", + "alphabetically": "aakkosjärjestys", + "alphabetically - reverse": "aakkosjärjestys - käänteinen", + "channel name": "kanavan nimi", + "channel name - reverse": "kanavan nimi - käänteinen", + "Only show latest video from channel: ": "Näytä vain uusin video kanavalta: ", + "Only show latest unwatched video from channel: ": "Näytä vain uusin katsomaton video kanavalta: ", + "Only show unwatched: ": "Näytä vain katsomattomat: ", + "Only show notifications (if there are any): ": "Näytä vain ilmoitukset (jos niitä on): ", + "Enable web notifications": "Näytä verkkoilmoitukset", + "`x` uploaded a video": "`x` latasi videon", + "`x` is live": "`x` lähettää suorana", + "Data preferences": "Tietojen asetukset", + "Clear watch history": "Tyhjennä katseluhistoria", + "Import/export data": "Tuo/vie tiedot", + "Change password": "Vaihda salasana", + "Manage subscriptions": "Hallinnoi tilauksia", + "Manage tokens": "Hallinnoi tunnuksia", + "Watch history": "Katseluhistoria", + "Delete account": "Poista tili", + "Administrator preferences": "Järjestelmänvalvojan asetukset", + "Default homepage: ": "Oletuskotisivu: ", + "Feed menu: ": "Syötevalikko: ", + "Top enabled: ": "Yläosa käytössä: ", + "CAPTCHA enabled: ": "CAPTCHA käytössä: ", + "Login enabled: ": "Kirjautuminen käytössä: ", + "Registration enabled: ": "Rekisteröityminen käytössä: ", + "Report statistics: ": "Raportoi tilastot: ", + "Save preferences": "Tallenna asetukset", + "Subscription manager": "Tilausten hallinnoija", + "Token manager": "Tunnusten hallinnoija", + "Token": "Tunnus", + "`x` subscriptions.([^.,0-9]|^)1([^.,0-9]|$)": "`x` tilausta.([^.,0-9]|^)1([^.,0-9]|$)", + "`x` subscriptions.": "`x` tilausta.", + "`x` tokens.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` tokens.": "", + "Import/export": "Tuo/vie", + "unsubscribe": "peru tilaus", + "revoke": "kumoa", + "Subscriptions": "Tilaukset", + "`x` unseen notifications.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` unseen notifications.": "", + "search": "", + "Log out": "", + "Released under the AGPLv3 by Omar Roth.": "", + "Source available here.": "", + "View JavaScript license information.": "", + "View privacy policy.": "", + "Trending": "", + "Public": "", + "Unlisted": "", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", + "Watch on YouTube": "", + "Hide annotations": "", + "Show annotations": "", + "Genre: ": "", + "License: ": "", + "Family friendly? ": "", + "Wilson score: ": "", + "Engagement: ": "", + "Whitelisted regions: ": "", + "Blacklisted regions: ": "", + "Shared `x`": "", + "`x` views.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` views.": "", + "Premieres in `x`": "", + "Premieres `x`": "", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "", + "View YouTube comments": "", + "View more comments on Reddit": "", + "View `x` comments.([^.,0-9]|^)1([^.,0-9]|$)": "", + "View `x` comments.": "", + "View Reddit comments": "", + "Hide replies": "", + "Show replies": "", + "Incorrect password": "", + "Quota exceeded, try again in a few hours": "", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "", + "Invalid TFA code": "", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "", + "Wrong answer": "", + "Erroneous CAPTCHA": "", + "CAPTCHA is a required field": "", + "User ID is a required field": "", + "Password is a required field": "", + "Wrong username or password": "", + "Please sign in using 'Log in with Google'": "", + "Password cannot be empty": "", + "Password cannot be longer than 55 characters": "", + "Please log in": "", + "Invidious Private Feed for `x`": "", + "channel:`x`": "", + "Deleted or invalid channel": "", + "This channel does not exist.": "", + "Could not get channel info.": "", + "Could not fetch comments": "", + "View `x` replies.([^.,0-9]|^)1([^.,0-9]|$)": "", + "View `x` replies.": "", + "`x` ago": "", + "Load more": "", + "`x` points.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` points.": "", + "Could not create mix.": "", + "Empty playlist": "", + "Not a playlist.": "", + "Playlist does not exist.": "", + "Could not pull trending pages.": "", + "Hidden field \"challenge\" is a required field": "", + "Hidden field \"token\" is a required field": "", + "Erroneous challenge": "", + "Erroneous token": "", + "No such user": "", + "Token is expired, please try again": "", + "English": "", + "English (auto-generated)": "", + "Afrikaans": "", + "Albanian": "", + "Amharic": "", + "Arabic": "", + "Armenian": "", + "Azerbaijani": "", + "Bangla": "", + "Basque": "", + "Belarusian": "", + "Bosnian": "", + "Bulgarian": "", + "Burmese": "", + "Catalan": "", + "Cebuano": "", + "Chinese (Simplified)": "", + "Chinese (Traditional)": "", + "Corsican": "", + "Croatian": "", + "Czech": "", + "Danish": "", + "Dutch": "", + "Esperanto": "", + "Estonian": "", + "Filipino": "", + "Finnish": "", + "French": "", + "Galician": "", + "Georgian": "", + "German": "", + "Greek": "", + "Gujarati": "", + "Haitian Creole": "", + "Hausa": "", + "Hawaiian": "", + "Hebrew": "", + "Hindi": "", + "Hmong": "", + "Hungarian": "", + "Icelandic": "", + "Igbo": "", + "Indonesian": "", + "Irish": "", + "Italian": "", + "Japanese": "", + "Javanese": "", + "Kannada": "", + "Kazakh": "", + "Khmer": "", + "Korean": "", + "Kurdish": "", + "Kyrgyz": "", + "Lao": "", + "Latin": "", + "Latvian": "", + "Lithuanian": "", + "Luxembourgish": "", + "Macedonian": "", + "Malagasy": "", + "Malay": "", + "Malayalam": "", + "Maltese": "", + "Maori": "", + "Marathi": "", + "Mongolian": "", + "Nepali": "", + "Norwegian Bokmål": "", + "Nyanja": "", + "Pashto": "", + "Persian": "", + "Polish": "", + "Portuguese": "", + "Punjabi": "", + "Romanian": "", + "Russian": "", + "Samoan": "", + "Scottish Gaelic": "", + "Serbian": "", + "Shona": "", + "Sindhi": "", + "Sinhala": "", + "Slovak": "", + "Slovenian": "", + "Somali": "", + "Southern Sotho": "", + "Spanish": "", + "Spanish (Latin America)": "", + "Sundanese": "", + "Swahili": "", + "Swedish": "", + "Tajik": "", + "Tamil": "", + "Telugu": "", + "Thai": "", + "Turkish": "", + "Ukrainian": "", + "Urdu": "", + "Uzbek": "", + "Vietnamese": "", + "Welsh": "", + "Western Frisian": "", + "Xhosa": "", + "Yiddish": "", + "Yoruba": "", + "Zulu": "", + "`x` years.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` years.": "", + "`x` months.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` months.": "", + "`x` weeks.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` weeks.": "", + "`x` days.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` days.": "", + "`x` hours.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` hours.": "", + "`x` minutes.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` minutes.": "", + "`x` seconds.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` seconds.": "", + "Fallback comments: ": "", + "Popular": "", + "Top": "", + "About": "", + "Rating: ": "", + "Language: ": "", + "View as playlist": "", + "Default": "", + "Music": "", + "Gaming": "", + "News": "", + "Movies": "", + "Download": "", + "Download as: ": "", + "%A %B %-d, %Y": "", + "(edited)": "", + "YouTube comment permalink": "", + "permalink": "", + "`x` marked it with a ❤": "", + "Audio mode": "", + "Video mode": "", + "Videos": "", + "Playlists": "", + "Community": "", + "Current version: ": "" +} diff --git a/locales/fr.json b/locales/fr.json index 24cabdea..664e25f5 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -16,7 +16,7 @@ "Previous page": "Page précédente", "Clear watch history?": "Êtes-vous sûr de vouloir supprimer l'historique des vidéos regardées ?", "New password": "Nouveau mot de passe", - "New passwords must match": "Les champs \"Nouveau mot de passe\" doivent être identiques", + "New passwords must match": "Les nouveaux mots de passe doivent correspondre", "Cannot change password for Google accounts": "Le mot de passe d'un compte Google ne peut pas être changé depuis Invidious", "Authorize token?": "Autoriser le token ?", "Authorize token for `x`?": "Autoriser le token pour `x` ?", @@ -48,7 +48,7 @@ "Image CAPTCHA": "CAPTCHA Image", "Sign In": "Se connecter", "Register": "S'inscrire", - "E-mail": "E-mail", + "E-mail": "Courriel", "Google verification code": "Code de vérification Google", "Preferences": "Préférences", "Player preferences": "Préférences du lecteur", @@ -106,7 +106,7 @@ "Feed menu: ": "Préferences des abonnements : ", "Top enabled: ": "Top activé : ", "CAPTCHA enabled: ": "CAPTCHA activé : ", - "Login enabled: ": "Connexion activé : ", + "Login enabled: ": "Connexion activée : ", "Registration enabled: ": "Inscription activée : ", "Report statistics: ": "Télémétrie activé : ", "Save preferences": "Enregistrer les préférences", @@ -145,7 +145,7 @@ "License: ": "Licence : ", "Family friendly? ": "Vidéo tout public ? ", "Wilson score: ": "Score de Wilson : ", - "Engagement: ": "Pourcentage de spectateur aillant appuyé sur \"J'aime\" ou \"J'aime Pas\" : ", + "Engagement: ": "Taux d'implication : ", "Whitelisted regions: ": "Régions sur liste blanche : ", "Blacklisted regions: ": "Régions sur liste noire : ", "Shared `x`": "Ajoutée le `x`", @@ -170,12 +170,12 @@ "User ID is a required field": "Veuillez entrer un Identifiant Utilisateur", "Password is a required field": "Veuillez entrer un Mot de passe", "Wrong username or password": "Nom d'utilisateur ou mot de passe invalide", - "Please sign in using 'Log in with Google'": "Veuillez vous connecter en utilisant \"Se connecter avec Google\"", + "Please sign in using 'Log in with Google'": "Veuillez vous connecter en utilisant « Se connecter avec Google »", "Password cannot be empty": "Le mot de passe ne peut pas être vide", "Password cannot be longer than 55 characters": "Le mot de passe ne doit pas comporter plus de 55 caractères", "Please log in": "Veuillez vous connecter", "Invidious Private Feed for `x`": "Flux RSS privé pour `x`", - "channel:`x`": "chaîne:`x`", + "channel:`x`": "chaîne :`x`", "Deleted or invalid channel": "Chaîne supprimée ou invalide", "This channel does not exist.": "Cette chaine n'existe pas.", "Could not get channel info.": "Impossible de charger les informations de cette chaîne.", @@ -189,8 +189,8 @@ "Not a playlist.": "La liste de lecture est invalide.", "Playlist does not exist.": "La liste de lecture n'existe pas.", "Could not pull trending pages.": "Impossible de charger les pages de tendances.", - "Hidden field \"challenge\" is a required field": "Le champ masqué \"challenge\" est un champ obligatoire", - "Hidden field \"token\" is a required field": "Le champ caché \"token\" est requis", + "Hidden field \"challenge\" is a required field": "Le champ masqué « challenge » est un champ obligatoire", + "Hidden field \"token\" is a required field": "Le champ caché « token » est requis", "Erroneous challenge": "Challenge invalide", "Erroneous token": "Token invalide", "No such user": "Cet utilisateur n'existe pas", @@ -217,21 +217,21 @@ "Croatian": "Croate", "Czech": "Tchèque", "Danish": "Danois", - "Dutch": "Hollandais", + "Dutch": "Néerlandais", "Esperanto": "Espéranto", "Estonian": "Estonien", "Filipino": "Philippin", - "Finnish": "Finlandais", + "Finnish": "Finnois", "French": "Français", "Galician": "Galicien", "Georgian": "Géorgien", "German": "Allemand", "Greek": "Grec", "Gujarati": "Gujarati", - "Haitian Creole": "Créole Haïtien", + "Haitian Creole": "Créole haïtien", "Hausa": "Haoussa", "Hawaiian": "Hawaïen", - "Hebrew": "Hébraïque", + "Hebrew": "Hébreu", "Hindi": "Hindi", "Hmong": "Hmong", "Hungarian": "Hongrois", @@ -262,21 +262,21 @@ "Marathi": "Marathi", "Mongolian": "Mongol", "Nepali": "Népalais", - "Norwegian Bokmål": "Norvégien", + "Norwegian Bokmål": "Norvégien bokmål", "Nyanja": "Nyanja", - "Pashto": "Pachtou", + "Pashto": "Pachto", "Persian": "Persan", "Polish": "Polonais", "Portuguese": "Portugais", - "Punjabi": "Punjabi", + "Punjabi": "Pendjabi", "Romanian": "Roumain", "Russian": "Russe", "Samoan": "Samoan", - "Scottish Gaelic": "Eaélique Ècossais", + "Scottish Gaelic": "Gaélique écossais", "Serbian": "Serbe", "Shona": "Shona", "Sindhi": "Sindhi", - "Sinhala": "Cinghalais", + "Sinhala": "Singhalais", "Slovak": "Slovaque", "Slovenian": "Slovène", "Somali": "Somalien", @@ -286,9 +286,9 @@ "Sundanese": "Sundanais", "Swahili": "Swahili", "Swedish": "Suédois", - "Tajik": "Tajik", + "Tajik": "Tadjik", "Tamil": "Tamil", - "Telugu": "Telugu", + "Telugu": "Télougou", "Thai": "Thaï", "Turkish": "Turc", "Ukrainian": "Ukrainien", @@ -317,7 +317,7 @@ "View as playlist": "Voir en tant que liste de lecture", "Default": "Défaut", "Music": "Musique", - "Gaming": "Jeux Vidéo", + "Gaming": "Jeux vidéo", "News": "Actualités", "Movies": "Films", "Download": "Télécharger", @@ -325,7 +325,7 @@ "%A %B %-d, %Y": "%A %-d %B %Y", "(edited)": "(modifié)", "YouTube comment permalink": "Lien permanent vers le commentaire sur YouTube", - "permalink": "Lien permanent", + "permalink": "permalien", "`x` marked it with a ❤": "`x` l'a marqué d'un ❤", "Audio mode": "Mode audio", "Video mode": "Mode vidéo", @@ -333,4 +333,4 @@ "Playlists": "Listes de lecture", "Community": "Communauté", "Current version: ": "Version actuelle : " -} \ No newline at end of file +} diff --git a/locales/hr.json b/locales/hr.json new file mode 100644 index 00000000..a9a179f3 --- /dev/null +++ b/locales/hr.json @@ -0,0 +1,387 @@ +{ + "`x` subscribers": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pretplatnika.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` pretplatnika." + }, + "`x` videos": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videa.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` videa." + }, + "`x` playlists": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` playliste.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` playliste." + }, + "LIVE": "UŽIVO", + "Shared `x` ago": "Dijeljeno prije `x`", + "Unsubscribe": "Odjavi pretplatu", + "Subscribe": "Pretplati se", + "View channel on YouTube": "Prikaži kanal na YouTubeu", + "View playlist on YouTube": "Prikaži playlistu na YouTubeu", + "newest": "najnovije", + "oldest": "najstarije", + "popular": "popularni", + "last": "zadnji", + "Next page": "Sljedeća stranica", + "Previous page": "Prethodna stranica", + "Clear watch history?": "Izbrisati povijest gledanja?", + "New password": "Nova lozinka", + "New passwords must match": "Nove lozinke se moraju poklapati", + "Cannot change password for Google accounts": "Nije moguće promijeniti lozinku za Google račune", + "Authorize token?": "Autorizirati token?", + "Authorize token for `x`?": "Autorizirati token za `x`?", + "Yes": "Da", + "No": "Ne", + "Import and Export Data": "Uvezi i izvezi podatke", + "Import": "Uvezi", + "Import Invidious data": "Uvezi Invidious podatke", + "Import YouTube subscriptions": "Uvezi YouTube pretplate", + "Import FreeTube subscriptions (.db)": "Uvezi FreeTube pretplate (.db)", + "Import NewPipe subscriptions (.json)": "Uvezi NewPipe pretplate (.json)", + "Import NewPipe data (.zip)": "Uvezi NewPipe podatke (.zip)", + "Export": "Izvezi", + "Export subscriptions as OPML": "Izvezi pretplate kao OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Izvezi pretplate kao OPML (za NewPipe i FreeTube)", + "Export data as JSON": "Izvezi podatke kao JSON", + "Delete account?": "Izbrisati račun?", + "History": "Povijest", + "An alternative front-end to YouTube": "Alternativa za YouTube", + "JavaScript license information": "Informacije o JavaScript licenci", + "source": "izvor", + "Log in": "Prijavi se", + "Log in/register": "Prijavi se/registriraj se", + "Log in with Google": "Prijavi se pomoću Googlea", + "User ID": "Korisnički ID", + "Password": "Lozinka", + "Time (h:mm:ss):": "Vrijeme (h:mm:ss):", + "Text CAPTCHA": "Tekstualni CAPTCHA", + "Image CAPTCHA": "Slikovni CAPTCHA", + "Sign In": "Prijava", + "Register": "Registriraj se", + "E-mail": "E-mail", + "Google verification code": "Googleov potvrdni kod", + "Preferences": "Postavke", + "Player preferences": "Postavke playera", + "Always loop: ": "Uvijek ponavljaj: ", + "Autoplay: ": "Automatski reproduciraj: ", + "Play next by default: ": "Standardno reproduciraj sljedeći: ", + "Autoplay next video: ": "Automatski reproduciraj sljedeći video: ", + "Listen by default: ": "Standardno slušaj: ", + "Proxy videos: ": "Koristi posrednika videa: ", + "Default speed: ": "Standardna brzina: ", + "Preferred video quality: ": "Primarna kvaliteta videa: ", + "Player volume: ": "Glasnoća playera: ", + "Default comments: ": "Standardni komentari: ", + "youtube": "youtube", + "reddit": "reddit", + "Default captions: ": "Standardni titlovi: ", + "Fallback captions: ": "Alternativni titlovi: ", + "Show related videos: ": "Prikaži povezana videa: ", + "Show annotations by default: ": "Standardno prikaži napomene: ", + "Visual preferences": "Postavke prikaza", + "Player style: ": "Stil playera: ", + "Dark mode: ": "Tamni modus: ", + "Theme: ": "Tema: ", + "dark": "tamno", + "light": "svijetlo", + "Thin mode: ": "Pojednostavljen prikaz: ", + "Subscription preferences": "Postavke pretplata", + "Show annotations by default for subscribed channels: ": "Standardno prikaži napomene za pretplaćene kanale: ", + "Redirect homepage to feed: ": "Preusmjeri početnu stranicu na feed: ", + "Number of videos shown in feed: ": "Broj prikazanih videa u feedu: ", + "Sort videos by: ": "Razvrstaj videa prema: ", + "published": "objavljeno", + "published - reverse": "objavljeno – obrnuto", + "alphabetically": "abecednim redom", + "alphabetically - reverse": "abecednim redom – obrnuto", + "channel name": "ime kanala", + "channel name - reverse": "ime kanala – obrnuto", + "Only show latest video from channel: ": "Prikaži samo najnovija videa kanala: ", + "Only show latest unwatched video from channel: ": "Prikaži samo najnovija nepogledana videa kanala: ", + "Only show unwatched: ": "Prikaži samo nepogledane: ", + "Only show notifications (if there are any): ": "Prikaži samo obavijesti (ako ih ima): ", + "Enable web notifications": "Aktiviraj web-obavijesti", + "`x` uploaded a video": "`x` je poslao/la video", + "`x` is live": "`x` je uživo", + "Data preferences": "Postavke podataka", + "Clear watch history": "Izbriši povijest gledanja", + "Import/export data": "Uvezi/izvezi podatke", + "Change password": "Promijeni lozinku", + "Manage subscriptions": "Upravljaj pretplatama", + "Manage tokens": "Upravljaj tokenima", + "Watch history": "Povijest gledanja", + "Delete account": "Izbriši račun", + "Administrator preferences": "Postavke administratora", + "Default homepage: ": "Standardna početna stranica: ", + "Feed menu: ": "Izbornik za feedove: ", + "Top enabled: ": "Najbolji aktivirani: ", + "CAPTCHA enabled: ": "Aktivirani CAPTCHA: ", + "Login enabled: ": "Prijava aktivirana: ", + "Registration enabled: ": "Registracija aktivirana: ", + "Report statistics: ": "Izvještaj o statistici: ", + "Save preferences": "Spremi postavke", + "Subscription manager": "Upravljanje pretplatama", + "Token manager": "Upravljanje tokenima", + "Token": "Token", + "`x` subscriptions": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pretplate.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` pretplate." + }, + "`x` tokens": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokena.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` tokena." + }, + "Import/export": "Uvezi/izvezi", + "unsubscribe": "odjavi pretplatu", + "revoke": "opozovi", + "Subscriptions": "Pretplate", + "`x` unseen notifications": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` neviđene obavijesti.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` neviđene obavijesti." + }, + "search": "traži", + "Log out": "Odjavi se", + "Released under the AGPLv3 by Omar Roth.": "Izdano pod licencom AGPLv3, Omar Roth.", + "Source available here.": "Izvor je ovdje dostupan.", + "View JavaScript license information.": "Prikaži informacije o JavaScript licenci.", + "View privacy policy.": "Prikaži politiku privatnosti.", + "Trending": "U trendu", + "Public": "Javno", + "Unlisted": "Nenavedeno", + "Private": "Privatno", + "View all playlists": "Prikaži sve playliste", + "Updated `x` ago": "Aktualizirano prije `x`", + "Delete playlist `x`?": "Izbrisati playlistu `x`?", + "Delete playlist": "Izbriši playlistu", + "Create playlist": "Stvori playlistu", + "Title": "Naslov", + "Playlist privacy": "Privatnost playliste", + "Editing playlist `x`": "Uređivanje playliste `x`", + "Watch on YouTube": "Gledaj na YouTubeu", + "Hide annotations": "Sakrij napomene", + "Show annotations": "Prikaži napomene", + "Genre: ": "Žanr: ", + "License: ": "Licenca: ", + "Family friendly? ": "Pogodan za cijelu obitelj? ", + "Wilson score: ": "Wilson rezultat: ", + "Engagement: ": "Sudjelovanje: ", + "Whitelisted regions: ": "Odobrene regije: ", + "Blacklisted regions: ": "Blokirane regije: ", + "Shared `x`": "Dijeljeno `x`", + "`x` views": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` gledanja.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` gledanja." + }, + "Premieres in `x`": "Premijera za `x`", + "Premieres `x`": "Premijera `x`", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Bok! Izgleda da je JavaScript isključen. Pritisni ovdje za prikaz komentara. Učitavanje će možda trajati malo duže.", + "View YouTube comments": "Prikaži YouTube komentare", + "View more comments on Reddit": "Prikaži još komentara na Redditu", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Prikaži `x` komentara.([^.,0-9]|^)1([^.,0-9]|$)", + "": "Prikaži `x` komentara." + }, + "View Reddit comments": "Prikaži Reddit komentare", + "Hide replies": "Sakrij odgovore", + "Show replies": "Prikaži odgovore", + "Incorrect password": "Neispravna lozinka", + "Quota exceeded, try again in a few hours": "Kvota je prekoračena. Pokušaj ponovo za par sati", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Prijava neuspjela. Provjeri da je dvofaktorska autentifikacija uključena (Authenticator ili SMS).", + "Invalid TFA code": "Neispravan TFA kod", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "Prijava neuspjela. Možda zato što za tvoj račun nije uključena dvofaktorska autentifikacija.", + "Wrong answer": "Krivi odgovor", + "Erroneous CAPTCHA": "Neispravan CAPTCHA", + "CAPTCHA is a required field": "CAPTCHA je obavezno polje", + "User ID is a required field": "Korisnički ID je obavezno polje", + "Password is a required field": "Polje lozinke je obavezno polje", + "Wrong username or password": "Krivo korisničko ime ili lozinka", + "Please sign in using 'Log in with Google'": "Za prijavu koristi „Prijavi se pomoću Googlea”", + "Password cannot be empty": "Polje lozinke ne smije ostati prazno", + "Password cannot be longer than 55 characters": "Lozinka ne može biti duža od 55 znakova", + "Please log in": "Prijavi se", + "Invidious Private Feed for `x`": "Invidious privatni feed za `x`", + "channel:`x`": "kanal:`x`", + "Deleted or invalid channel": "Izbrisan ili neispravan kanal", + "This channel does not exist.": "Ovaj kanal ne postoji.", + "Could not get channel info.": "Neuspjelo dobivanje podataka kanala.", + "Could not fetch comments": "Neuspjelo dohvaćanje komentara", + "View `x` replies": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Prikaži `x` odgovora.([^.,0-9]|^)1([^.,0-9]|$)", + "": "Prikaži `x` odgovora." + }, + "`x` ago": "prije `x`", + "Load more": "Učitaj više", + "`x` points": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bodova.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` bodova." + }, + "Could not create mix.": "Neuspjelo stvaranje miksa.", + "Empty playlist": "Prazna playlista", + "Not a playlist.": "Nije playlista.", + "Playlist does not exist.": "Playlista ne postoji.", + "Could not pull trending pages.": "Neuspjelo preuzimanje stranica u trendu.", + "Hidden field \"challenge\" is a required field": "Skriveno polje „izazov” je obavezno polje", + "Hidden field \"token\" is a required field": "Skriveno polje „token” je obavezno polje", + "Erroneous challenge": "Neispravan izazov", + "Erroneous token": "Neispravan token", + "No such user": "Takav korisnik ne postoji", + "Token is expired, please try again": "Token je istekao, pokušaj ponovo", + "English": "Engleski", + "English (auto-generated)": "Engleski (automatki generirano)", + "Afrikaans": "Afrikaanski", + "Albanian": "Albanski", + "Amharic": "Amharski", + "Arabic": "Arapski", + "Armenian": "Armenski", + "Azerbaijani": "Azerbajdžanski", + "Bangla": "Bengalski", + "Basque": "Baskijski", + "Belarusian": "Bjeloruski", + "Bosnian": "Bošnjački", + "Bulgarian": "Bugarski", + "Burmese": "Burmanski", + "Catalan": "Katalonski", + "Cebuano": "Cebuano", + "Chinese (Simplified)": "Kineski (pojednostavljeni)", + "Chinese (Traditional)": "Kineski (tradicionalni)", + "Corsican": "Korzikanski", + "Croatian": "Hrvatski", + "Czech": "Češki", + "Danish": "Danski", + "Dutch": "Nizozemski", + "Esperanto": "Esperanto", + "Estonian": "Estonski", + "Filipino": "Filipinski", + "Finnish": "Finski", + "French": "Francuski", + "Galician": "Galicijski", + "Georgian": "Gruzijski", + "German": "Njemački", + "Greek": "Grčki", + "Gujarati": "Gudžaratski", + "Haitian Creole": "Haitjanski kreolski", + "Hausa": "Hauski", + "Hawaiian": "Havajski", + "Hebrew": "Hebrejski", + "Hindi": "Hindski", + "Hmong": "Hmong", + "Hungarian": "Mađarski", + "Icelandic": "Islandski", + "Igbo": "Igboški", + "Indonesian": "Indonezijski", + "Irish": "Irski", + "Italian": "Talijanski", + "Japanese": "Japanski", + "Javanese": "Javanski", + "Kannada": "Kannada", + "Kazakh": "Kazaški", + "Khmer": "Kmerski", + "Korean": "Korejski", + "Kurdish": "Kurdski", + "Kyrgyz": "Kirgiški", + "Lao": "Laoški", + "Latin": "Latinski", + "Latvian": "Latvijski", + "Lithuanian": "Litvanski", + "Luxembourgish": "Luksemburgški", + "Macedonian": "Makedonski", + "Malagasy": "Malagaški", + "Malay": "Malajski", + "Malayalam": "Malajalamski", + "Maltese": "Malteški", + "Maori": "Maorski", + "Marathi": "Marathi", + "Mongolian": "Mongolski", + "Nepali": "Nepalski", + "Norwegian Bokmål": "Norveški Bokmål", + "Nyanja": "Nijanja", + "Pashto": "Paštunski", + "Persian": "Perzijski", + "Polish": "Poljski", + "Portuguese": "Portugalski", + "Punjabi": "Pandžapski", + "Romanian": "Rumunjski", + "Russian": "Ruski", + "Samoan": "Samoanski", + "Scottish Gaelic": "Škotski galski", + "Serbian": "Srpski", + "Shona": "Šona", + "Sindhi": "Sindhi", + "Sinhala": "Singaleški", + "Slovak": "Slovački", + "Slovenian": "Slovenski", + "Somali": "Somalijski", + "Southern Sotho": "Sjeverno samski", + "Spanish": "Španjolski", + "Spanish (Latin America)": "Španjolski (Latinska Amerika)", + "Sundanese": "Sundski", + "Swahili": "Svahili", + "Swedish": "Švedski", + "Tajik": "Tadžički", + "Tamil": "Tamilski", + "Telugu": "Teluški", + "Thai": "Tajlandski", + "Turkish": "Turski", + "Ukrainian": "Ukrajinski", + "Urdu": "Urdski", + "Uzbek": "Uzbečki", + "Vietnamese": "Vijetnamski", + "Welsh": "Velški", + "Western Frisian": "Zapadni frizijski", + "Xhosa": "Xhosa", + "Yiddish": "Jidiš", + "Yoruba": "Jorubški", + "Zulu": "Zulu", + "`x` years": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` g.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` g." + }, + "`x` months": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mj.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` mj." + }, + "`x` weeks": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tj.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` tj." + }, + "`x` days": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dana.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` dana." + }, + "`x` hours": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` h.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` h." + }, + "`x` minutes": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` min.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` min." + }, + "`x` seconds": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` s.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` s." + }, + "Fallback comments: ": "Alternativni komentari: ", + "Popular": "Popularni", + "Top": "Najbolji", + "About": "Informacije", + "Rating: ": "Ocjena: ", + "Language: ": "Jezik: ", + "View as playlist": "Prikaži kao playlistu", + "Default": "Standardno", + "Music": "Glazba", + "Gaming": "Videoigre", + "News": "Vijesti", + "Movies": "Filmovi", + "Download": "Preuzmi", + "Download as: ": "Preuzmi kao: ", + "%A %B %-d, %Y": "%A, %-d. %B %Y.", + "(edited)": "(uređeno)", + "YouTube comment permalink": "Permalink YouTube komentara", + "permalink": "permalink", + "`x` marked it with a ❤": "Označeno sa ❤ od `x`", + "Audio mode": "Audio modus", + "Video mode": "Videomodus", + "Videos": "Videa", + "Playlists": "Playliste", + "Community": "Zajednica", + "Current version: ": "Trenutačna verzija: " +} diff --git a/locales/id.json b/locales/id.json new file mode 100644 index 00000000..217ea1c7 --- /dev/null +++ b/locales/id.json @@ -0,0 +1,387 @@ +{ + "`x` subscribers": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pelanggan.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` pelanggan." + }, + "`x` videos": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` video." + }, + "`x` playlists": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` daftar putar.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` daftar putar." + }, + "LIVE": "SIARAN LANGSUNG", + "Shared `x` ago": "Dibagikan`x` lalu", + "Unsubscribe": "Batal Langganan", + "Subscribe": "Langganan", + "View channel on YouTube": "Lihat kanal di YouTube", + "View playlist on YouTube": "Lihat daftar putar di YouTube", + "newest": "terbaru", + "oldest": "terlawas", + "popular": "populer", + "last": "terakhir", + "Next page": "Halaman berikutnya", + "Previous page": "Halaman sebelumnya", + "Clear watch history?": "Bersihkan riwayat tontonan?", + "New password": "Kata sandi baru", + "New passwords must match": "Kata sandi baru harus cocok", + "Cannot change password for Google accounts": "Tidak dapat mengganti kata sandi untuk akun Google", + "Authorize token?": "Otorisasi token?", + "Authorize token for `x`?": "Otorisasi token untuk `x`?", + "Yes": "Ya", + "No": "Tidak", + "Import and Export Data": "Impor dan Ekspor Data", + "Import": "Impor", + "Import Invidious data": "Impor data Invidious", + "Import YouTube subscriptions": "Impor langganan YouTube", + "Import FreeTube subscriptions (.db)": "Impor langganan FreeTube (.db)", + "Import NewPipe subscriptions (.json)": "Impor langganan NewPipe (.json)", + "Import NewPipe data (.zip)": "Impor data NewPipe (.zip)", + "Export": "Ekspor", + "Export subscriptions as OPML": "Ekspor langganan sebagai OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Ekspor langganan sebagai OPML (untuk NewPipe & FreeTube)", + "Export data as JSON": "Ekspor data sebagai JSON", + "Delete account?": "Hapus akun?", + "History": "Riwayat", + "An alternative front-end to YouTube": "Sebuah alternatif front-end untuk YouTube", + "JavaScript license information": "Informasi lisensi JavaScript", + "source": "sumber", + "Log in": "Masuk", + "Log in/register": "Daftar", + "Log in with Google": "Masuk dengan Google", + "User ID": "ID Pengguna", + "Password": "Kata Sandi", + "Time (h:mm:ss):": "Waktu (j:mm:dd):", + "Text CAPTCHA": "Teks CAPTCHA", + "Image CAPTCHA": "Gambar CAPTCHA", + "Sign In": "Masuk", + "Register": "Daftar", + "E-mail": "Surel", + "Google verification code": "Kode verifikasi Google", + "Preferences": "Preferensi", + "Player preferences": "Preferensi pemutar", + "Always loop: ": "Selalu ulangi: ", + "Autoplay: ": "Putar-Otomatis: ", + "Play next by default: ": "Putar selanjutnya secara default: ", + "Autoplay next video: ": "Otomatis-Putar video berikutnya: ", + "Listen by default: ": "Dengarkan secara default: ", + "Proxy videos: ": "Video Proksi: ", + "Default speed: ": "Kecepatan default: ", + "Preferred video quality: ": "Kualitas video yang disukai: ", + "Player volume: ": "Volume pemutar: ", + "Default comments: ": "Komentar default: ", + "youtube": "youtube", + "reddit": "reddit", + "Default captions: ": "Subtitel default: ", + "Fallback captions: ": "", + "Show related videos: ": "Tampilkan video terkait: ", + "Show annotations by default: ": "Tampilkan anotasi secara default: ", + "Visual preferences": "Preferensi visual", + "Player style: ": "Gaya pemutar: ", + "Dark mode: ": "Mode gelap: ", + "Theme: ": "Tema: ", + "dark": "gelap", + "light": "terang", + "Thin mode: ": "Mode tipis: ", + "Subscription preferences": "Preferensi langganan", + "Show annotations by default for subscribed channels: ": "Tampilkan anotasi secara default untuk kanal langganan: ", + "Redirect homepage to feed: ": "Arahkan kembali laman beranda ke umpan: ", + "Number of videos shown in feed: ": "Jumlah video ditampilkan di umpan: ", + "Sort videos by: ": "Urutkan video berdasarkan: ", + "published": "dipublikasi", + "published - reverse": "dipublikasi - sebaliknya", + "alphabetically": "menurut abjad", + "alphabetically - reverse": "menurut abjad - sebaliknya", + "channel name": "nama kanal", + "channel name - reverse": "nama kanal - sebaliknya", + "Only show latest video from channel: ": "Hanya tampilkan video terbaru dari kanal: ", + "Only show latest unwatched video from channel: ": "Hanya tampilkan video belum ditonton terbaru dari kanal: ", + "Only show unwatched: ": "Hanya tampilkan belum ditonton: ", + "Only show notifications (if there are any): ": "Hanya tampilkan pemberitahuan (jika ada): ", + "Enable web notifications": "Aktifkan pemberitahuan web", + "`x` uploaded a video": "`x` mengunggah video", + "`x` is live": "`x` sedang siaran langsung", + "Data preferences": "Preferensi Data", + "Clear watch history": "Bersihkan riwayat tontonan", + "Import/export data": "Impor/Ekspor data", + "Change password": "Ganti kata sandi", + "Manage subscriptions": "Atur langganan", + "Manage tokens": "Atur token", + "Watch history": "Riwayat tontonan", + "Delete account": "Hapus akun", + "Administrator preferences": "Preferensi administrator", + "Default homepage: ": "Laman beranda default: ", + "Feed menu: ": "Menu umpan: ", + "Top enabled: ": "", + "CAPTCHA enabled: ": "CAPTCHA diaktifkan: ", + "Login enabled: ": "Masuk diaktifkan: ", + "Registration enabled: ": "Registrasi diaktifkan: ", + "Report statistics: ": "Laporan statistik: ", + "Save preferences": "Simpan preferensi", + "Subscription manager": "Pengatur langganan", + "Token manager": "Pengatur token", + "Token": "Token", + "`x` subscriptions": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` langganan.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` langganan." + }, + "`x` tokens": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` token." + }, + "Import/export": "Impor/ekspor", + "unsubscribe": "batal langganan", + "revoke": "cabut", + "Subscriptions": "Langganan", + "`x` unseen notifications": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pemberitahuan belum dilihat.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` pemberitahuan belum dilihat." + }, + "search": "cari", + "Log out": "Keluar", + "Released under the AGPLv3 by Omar Roth.": "Dirilis dibawah AGPLv3 oleh Omar Roth.", + "Source available here.": "Sumber tersedia di sini.", + "View JavaScript license information.": "Tampilkan informasi lisensi JavaScript.", + "View privacy policy.": "Lihat kebijakan privasi.", + "Trending": "Sedang tren", + "Public": "Publik", + "Unlisted": "Tidak terdaftar", + "Private": "Pribadi", + "View all playlists": "Lihat semua daftar putar", + "Updated `x` ago": "Diperbarui`x` lalu", + "Delete playlist `x`?": "Hapus daftar putar `x`?", + "Delete playlist": "Hapus daftar putar", + "Create playlist": "Buat daftar putar", + "Title": "Judul", + "Playlist privacy": "Privasi daftar putar", + "Editing playlist `x`": "Menyunting daftar putar `x`", + "Watch on YouTube": "Tonton di YouTube", + "Hide annotations": "Sembunyikan anotasi", + "Show annotations": "Tampilkan anotasi", + "Genre: ": "Genre: ", + "License: ": "Lisensi: ", + "Family friendly? ": "Ramah keluarga? ", + "Wilson score: ": "Skor Wilson: ", + "Engagement: ": "Keterikatan: ", + "Whitelisted regions: ": "Wilayah daftar-putih: ", + "Blacklisted regions: ": "Wilayah daftar-hitam: ", + "Shared `x`": "Berbagi`x`", + "`x` views": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tampilan.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` tampilan." + }, + "Premieres in `x`": "Tayang dalam `x`", + "Premieres `x`": "Tayang `x`", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hai! Kelihatannya JavaScript kamu dimatikan. Klik di sini untuk melihat komentar, perlu diingat hal ini mungkin membutuhkan waktu sedikit lebih lama untuk dimuat.", + "View YouTube comments": "Lihat komentar YouTube", + "View more comments on Reddit": "Lihat lebih banyak komentar di Reddit", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Lihat`x` komentar.([^.,0-9]|^)1([^.,0-9]|$)", + "": "Lihat`x` komentar." + }, + "View Reddit comments": "Lihat komentar Reddit", + "Hide replies": "Sembunyikan balasan", + "Show replies": "Lihat balasan", + "Incorrect password": "Kata sandi salah", + "Quota exceeded, try again in a few hours": "Kuota penuh, coba lagi dalam beberapa jam", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Tidak dapat masuk, pastikan autentikasi dua-faktor (autentikator atau SMS) sudah nyala.", + "Invalid TFA code": "Kode TFA tidak valid", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "Gagal masuk. Ini mungkin disebabkan autentikasi dua-faktor tidak dinyalakan untuk akun Anda.", + "Wrong answer": "Jawaban salah", + "Erroneous CAPTCHA": "CAPTCHA salah", + "CAPTCHA is a required field": "CAPTCHA perlu diisi", + "User ID is a required field": "ID pengguna perlu diisi", + "Password is a required field": "Kata sandi perlu diisi", + "Wrong username or password": "Nama pengguna atau kata sandi salah", + "Please sign in using 'Log in with Google'": "Harap masuk menggunakan 'Masuk dengan Google'", + "Password cannot be empty": "Kata sandi tidak boleh kosong", + "Password cannot be longer than 55 characters": "Kata sandi tidak boleh lebih dari 55 karakter", + "Please log in": "Harap masuk", + "Invidious Private Feed for `x`": "Umpan pribadi Invidious untuk`x`", + "channel:`x`": "kanal:`x`", + "Deleted or invalid channel": "Kanal terhapus atau invalid", + "This channel does not exist.": "Kanal ini tidak ada.", + "Could not get channel info.": "Tidak bisa mendapatkan info kanal.", + "Could not fetch comments": "Tidak dapat memuat komentar", + "View `x` replies": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Lihat`x` balasan.([^.,0-9]|^)1([^.,0-9]|$)", + "": "Lihat `x` balasan." + }, + "`x` ago": "`x` lalu", + "Load more": "Muat lebih banyak", + "`x` points": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Could not create mix.": "", + "Empty playlist": "Daftar putar kosong", + "Not a playlist.": "Bukan daftar putar.", + "Playlist does not exist.": "Daftar putar tidak ada.", + "Could not pull trending pages.": "Tidak bisa mendapatkan laman tren.", + "Hidden field \"challenge\" is a required field": "", + "Hidden field \"token\" is a required field": "", + "Erroneous challenge": "", + "Erroneous token": "", + "No such user": "Tidak ada pengguna demikian", + "Token is expired, please try again": "Token kadaluwarsa, harap coba lagi", + "English": "Bahasa Inggris", + "English (auto-generated)": "Bahasa Inggris (dibuat-otomatis)", + "Afrikaans": "Bahasa Afrika", + "Albanian": "Bahasa Albania", + "Amharic": "Bahasa Amharik", + "Arabic": "Bahasa arab", + "Armenian": "Bahasa Armenia", + "Azerbaijani": "", + "Bangla": "", + "Basque": "", + "Belarusian": "", + "Bosnian": "Bahasa Bosnia", + "Bulgarian": "Bahasa Bulgaria", + "Burmese": "Bahasa Birma", + "Catalan": "", + "Cebuano": "", + "Chinese (Simplified)": "", + "Chinese (Traditional)": "", + "Corsican": "", + "Croatian": "Bahasa Kroasia", + "Czech": "Bahasa Ceko", + "Danish": "", + "Dutch": "Bahasa Belanda", + "Esperanto": "", + "Estonian": "", + "Filipino": "", + "Finnish": "", + "French": "", + "Galician": "", + "Georgian": "", + "German": "", + "Greek": "Bahasa Yunani", + "Gujarati": "", + "Haitian Creole": "", + "Hausa": "", + "Hawaiian": "", + "Hebrew": "", + "Hindi": "", + "Hmong": "", + "Hungarian": "", + "Icelandic": "", + "Igbo": "", + "Indonesian": "Bahasa Indonesia", + "Irish": "", + "Italian": "", + "Japanese": "Bahasa Jepang", + "Javanese": "Bahasa Jawa", + "Kannada": "", + "Kazakh": "", + "Khmer": "", + "Korean": "Bahasa Korea", + "Kurdish": "", + "Kyrgyz": "", + "Lao": "", + "Latin": "", + "Latvian": "", + "Lithuanian": "", + "Luxembourgish": "", + "Macedonian": "", + "Malagasy": "", + "Malay": "Bahasa Melayu", + "Malayalam": "", + "Maltese": "", + "Maori": "", + "Marathi": "", + "Mongolian": "", + "Nepali": "", + "Norwegian Bokmål": "", + "Nyanja": "", + "Pashto": "", + "Persian": "", + "Polish": "", + "Portuguese": "", + "Punjabi": "", + "Romanian": "", + "Russian": "", + "Samoan": "", + "Scottish Gaelic": "", + "Serbian": "", + "Shona": "", + "Sindhi": "", + "Sinhala": "", + "Slovak": "", + "Slovenian": "", + "Somali": "", + "Southern Sotho": "", + "Spanish": "", + "Spanish (Latin America)": "", + "Sundanese": "Bahasa Sunda", + "Swahili": "", + "Swedish": "", + "Tajik": "", + "Tamil": "", + "Telugu": "", + "Thai": "Bahasa Thailand", + "Turkish": "", + "Ukrainian": "", + "Urdu": "", + "Uzbek": "", + "Vietnamese": "Bahasa Vietnam", + "Welsh": "", + "Western Frisian": "", + "Xhosa": "", + "Yiddish": "", + "Yoruba": "", + "Zulu": "", + "`x` years": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tahun.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` tahun." + }, + "`x` months": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bulan.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` bulan." + }, + "`x` weeks": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pekan.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` pekan." + }, + "`x` days": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hari.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` hari." + }, + "`x` hours": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jam.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` jam." + }, + "`x` minutes": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` menit.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` menit." + }, + "`x` seconds": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` detik.([^.,0-9]|^)1([^.,0-9]|$)", + "": "`x` detik." + }, + "Fallback comments: ": "", + "Popular": "Populer", + "Top": "", + "About": "Ihwal", + "Rating: ": "Peringkat: ", + "Language: ": "Bahasa: ", + "View as playlist": "Tampilkan sebagai daftar putar", + "Default": "Asali", + "Music": "Musik", + "Gaming": "Gaming", + "News": "Berita", + "Movies": "Film", + "Download": "Unduh", + "Download as: ": "Unduh sebagai: ", + "%A %B %-d, %Y": "", + "(edited)": "(disunting)", + "YouTube comment permalink": "", + "permalink": "", + "`x` marked it with a ❤": "`x` telah ditandai dengan ❤", + "Audio mode": "Mode audio", + "Video mode": "Mode video", + "Videos": "Video", + "Playlists": "Daftar putar", + "Community": "Komunitas", + "Current version: ": "Versi saat ini: " +} diff --git a/locales/is.json b/locales/is.json index 4cd15076..a2943b88 100644 --- a/locales/is.json +++ b/locales/is.json @@ -1,7 +1,7 @@ { - "`x` subscribers": "", - "`x` videos": "", - "`x` playlists": "", + "`x` subscribers": "`x`áskrifendur", + "`x` videos": "`x` myndbönd", + "`x` playlists": "`x` spilunarlistar", "`x` subscribers.": "`x` áskrifandar.", "`x` videos.": "`x` myndbönd.", "LIVE": "BEINT", @@ -71,11 +71,11 @@ "Show related videos: ": "Sýna tengd myndbönd? ", "Show annotations by default: ": "Á að sýna glósur sjálfgefið? ", "Visual preferences": "Sjónrænar stillingar", - "Player style: ": "", + "Player style: ": "Spilara stíl: ", "Dark mode: ": "Myrkur ham: ", - "Theme: ": "", - "dark": "", - "light": "", + "Theme: ": "Þema: ", + "dark": "dimmt", + "light": "ljóst", "Thin mode: ": "Þunnt ham: ", "Subscription preferences": "Áskriftarstillingar", "Show annotations by default for subscribed channels: ": "Á að sýna glósur sjálfgefið fyrir áskriftarrásir? ", @@ -113,13 +113,13 @@ "Report statistics: ": "Skrá talnagögn? ", "Save preferences": "Vista stillingar", "Subscription manager": "Áskriftarstjóri", - "`x` subscriptions": "", - "`x` tokens": "", + "`x` subscriptions": "`x` áskrifendur", + "`x` tokens": "`x` tákn", "Token manager": "Táknstjóri", "Token": "Tákn", "`x` subscriptions.": "`x` áskriftir.", "`x` tokens.": "`x` tákn.", - "`x` unseen notifications": "", + "`x` unseen notifications": "`x` óséðar tilkynningar", "Import/export": "Flytja inn/út", "unsubscribe": "afskrá", "revoke": "afturkalla", @@ -132,24 +132,24 @@ "View JavaScript license information.": "Skoða JavaScript leyfisupplýsingar.", "View privacy policy.": "Skoða meðferð persónuupplýsinga.", "Trending": "Vinsælt", - "Public": "", + "Public": "Opinbert", "Unlisted": "Óskráð", - "Private": "", - "View all playlists": "", - "Updated `x` ago": "", - "Delete playlist `x`?": "", - "Delete playlist": "", - "Create playlist": "", - "Title": "", - "Playlist privacy": "", - "Editing playlist `x`": "", + "Private": "Einka", + "View all playlists": "Skoða alla spilunarlista", + "Updated `x` ago": "Uppfært `x` síðann", + "Delete playlist `x`?": "Eiða spilunarlista `x`?", + "Delete playlist": "Eiða spilunarlista", + "Create playlist": "Búa til spilunarlista", + "Title": "Titill", + "Playlist privacy": "Spilunarlista opinberri", + "Editing playlist `x`": "Að breyta spilunarlista `x`", "Watch on YouTube": "Horfa á YouTube", "Hide annotations": "Fela glósur", "Show annotations": "Sýna glósur", "Genre: ": "Tegund: ", "License: ": "Notkunarleyfi: ", "Family friendly? ": "Fjölskylduvænt? ", - "`x` views": "", + "`x` views": "`x` áhorf", "Wilson score: ": "Wilson stig: ", "Engagement: ": "Þátttöku: ", "Whitelisted regions: ": "Svæði á hvítum lista: ", @@ -180,10 +180,10 @@ "Password cannot be empty": "Lykilorð má ekki vera autt", "Password cannot be longer than 55 characters": "Lykilorð má ekki vera lengra en 55 stafir", "Please log in": "Vinsamlegast skráðu þig inn", - "View `x` replies": "", + "View `x` replies": "Skoða `x` svör", "Invidious Private Feed for `x`": "Invidious Persónulegur Straumur fyrir `x`", "channel:`x`": "rás:`x`", - "`x` points": "", + "`x` points": "`x` stig", "Deleted or invalid channel": "Eytt eða ógild rás", "This channel does not exist.": "Þessi rás er ekki til.", "Could not get channel info.": "Ekki tókst að fá rásarupplýsingar.", @@ -301,13 +301,13 @@ "Turkish": "Tyrkneska", "Ukrainian": "Úkraníska", "Urdu": "Úrdú", - "`x` years": "", - "`x` months": "", - "`x` weeks": "", - "`x` days": "", - "`x` hours": "", - "`x` minutes": "", - "`x` seconds": "", + "`x` years": "`x` ár", + "`x` months": "`x` mánuði", + "`x` weeks": "`x` vikur", + "`x` days": "`x` daga", + "`x` hours": "`x` klukkustundir", + "`x` minutes": "`x` mínútur", + "`x` seconds": "`x` sekúndur", "Uzbek": "Úsbekíska", "Vietnamese": "Víetnamska", "Welsh": "Velska", @@ -325,13 +325,13 @@ "`x` seconds.": "`x` sekúndur.", "Fallback comments: ": "Vara ummæli: ", "Popular": "Vinsælt", - "permalink": "", + "permalink": "Varanlegur tengill", "Top": "Topp", "About": "Um", "Rating: ": "Einkunn: ", "Language: ": "Tungumál: ", "View as playlist": "Skoða sem spilunarlista", - "Community": "", + "Community": "Samfélag", "Default": "Sjálfgefið", "Music": "Tónlist", "Gaming": "Tólvuleikja", @@ -348,4 +348,4 @@ "Videos": "Myndbönd", "Playlists": "Spilunarlistar", "Current version: ": "Núverandi útgáfa: " -} \ No newline at end of file +} diff --git a/locales/it.json b/locales/it.json index 2e993c81..789bdd1a 100644 --- a/locales/it.json +++ b/locales/it.json @@ -1,12 +1,8 @@ { - "`x` subscribers.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` iscritto", - "": "`x` iscritti." - }, - "`x` videos.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video", - "": "`x` video." - }, + "`x` subscribers..([^.,0-9]|^)1([^.,0-9]|$)": "`x` iscritto", + "`x` subscribers..": "`x` iscritti.", + "`x` videos..([^.,0-9]|^)1([^.,0-9]|$)": "`x` video", + "`x` videos..": "`x` video.", "`x` playlists": "`x` playlist", "LIVE": "IN DIRETTA", "Shared `x` ago": "Condiviso `x` fa", @@ -54,7 +50,7 @@ "Image CAPTCHA": "Immagine CAPTCHA", "Sign In": "Accedi", "Register": "Registrati", - "E-mail": "Email", + "E-mail": "E-mail", "Google verification code": "Codice di verifica Google", "Preferences": "Preferenze", "Player preferences": "Preferenze del riproduttore", @@ -119,22 +115,16 @@ "Subscription manager": "Gestione delle iscrizioni", "Token manager": "Gestione dei gettoni", "Token": "Gettone", - "`x` subscriptions.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` iscrizione", - "": "`x` iscrizioni." - }, - "`x` tokens.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` gettone", - "": "`x` gettoni." - }, + "`x` subscriptions..([^.,0-9]|^)1([^.,0-9]|$)": "`x` iscrizione", + "`x` subscriptions..": "`x` iscrizioni.", + "`x` tokens..([^.,0-9]|^)1([^.,0-9]|$)": "`x` gettone", + "`x` tokens..": "`x` gettoni.", "Import/export": "Importa/esporta", "unsubscribe": "disiscriviti", "revoke": "revoca", "Subscriptions": "Iscrizioni", - "`x` unseen notifications.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notifica non visualizzata", - "": "`x` notifiche non visualizzate." - }, + "`x` unseen notifications..([^.,0-9]|^)1([^.,0-9]|$)": "`x` notifica non visualizzata", + "`x` unseen notifications..": "`x` notifiche non visualizzate.", "search": "Cerca", "Log out": "Esci", "Released under the AGPLv3 by Omar Roth.": "Pubblicato con licenza AGPLv3 da Omar Roth.", @@ -164,10 +154,8 @@ "Whitelisted regions: ": "Regioni in lista bianca: ", "Blacklisted regions: ": "Regioni in lista nera: ", "Shared `x`": "Condiviso `x`", - "`x` views.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizzazione", - "": "`x` visualizzazioni." - }, + "`x` views..([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizzazione", + "`x` views..": "`x` visualizzazioni.", "Premieres in `x`": "In anteprima in `x`", "Premieres `x`": "In anteprima `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Ciao! Sembra che tu abbia disattivato JavaScript. Clicca qui per visualizzare i commenti. Considera che potrebbe volerci più tempo.", @@ -188,7 +176,7 @@ "User ID is a required field": "L'ID utente è obbligatorio", "Password is a required field": "La password è un campo obbligatorio", "Wrong username or password": "Nome utente o password errati", - "Please sign in using 'Log in with Google'": "Per favore accedi con \"Entra con Google\"", + "Please sign in using 'Log in with Google'": "Per favore accedi con «Entra con Google»", "Password cannot be empty": "La password non può essere vuota", "Password cannot be longer than 55 characters": "La password non può contenere più di 55 caratteri", "Please log in": "Per favore, accedi", @@ -198,24 +186,20 @@ "This channel does not exist.": "Questo canale non esiste.", "Could not get channel info.": "Impossibile ottenere le informazioni del canale.", "Could not fetch comments": "Impossibile recuperare i commenti", - "View `x` replies.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Visualizza `x` risposta", - "": "Visualizza `x` risposte." - }, + "View `x` replies..([^.,0-9]|^)1([^.,0-9]|$)": "Visualizza `x` risposta", + "View `x` replies..": "Visualizza `x` risposte.", "`x` ago": "`x` fa", "Load more": "Carica altro", - "`x` points.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` punto", - "": "`x` punti." - }, + "`x` points..([^.,0-9]|^)1([^.,0-9]|$)": "`x` punto", + "`x` points..": "`x` punti.", "Could not create mix.": "Impossibile creare il mix.", "Empty playlist": "Playlist vuota", "Not a playlist.": "Non è una playlist.", "Playlist does not exist.": "La playlist non esiste.", "Could not pull trending pages.": "Impossibile recuperare le tendenze.", "Hidden field \"challenge\" is a required field": "Il campo nascosto \"challenge\" è obbligatorio", - "Hidden field \"token\" is a required field": "Il campo nascosto \"token\" è obbligatorio", - "Erroneous challenge": "Campo \"challenge\" non valido", + "Hidden field \"token\" is a required field": "Il campo nascosto «token» è obbligatorio", + "Erroneous challenge": "Campo «challenge» non valido", "Erroneous token": "Campo \"token\" non valido", "No such user": "Utente non valido", "Token is expired, please try again": "Gettone scaduto, riprova", @@ -288,7 +272,7 @@ "Nepali": "Nepalese", "Norwegian Bokmål": "Norvegese", "Nyanja": "Nyanja", - "Pashto": "Lingua pashtu", + "Pashto": "Pashtu", "Persian": "Persiano", "Polish": "Polacco", "Portuguese": "Portoghese", @@ -325,34 +309,20 @@ "Yiddish": "Yiddish", "Yoruba": "Yoruba", "Zulu": "Zulu", - "`x` years.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` anno", - "": "`x` anni." - }, - "`x` months.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mese", - "": "`x` mesi." - }, - "`x` weeks.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` settimana", - "": "`x` settimane." - }, - "`x` days.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` giorno", - "": "`x` giorni." - }, - "`x` hours.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ora", - "": "`x` ore." - }, - "`x` minutes.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuto", - "": "`x` minuti." - }, - "`x` seconds.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` secondo", - "": "`x` secondi." - }, + "`x` years..([^.,0-9]|^)1([^.,0-9]|$)": "`x` anno", + "`x` years..": "`x` anni.", + "`x` months..([^.,0-9]|^)1([^.,0-9]|$)": "`x` mese", + "`x` months..": "`x` mesi.", + "`x` weeks..([^.,0-9]|^)1([^.,0-9]|$)": "`x` settimana", + "`x` weeks..": "`x` settimane.", + "`x` days..([^.,0-9]|^)1([^.,0-9]|$)": "`x` giorno", + "`x` days..": "`x` giorni.", + "`x` hours..([^.,0-9]|^)1([^.,0-9]|$)": "`x` ora", + "`x` hours..": "`x` ore.", + "`x` minutes..([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuto", + "`x` minutes..": "`x` minuti.", + "`x` seconds..([^.,0-9]|^)1([^.,0-9]|$)": "`x` secondo", + "`x` seconds..": "`x` secondi.", "Fallback comments: ": "Commenti alternativi: ", "Popular": "Popolare", "Top": "Top", @@ -378,4 +348,4 @@ "Playlists": "Playlist", "Community": "Comunità", "Current version: ": "Versione attuale: " -} \ No newline at end of file +} diff --git a/locales/ja.json b/locales/ja.json index e9ca0e62..0c429d6b 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -201,7 +201,7 @@ "Invidious Private Feed for `x`": "`x` の Invidious プライベートフィード", "channel:`x`": "チャンネル:`x`", "Deleted or invalid channel": "削除済みまたは無効なチャンネルです", - "This channel does not exist.": "このチャンネルは存在していません", + "This channel does not exist.": "このチャンネルは存在しません。", "Could not get channel info.": "チャンネル情報を取得できませんでした。", "Could not fetch comments": "コメントを取得できませんでした", "View `x` replies": { @@ -217,7 +217,7 @@ "Could not create mix.": "ミックスを作成できませんでした。", "Empty playlist": "空の再生リスト", "Not a playlist.": "再生リストではありません。", - "Playlist does not exist.": "再生リストが存在していません・", + "Playlist does not exist.": "再生リストが存在しません。", "Could not pull trending pages.": "急上昇ページを取得できませんでした。", "Hidden field \"challenge\" is a required field": "非表示項目 \"challenge\" は必須項目です", "Hidden field \"token\" is a required field": "非表示項目 \"token\" は必須項目です", @@ -384,4 +384,4 @@ "Playlists": "プレイリスト", "Community": "コミュニティ", "Current version: ": "現在のバージョン: " -} \ No newline at end of file +} diff --git a/locales/nb-NO.json b/locales/nb-NO.json index ff40e27b..6bf5107b 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -132,12 +132,12 @@ "Private": "Privat", "View all playlists": "Vis alle spillelister", "Updated `x` ago": "Oppdatert `x` siden", - "Delete playlist `x`?": "Slett spillelisten `x`?", + "Delete playlist `x`?": "Slett spilleliste «x»?", "Delete playlist": "Slett spilleliste", "Create playlist": "Opprett spilleliste", "Title": "Tittel", "Playlist privacy": "Vern av spilleliste", - "Editing playlist `x`": "Redigerer spillelisten `x`", + "Editing playlist `x`": "Endre spilleliste «x»", "Watch on YouTube": "Vis video på YouTube", "Hide annotations": "Skjul merknader", "Show annotations": "Vis merknader", @@ -174,7 +174,7 @@ "Password cannot be empty": "Passordet kan ikke være tomt", "Password cannot be longer than 55 characters": "Passordet kan ikke være lengre enn 55 tegn", "Please log in": "Logg inn", - "Invidious Private Feed for `x`": "Ugyldig privat flyt for `x`", + "Invidious Private Feed for `x`": "Invidious personlige flyt for `x`", "channel:`x`": "kanal `x`", "Deleted or invalid channel": "Slettet eller ugyldig kanal", "This channel does not exist.": "Denne kanalen finnes ikke.", @@ -203,18 +203,18 @@ "Arabic": "Arabisk", "Armenian": "Armensk", "Azerbaijani": "Aserbajdsjansk", - "Bangla": "", - "Basque": "", + "Bangla": "Bengali", + "Basque": "Baskisk", "Belarusian": "Hviterussisk", "Bosnian": "Bosnisk", "Bulgarian": "Bulgarsk", "Burmese": "Burmesisk", "Catalan": "Katalansk", - "Cebuano": "", - "Chinese (Simplified)": "", - "Chinese (Traditional)": "", - "Corsican": "", - "Croatian": "", + "Cebuano": "Sugboanon", + "Chinese (Simplified)": "Forenklet kinesisk", + "Chinese (Traditional)": "Tradisjonell kinesisk", + "Corsican": "Korsikansk", + "Croatian": "Kroatisk", "Czech": "Tsjekkisk", "Danish": "Dansk", "Dutch": "Nederlandsk", @@ -223,84 +223,84 @@ "Filipino": "Filippinsk", "Finnish": "Finsk", "French": "Fransk", - "Galician": "", - "Georgian": "", + "Galician": "Galisisk", + "Georgian": "Georgisk", "German": "Tysk", "Greek": "Gresk", - "Gujarati": "", - "Haitian Creole": "", - "Hausa": "", - "Hawaiian": "", - "Hebrew": "", - "Hindi": "", - "Hmong": "", + "Gujarati": "Gujarati", + "Haitian Creole": "Haitisk kreol", + "Hausa": "Hausa", + "Hawaiian": "Hawaiisk", + "Hebrew": "Hebraisk", + "Hindi": "Hindi", + "Hmong": "Hmong", "Hungarian": "Ungarsk", "Icelandic": "Islandsk", - "Igbo": "", + "Igbo": "Ibo", "Indonesian": "Indonesisk", "Irish": "Irsk", "Italian": "Italiensk", "Japanese": "Japansk", - "Javanese": "", - "Kannada": "", - "Kazakh": "", - "Khmer": "", - "Korean": "", - "Kurdish": "", - "Kyrgyz": "", - "Lao": "", - "Latin": "", - "Latvian": "", - "Lithuanian": "", - "Luxembourgish": "", - "Macedonian": "", - "Malagasy": "", - "Malay": "", - "Malayalam": "", - "Maltese": "", - "Maori": "", - "Marathi": "", - "Mongolian": "", - "Nepali": "", + "Javanese": "Javanesisk", + "Kannada": "Kanaresisk", + "Kazakh": "Kasakhisk", + "Khmer": "Khmer", + "Korean": "Koreansk", + "Kurdish": "Kurdisk", + "Kyrgyz": "Kirgisisk", + "Lao": "Laotisk", + "Latin": "Latin", + "Latvian": "Latvisk", + "Lithuanian": "Litauisk", + "Luxembourgish": "Luxemburgsk", + "Macedonian": "Makedonsk", + "Malagasy": "Madagassisk", + "Malay": "Malayisk", + "Malayalam": "Malayalam", + "Maltese": "Maltesisk", + "Maori": "Maorisk", + "Marathi": "Marathi", + "Mongolian": "Mongolsk", + "Nepali": "Gurkhali", "Norwegian Bokmål": "Norsk bokmål", - "Nyanja": "", - "Pashto": "", - "Persian": "", - "Polish": "", - "Portuguese": "", - "Punjabi": "", - "Romanian": "", + "Nyanja": "Nyanja", + "Pashto": "Pukhto", + "Persian": "Persisk", + "Polish": "Polsk", + "Portuguese": "Portugisisk", + "Punjabi": "Panjabi", + "Romanian": "Rumensk", "Russian": "Russisk", - "Samoan": "", - "Scottish Gaelic": "", + "Samoan": "Samoansk", + "Scottish Gaelic": "Skotsk-gælisk", "Serbian": "Serbisk", - "Shona": "", - "Sindhi": "", - "Sinhala": "", + "Shona": "Shona", + "Sindhi": "Sindhī", + "Sinhala": "Singalesisk", "Slovak": "Slovakisk", "Slovenian": "Slovensk", "Somali": "Somali", - "Southern Sotho": "", + "Southern Sotho": "Sørsotho", "Spanish": "Spansk", - "Spanish (Latin America)": "", - "Sundanese": "", - "Swahili": "", + "Spanish (Latin America)": "Spansk (Latin-Amerika)", + "Sundanese": "Sundanesisk", + "Swahili": "Kiswahili", "Swedish": "Svensk", - "Tajik": "", - "Tamil": "", - "Telugu": "", - "Thai": "", + "Tajik": "Tadsjikisk", + "Tamil": "Tamil", + "Telugu": "Telugu", + "Thai": "Thai", "Turkish": "Tyrkisk", "Ukrainian": "Ukrainsk", - "Urdu": "", - "Uzbek": "", + "Urdu": "Lashkari", + "Uzbek": "Usbekisk", "Vietnamese": "Vietnamesisk", - "Welsh": "", - "Western Frisian": "", - "Xhosa": "", - "Yiddish": "", - "Yoruba": "", - "Zulu": "", + "Welsh": "Velsk", + "Western Frisian": "Vestfrisisk", + "Xhosa": "Xhosa", + "Yiddish": "Jiddisk", + "Yoruba": "Joruba", + "Zulu": "Zulu", "`x` years": "`x` år", "`x` months": "`x` måneder", "`x` weeks": "`x` uker", @@ -322,7 +322,7 @@ "Movies": "Filmer", "Download": "Last ned", "Download as: ": "Last ned som: ", - "%A %B %-d, %Y": "", + "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(redigert)", "YouTube comment permalink": "Permanent YouTube-lenke til innholdet", "permalink": "permanent lenke", @@ -332,5 +332,5 @@ "Videos": "Videoer", "Playlists": "Spillelister", "Community": "Gemenskap", - "Current version: ": "Nåværende versjon: " -} \ No newline at end of file + "Current version: ": "Gjeldende versjon: " +} diff --git a/locales/nl.json b/locales/nl.json index 29af954a..de2a2bb7 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -69,7 +69,7 @@ "Show related videos: ": "Gerelateerde video's tonen? ", "Show annotations by default: ": "Standaard annotaties tonen? ", "Visual preferences": "Visuele instellingen", - "Player style: ": "Speler vormgeving", + "Player style: ": "Speler vormgeving ", "Dark mode: ": "Donkere modus: ", "Theme: ": "Thema: ", "dark": "donker", @@ -103,7 +103,7 @@ "Delete account": "Account verwijderen", "Administrator preferences": "Beheerdersinstellingen", "Default homepage: ": "Standaard startpagina: ", - "Feed menu: ": "Feedmenu:", + "Feed menu: ": "Feedmenu: ", "Top enabled: ": "Bovenkant inschakelen? ", "CAPTCHA enabled: ": "CAPTCHA gebruiken? ", "Login enabled: ": "Inloggen toestaan? ", @@ -125,7 +125,7 @@ "Released under the AGPLv3 by Omar Roth.": "Uitgebracht onder de AGPLv3-licentie, door Omar Roth.", "Source available here.": "De broncode is hier beschikbaar.", "View JavaScript license information.": "JavaScript-licentieinformatie tonen.", - "View privacy policy.": "Privacybeleid tonen", + "View privacy policy.": "Privacybeleid tonen.", "Trending": "Uitgelicht", "Public": "Publiek", "Unlisted": "Verborgen", @@ -151,7 +151,7 @@ "Shared `x`": "`x` gedeeld", "`x` views": "`x` weergaven", "Premieres in `x`": "Verschijnt over `x`", - "Premieres `x`": "", + "Premieres `x`": "Verschijnt op `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hoi! Het lijkt erop dat je JavaScript hebt uitgeschakeld. Klik hier om de reacties te bekijken. Let op: het laden duurt wat langer.", "View YouTube comments": "YouTube-reacties tonen", "View more comments on Reddit": "Meer reacties bekijken op Reddit", @@ -308,16 +308,16 @@ "`x` hours": "`x` uur", "`x` minutes": "`x` minuten", "`x` seconds": "`x` seconden", - "Fallback comments: ": "Terugvallen op", + "Fallback comments: ": "Terugvallen op ", "Popular": "Populair", "Top": "Top", "About": "Over", - "Rating: ": "Waardering", - "Language: ": "Taal", + "Rating: ": "Waardering: ", + "Language: ": "Taal: ", "View as playlist": "Tonen als afspeellijst", "Default": "Standaard", "Music": "Muziek", - "Gaming": "Gaming", + "Gaming": "Gamen", "News": "Nieuws", "Movies": "Films", "Download": "Downloaden", @@ -325,7 +325,7 @@ "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(bewerkt)", "YouTube comment permalink": "Link naar YouTube-reactie", - "permalink": "", + "permalink": "permalink", "`x` marked it with a ❤": "`x` heeft dit gemarkeerd met ❤", "Audio mode": "Audiomodus", "Video mode": "Videomodus", @@ -334,4 +334,4 @@ "Community": "Gemeenschap", "Current version: ": "Huidige versie: ", "Download is disabled.": "Downloaden is uitgeschakeld." -} \ No newline at end of file +} diff --git a/locales/pl.json b/locales/pl.json index 32ff0530..66b8f4b0 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -48,7 +48,7 @@ "Image CAPTCHA": "Obraz CAPTCHA", "Sign In": "Zaloguj się", "Register": "Zarejestruj się", - "E-mail": "Email", + "E-mail": "E-mail", "Google verification code": "Kod weryfikacyjny Google", "Preferences": "Preferencje", "Player preferences": "Ustawienia odtwarzacza", @@ -322,7 +322,7 @@ "Movies": "Filmy", "Download": "Pobierz", "Download as: ": "Pobierz jako: ", - "%A %B %-d, %Y": "", + "%A %B %-d, %Y": "%A, %-d %B %Y", "(edited)": "(edytowany)", "YouTube comment permalink": "Odnośnik bezpośredni do komentarza na YouTube", "permalink": "bezpośredni odnośnik", @@ -333,4 +333,4 @@ "Playlists": "Playlisty", "Community": "Społeczność", "Current version: ": "Aktualna wersja: " -} \ No newline at end of file +} diff --git a/locales/pt-BR.json b/locales/pt-BR.json index 9dd237c6..cf73abd8 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -1,10 +1,10 @@ { "`x` subscribers": "`x` inscritos", - "`x` videos": "`x` videos", - "`x` playlists": "`x` lista de reprodução", + "`x` videos": "`x` vídeos", + "`x` playlists": "`x` listas de reprodução", "LIVE": "AO VIVO", "Shared `x` ago": "Compartilhado `x` atrás", - "Unsubscribe": "Desinscrever-se", + "Unsubscribe": "Cancelar inscrição", "Subscribe": "Inscrever-se", "View channel on YouTube": "Ver canal no YouTube", "View playlist on YouTube": "Ver lista de reprodução no YouTube", @@ -17,14 +17,14 @@ "Clear watch history?": "Limpar histórico de reprodução?", "New password": "Nova senha", "New passwords must match": "Nova senha deve ser igual", - "Cannot change password for Google accounts": "Não é possível alterar sua senha da conta Google", + "Cannot change password for Google accounts": "Não é possível alterar sua senha de contas do Google", "Authorize token?": "Autorizar o token?", "Authorize token for `x`?": "Autorizar o token para `x`?", "Yes": "Sim", "No": "Não", "Import and Export Data": "Importar e Exportar Dados", "Import": "Importar", - "Import Invidious data": "Importar datos do Invidious", + "Import Invidious data": "Importar dados do Invidious", "Import YouTube subscriptions": "Importar inscrições do YouTube", "Import FreeTube subscriptions (.db)": "Importar inscrições do FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Importar inscrições do NewPipe (.json)", @@ -33,11 +33,11 @@ "Export subscriptions as OPML": "Exportar inscrições como OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar inscrições como OPML (para NewPipe e FreeTube)", "Export data as JSON": "Exportar dados como JSON", - "Delete account?": "Deletar conta?", + "Delete account?": "Excluir conta?", "History": "Histórico", "An alternative front-end to YouTube": "Uma interface alternativa para o YouTube", "JavaScript license information": "Informação de licença do JavaScript", - "source": "código fonte", + "source": "código-fonte", "Log in": "Entrar", "Log in/register": "Entrar/Registrar", "Log in with Google": "Entrar com conta Google", @@ -45,7 +45,7 @@ "Password": "Senha", "Time (h:mm:ss):": "Hora (h:mm:ss):", "Text CAPTCHA": "CAPTCHA em texto", - "Image CAPTCHA": "CAPTCHA em imagen", + "Image CAPTCHA": "CAPTCHA em imagem", "Sign In": "Entrar", "Register": "Registrar", "E-mail": "E-mail", @@ -55,43 +55,43 @@ "Always loop: ": "Repetir sempre: ", "Autoplay: ": "Reprodução automática: ", "Play next by default: ": "Sempre reproduzir próximo: ", - "Autoplay next video: ": "Reproduzir próximo video automaticamente: ", - "Listen by default: ": "Sempre ativar som: ", - "Proxy videos: ": "Usar proxy nos videos: ", - "Default speed: ": "Velocidade preferida: ", - "Preferred video quality: ": "Qualidade de video preferida: ", + "Autoplay next video: ": "Reproduzir próximo vídeo automaticamente: ", + "Listen by default: ": "Apenas áudio por padrão: ", + "Proxy videos: ": "Usar proxy nos vídeos: ", + "Default speed: ": "Velocidade padrão: ", + "Preferred video quality: ": "Qualidade de vídeo preferida: ", "Player volume: ": "Volume de reprodução: ", "Default comments: ": "Preferência de comentários: ", "youtube": "youtube", "reddit": "reddit", "Default captions: ": "Preferência de legendas: ", "Fallback captions: ": "Legendas alternativas: ", - "Show related videos: ": "Ver videos relacionados: ", + "Show related videos: ": "Mostrar vídeos relacionados: ", "Show annotations by default: ": "Sempre mostrar anotações: ", "Visual preferences": "Preferências visuais", - "Player style: ": "Estilo do reprodutor", + "Player style: ": "Estilo do tocador: ", "Dark mode: ": "Modo escuro: ", - "Theme: ": "Tema", + "Theme: ": "Tema: ", "dark": "escuro", "light": "claro", "Thin mode: ": "Modo compacto: ", "Subscription preferences": "Preferências de inscrições", - "Show annotations by default for subscribed channels: ": "Sempre mostrar anotações nos videos de canais inscritos ", + "Show annotations by default for subscribed channels: ": "Sempre mostrar anotações dos vídeos de canais inscritos: ", "Redirect homepage to feed: ": "Redirecionar página inicial para o feed: ", - "Number of videos shown in feed: ": "Número de videos no feed: ", - "Sort videos by: ": "Ordenar videos por: ", + "Number of videos shown in feed: ": "Número de vídeos no feed: ", + "Sort videos by: ": "Ordenar vídeos por: ", "published": "publicado", "published - reverse": "publicado - ordem inversa", "alphabetically": "alfabética", "alphabetically - reverse": "alfabética - ordem inversa", "channel name": "nome do canal", "channel name - reverse": "nome do canal - ordem inversa", - "Only show latest video from channel: ": "Mostrar apenas o video mais recente do canal: ", - "Only show latest unwatched video from channel: ": "Mostrar apenas o video mais recente não visualizados do canal: ", - "Only show unwatched: ": "Mostrar apenas videos não visualizados: ", + "Only show latest video from channel: ": "Mostrar apenas o vídeo mais recente do canal: ", + "Only show latest unwatched video from channel: ": "Mostrar apenas o vídeo mais recente não visualizado do canal: ", + "Only show unwatched: ": "Mostrar apenas vídeos não visualizados: ", "Only show notifications (if there are any): ": "Mostrar apenas notificações (se existentes): ", "Enable web notifications": "Ativar notificações pela web", - "`x` uploaded a video": "`x` publicou um novo video", + "`x` uploaded a video": "`x` publicou um novo vídeo", "`x` is live": "`x` está ao vivo", "Data preferences": "Preferências de dados", "Clear watch history": "Limpar histórico de reprodução", @@ -102,8 +102,8 @@ "Watch history": "Histórico de reprodução", "Delete account": "Apagar sua conta", "Administrator preferences": "Preferências de administrador", - "Default homepage: ": "Página de inicio padrão: ", - "Feed menu: ": "Menú do feed: ", + "Default homepage: ": "Página de início padrão: ", + "Feed menu: ": "Menu do feed: ", "Top enabled: ": "Habilitar destaques: ", "CAPTCHA enabled: ": "Habilitar CAPTCHA: ", "Login enabled: ": "Habilitar login: ", @@ -116,64 +116,64 @@ "`x` subscriptions": "`x` inscrições", "`x` tokens": "`x` tokens", "Import/export": "Importar/Exportar", - "unsubscribe": "desinscrever-se", + "unsubscribe": "cancelar inscrição", "revoke": "revogar", "Subscriptions": "Inscrições", "`x` unseen notifications": "`x` notificações não visualizadas", - "search": "procurar", + "search": "Pesquisar", "Log out": "Sair", "Released under the AGPLv3 by Omar Roth.": "Publicado sob a licença AGPLv3, por Omar Roth.", - "Source available here.": "Código fonte disponível aqui.", + "Source available here.": "Código-fonte disponível aqui.", "View JavaScript license information.": "Ver informações da licença do JavaScript.", - "View privacy policy.": "Ver a política de privacidade", - "Trending": "Trending", + "View privacy policy.": "Ver a política de privacidade.", + "Trending": "Tendências", "Public": "Público", - "Unlisted": "No listado", + "Unlisted": "Não listado", "Private": "Privado", "View all playlists": "Mostrar todas listas de reprodução", - "Updated `x` ago": "Enviado `x` atrás", + "Updated `x` ago": "Atualizado `x` atrás", "Delete playlist `x`?": "Apagar a playlist `x`?", "Delete playlist": "Apagar playlist", "Create playlist": "Criar playlist", "Title": "Título", "Playlist privacy": "Privacidade da playlist", - "Editing playlist `x`": "Editando playlist", - "Watch on YouTube": "Assistir vídeo no YouTube", + "Editing playlist `x`": "Editando playlist `x`", + "Watch on YouTube": "Assistir no YouTube", "Hide annotations": "Ocultar anotações", "Show annotations": "Mostrar anotações", "Genre: ": "Gênero: ", "License: ": "Licença: ", - "Family friendly? ": "Fistrar conteúdo impróprio: ", + "Family friendly? ": "Filtrar conteúdo impróprio: ", "Wilson score: ": "Pontuação de Wilson: ", - "Engagement: ": "Engagement: ", + "Engagement: ": "Empenho: ", "Whitelisted regions: ": "Regiões permitidas: ", "Blacklisted regions: ": "Regiões bloqueadas: ", "Shared `x`": "Compartilhado `x`", "`x` views": "`x` visualizações", - "Premieres in `x`": "Estreias em `x`", + "Premieres in `x`": "Estreia em `x`", "Premieres `x`": "Estreia `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Oi! Parece que seu JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar um pouco mais de tempo para carregar.", - "View YouTube comments": "Ver comentários do YouTube", - "View more comments on Reddit": "Ver mais comentários do Reddit", + "View YouTube comments": "Ver comentários no YouTube", + "View more comments on Reddit": "Ver mais comentários no Reddit", "View `x` comments": "Ver `x` comentários", - "View Reddit comments": "Ver comentários do Reddit", + "View Reddit comments": "Ver comentários no Reddit", "Hide replies": "Ocultar respostas", "Show replies": "Mostrar respostas", "Incorrect password": "Senha incorreta", "Quota exceeded, try again in a few hours": "Cota excedida, tente novamente em algumas horas", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Não foi possível fazer login, sua autenticação por dois passos (app autenticador ou sms) deve estar ativada.", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Não foi possível fazer login, sua autenticação em dois passos (app autenticador ou sms) deve estar ativada.", "Invalid TFA code": "Código TFA inválido", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Falha no login. Isso pode acontecer pois a autenticação por dois passos está desativada para sua conta.", - "Wrong answer": "Respuesta inválida", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "Falha no login. Isso pode acontecer porque a autenticação em dois passos está desativada para sua conta.", + "Wrong answer": "Resposta incorreta", "Erroneous CAPTCHA": "CAPTCHA inválido", "CAPTCHA is a required field": "O CAPTCHA é um campo obrigatório", "User ID is a required field": "O nome de usuário é um campo obrigatório", "Password is a required field": "A senha é um campo obrigatório", "Wrong username or password": "Nome de usuário ou senha inválidos", "Please sign in using 'Log in with Google'": "Por favor, entre usando 'Entrar com conta Google'", - "Password cannot be empty": "A senha não pode estar vazia", + "Password cannot be empty": "A senha não pode ficar em branco", "Password cannot be longer than 55 characters": "A senha não pode ter mais que 55 caracteres", - "Please log in": "Por favor, inicie sua seção", + "Please log in": "Por favor, inicie sua sessão", "Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`", "channel:`x`": "canal: `x`", "Deleted or invalid channel": "Este canal foi apagado ou é inválido", @@ -185,15 +185,15 @@ "Load more": "Carregar mais", "`x` points": "`x` pontos", "Could not create mix.": "Não foi possível criar o mix.", - "Empty playlist": "A lista de reprodução está vazia", - "Not a playlist.": "Lista de reprodução inválida.", + "Empty playlist": "Lista de reprodução vazia", + "Not a playlist.": "Não é uma lista de reprodução.", "Playlist does not exist.": "A lista de reprodução não existe.", - "Could not pull trending pages.": "Não foi possível oberter as páginas dos videos em alta.", + "Could not pull trending pages.": "Não foi possível obter as páginas dos vídeos em alta.", "Hidden field \"challenge\" is a required field": "O campo oculto \"desafio\" é obrigatório", "Hidden field \"token\" is a required field": "O campo oculto \"token\" é obrigatório", - "Erroneous challenge": "Desafío inválido", - "Erroneous token": "Símbolo inválido", - "No such user": "Usuario inválido", + "Erroneous challenge": "Desafio inválido", + "Erroneous token": "Token inválido", + "No such user": "Usuário inválido", "Token is expired, please try again": "Token expirou, tente novamente", "English": "Inglês", "English (auto-generated)": "Inglês (gerado automaticamente)", @@ -206,7 +206,7 @@ "Bangla": "Bengalês", "Basque": "Basco", "Belarusian": "Bielorrusso", - "Bosnian": "Língua Bósnia", + "Bosnian": "Bósnio", "Bulgarian": "Búlgaro", "Burmese": "Birmanês", "Catalan": "Catalão", @@ -242,7 +242,7 @@ "Italian": "Italiano", "Japanese": "Japonês", "Javanese": "Javanês", - "Kannada": "Canarẽs", + "Kannada": "Canarês", "Kazakh": "Cazaque", "Khmer": "Khmer", "Korean": "Coreano", @@ -266,26 +266,26 @@ "Nyanja": "Nianja", "Pashto": "Pachto", "Persian": "Persa", - "Polish": "Polaco", + "Polish": "Polonês", "Portuguese": "Português", "Punjabi": "Panjábi", - "Romanian": "Língua Romena", + "Romanian": "Romeno", "Russian": "Russo", "Samoan": "Samoano", "Scottish Gaelic": "Ânglico Escocês", - "Serbian": "Língua Sérvia", + "Serbian": "Sérvio", "Shona": "Xona", "Sindhi": "Sindi", "Sinhala": "Cingalês", "Slovak": "Eslovaco", "Slovenian": "Esloveno", - "Somali": "Língua Somalí", + "Somali": "Somali", "Southern Sotho": "Sesoto", "Spanish": "Espanhol", - "Spanish (Latin America)": "Espanhol (América)", - "Sundanese": "Sondanese", + "Spanish (Latin America)": "Espanhol (América Latina)", + "Sundanese": "Sundanês", "Swahili": "Suaíli", - "Swedish": "Suéco", + "Swedish": "Sueco", "Tajik": "Tajiques", "Tamil": "Tâmil", "Telugu": "Telugo", @@ -300,7 +300,7 @@ "Xhosa": "Xhosa", "Yiddish": "Iídiche", "Yoruba": "Iorubá", - "Zulu": "Língua Zulú", + "Zulu": "Zulu", "`x` years": "`x` anos", "`x` months": "`x` meses", "`x` weeks": "`x` semanas", @@ -315,22 +315,22 @@ "Rating: ": "Avaliação: ", "Language: ": "Idioma: ", "View as playlist": "Ver como lista de reprodução", - "Default": "Configuração padrão", - "Music": "Música", - "Gaming": "Video Games", + "Default": "Padrão", + "Music": "Músicas", + "Gaming": "Jogos", "News": "Notícias", "Movies": "Filmes", "Download": "Baixar", "Download as: ": "Baixar como: ", "%A %B %-d, %Y": "%A %-d %B %Y", "(edited)": "(editado)", - "YouTube comment permalink": "Link permanente do comentário do YouTube", + "YouTube comment permalink": "Link permanente do comentário no YouTube", "permalink": "Link permanente", "`x` marked it with a ❤": "`x` foi marcado como ❤", - "Audio mode": "Modo de audio", - "Video mode": "Modo de video", + "Audio mode": "Modo de áudio", + "Video mode": "Modo de vídeo", "Videos": "Vídeos", "Playlists": "Listas de reprodução", "Community": "Comunidade", "Current version: ": "Versão atual: " -} \ No newline at end of file +} diff --git a/locales/pt-PT.json b/locales/pt-PT.json index ab7d3468..1082c023 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -1,16 +1,10 @@ { - "`x` subscribers.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscritores.", - "": "`x` subscritores." - }, - "`x` videos.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vídeos.", - "": "`x` vídeos." - }, - "`x` playlists.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` listas de reprodução.", - "": "`x` listas de reprodução." - }, + "`x` subscribers..([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscritores..([^.,0-9]|^)1([^.,0-9]|$)", + "`x` subscribers..": "`x` subscritores.", + "`x` videos..([^.,0-9]|^)1([^.,0-9]|$)": "`x` videos..([^.,0-9]|^)1([^.,0-9]|$)", + "`x` videos..": "`x` vídeos.", + "`x` playlists..([^.,0-9]|^)1([^.,0-9]|$)": "`x` listas de reprodução.", + "`x` playlists..": "`x` listas de reprodução.", "LIVE": "Em direto", "Shared `x` ago": "Partilhado `x` atrás", "Unsubscribe": "Anular subscrição", @@ -26,7 +20,7 @@ "Clear watch history?": "Limpar histórico de reprodução?", "New password": "Nova palavra-chave", "New passwords must match": "As novas palavra-chaves devem corresponder", - "Cannot change password for Google accounts": "Não é possível alterar palavra-chave para contas do Google", + "Cannot change password for Google accounts": "Não é possível alterar a palavra-passe para contas do Google", "Authorize token?": "Autorizar token?", "Authorize token for `x`?": "Autorizar token para `x`?", "Yes": "Sim", @@ -42,9 +36,9 @@ "Export subscriptions as OPML": "Exportar subscrições como OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar subscrições como OPML (para NewPipe e FreeTube)", "Export data as JSON": "Exportar dados como JSON", - "Delete account?": "Eliminar conta?", + "Delete account?": "Apagar conta?", "History": "Histórico", - "An alternative front-end to YouTube": "Uma interface alternativa para o YouTube", + "An alternative front-end to YouTube": "Uma interface alternativa ao YouTube", "JavaScript license information": "Informação de licença do JavaScript", "source": "código-fonte", "Log in": "Iniciar sessão", @@ -85,9 +79,9 @@ "light": "claro", "Thin mode: ": "Modo compacto: ", "Subscription preferences": "Preferências de subscrições", - "Show annotations by default for subscribed channels: ": "Mostrar sempre anotações para os canais subscritos: ", + "Show annotations by default for subscribed channels: ": "Mostrar sempre anotações aos canais subscritos: ", "Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ", - "Number of videos shown in feed: ": "Número de vídeos nas subscrições: ", + "Number of videos shown in feed: ": "Quantidade de vídeos nas subscrições: ", "Sort videos by: ": "Ordenar vídeos por: ", "published": "publicado", "published - reverse": "publicado - inverso", @@ -109,9 +103,9 @@ "Manage subscriptions": "Gerir as subscrições", "Manage tokens": "Gerir tokens", "Watch history": "Histórico de reprodução", - "Delete account": "Eliminar conta", + "Delete account": "Apagar conta", "Administrator preferences": "Preferências de administrador", - "Default homepage: ": "Página inicial padrão: ", + "Default homepage: ": "Página inicial predefinida: ", "Feed menu: ": "Menu de subscrições: ", "Top enabled: ": "Top ativado: ", "CAPTCHA enabled: ": "CAPTCHA ativado: ", @@ -122,22 +116,16 @@ "Subscription manager": "Gerir subscrições", "Token manager": "Gerir tokens", "Token": "Token", - "`x` subscriptions.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscrições.", - "": "`x` subscrições." - }, - "`x` tokens.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens.", - "": "`x` tokens." - }, + "`x` subscriptions..([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscrições.", + "`x` subscriptions..": "`x` subscrições.", + "`x` tokens..([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens.", + "`x` tokens..": "`x` tokens.", "Import/export": "Importar/Exportar", "unsubscribe": "Anular subscrição", "revoke": "revogar", "Subscriptions": "Subscrições", - "`x` unseen notifications.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notificações não vistas.", - "": "`x` notificações não vistas." - }, + "`x` unseen notifications..([^.,0-9]|^)1([^.,0-9]|$)": "`x` notificações não vistas.", + "`x` unseen notifications..": "`x` notificações não vistas.", "search": "Pesquisar", "Log out": "Terminar sessão", "Released under the AGPLv3 by Omar Roth.": "Publicado sob a licença AGPLv3, por Omar Roth.", @@ -150,8 +138,8 @@ "Private": "Privado", "View all playlists": "Ver todas as listas de reprodução", "Updated `x` ago": "Atualizado `x` atrás", - "Delete playlist `x`?": "Eliminar a lista de reprodução 'x'?", - "Delete playlist": "Eliminar lista de reprodução", + "Delete playlist `x`?": "Apagar a lista de reprodução 'x'?", + "Delete playlist": "Apagar lista de reprodução", "Create playlist": "Criar lista de reprodução", "Title": "Título", "Playlist privacy": "Privacidade da lista de reprodução", @@ -167,19 +155,15 @@ "Whitelisted regions: ": "Regiões permitidas: ", "Blacklisted regions: ": "Regiões bloqueadas: ", "Shared `x`": "Partilhado `x`", - "`x` views.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizações.", - "": "`x` visualizações." - }, + "`x` views..([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizações.", + "`x` views..": "`x` visualizações.", "Premieres in `x`": "Estreias em 'x'", "Premieres `x`": "Estreias 'x'", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Oi! Parece que JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar mais tempo para carregar.", "View YouTube comments": "Ver comentários do YouTube", "View more comments on Reddit": "Ver mais comentários no Reddit", - "View `x` comments.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentários.", - "": "Ver `x` comentários." - }, + "View `x` comments..([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentários.", + "View `x` comments..": "Ver `x` comentários.", "View Reddit comments": "Ver comentários do Reddit", "Hide replies": "Ocultar respostas", "Show replies": "Mostrar respostas", @@ -204,16 +188,12 @@ "This channel does not exist.": "Este canal não existe.", "Could not get channel info.": "Não foi possível obter as informações do canal.", "Could not fetch comments": "Não foi possível obter os comentários", - "View `x` replies.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` respostas.", - "": "Ver `x` respostas." - }, + "View `x` replies..([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` respostas.", + "View `x` replies..": "Ver `x` respostas.", "`x` ago": "`x` atrás", "Load more": "Carregar mais", - "`x` points.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "'x' pontos.", - "": "'x' pontos." - }, + "`x` points..([^.,0-9]|^)1([^.,0-9]|$)": "'x' pontos.", + "`x` points..": "'x' pontos.", "Could not create mix.": "Não foi possível criar mistura.", "Empty playlist": "Lista de reprodução vazia", "Not a playlist.": "Não é uma lista de reprodução.", @@ -331,34 +311,20 @@ "Yiddish": "Iídiche", "Yoruba": "Ioruba", "Zulu": "Zulu", - "`x` years.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` anos.", - "": "`x` anos." - }, - "`x` months.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` meses.", - "": "`x` meses." - }, - "`x` weeks.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semanas.", - "": "`x` semanas." - }, - "`x` days.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dias.", - "": "`x` dias." - }, - "`x` hours.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` horas.", - "": "`x` horas." - }, - "`x` minutes.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutos.", - "": "`x` minutos." - }, - "`x` seconds.": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundos.", - "": "`x` segundos." - }, + "`x` years..([^.,0-9]|^)1([^.,0-9]|$)": "`x` anos.", + "`x` years..": "`x` anos.", + "`x` months..([^.,0-9]|^)1([^.,0-9]|$)": "`x` meses.", + "`x` months..": "`x` meses.", + "`x` weeks..([^.,0-9]|^)1([^.,0-9]|$)": "`x` semanas.", + "`x` weeks..": "`x` semanas.", + "`x` days..([^.,0-9]|^)1([^.,0-9]|$)": "`x` dias.", + "`x` days..": "`x` dias.", + "`x` hours..([^.,0-9]|^)1([^.,0-9]|$)": "`x` horas.", + "`x` hours..": "`x` horas.", + "`x` minutes..([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutos.", + "`x` minutes..": "`x` minutos.", + "`x` seconds..([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundos.", + "`x` seconds..": "`x` segundos.", "Fallback comments: ": "Comentários alternativos: ", "Popular": "Popular", "Top": "Top", @@ -375,7 +341,7 @@ "Download as: ": "Transferir como: ", "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(editado)", - "YouTube comment permalink": "Link permanente do comentário do YouTube", + "YouTube comment permalink": "Hiperligação permanente ao comentário do YouTube", "permalink": "ligação permanente", "`x` marked it with a ❤": "`x` foi marcado como ❤", "Audio mode": "Modo de áudio", @@ -384,4 +350,4 @@ "Playlists": "Listas de reprodução", "Community": "Comunidade", "Current version: ": "Versão atual: " -} \ No newline at end of file +} diff --git a/locales/sk.json b/locales/sk.json new file mode 100644 index 00000000..0957cb87 --- /dev/null +++ b/locales/sk.json @@ -0,0 +1,353 @@ +{ + "`x` subscribers.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` subscribers.": "`x` odberateľov.", + "`x` videos.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` videos.": "", + "`x` playlists.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` playlists.": "", + "LIVE": "NAŽIVO", + "Shared `x` ago": "", + "Unsubscribe": "Zrušiť odber", + "Subscribe": "Odoberať", + "View channel on YouTube": "Zobraziť kanál na YouTube", + "View playlist on YouTube": "", + "newest": "najnovšie", + "oldest": "najstaršie", + "popular": "populárne", + "last": "posledné", + "Next page": "Ďalšia strana", + "Previous page": "Predchádzajúca strana", + "Clear watch history?": "Vymazať históriu sledovania?", + "New password": "Nové heslo", + "New passwords must match": "Nové heslá sa musia zhodovať", + "Cannot change password for Google accounts": "Heslo pre účty Google sa nedá zmeniť", + "Authorize token?": "Autorizovať token?", + "Authorize token for `x`?": "", + "Yes": "Áno", + "No": "Nie", + "Import and Export Data": "Import a Export údajov", + "Import": "Import", + "Import Invidious data": "Importovať údaje Invidious", + "Import YouTube subscriptions": "Importovať odbery YouTube", + "Import FreeTube subscriptions (.db)": "Importovať odbery FreeTube (.db)", + "Import NewPipe subscriptions (.json)": "Importovať odbery NewPipe (.json)", + "Import NewPipe data (.zip)": "Importovať údaje NewPipe (.zip)", + "Export": "Export", + "Export subscriptions as OPML": "Exportovať odbery ako OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportovať odbery ako OPML (pre NewPipe a FreeTube)", + "Export data as JSON": "Export údajov ako JSON", + "Delete account?": "Zrušiť účet?", + "History": "História", + "An alternative front-end to YouTube": "Alternatívny front-end pre YouTube", + "JavaScript license information": "Informácie o licencii JavaScript", + "source": "zdroj", + "Log in": "Prihlásiť sa", + "Log in/register": "Prihlásiť sa/Registrovať", + "Log in with Google": "Prihlásiť sa pomocou účtu Google", + "User ID": "ID používateľa", + "Password": "Heslo", + "Time (h:mm:ss):": "Čas (h:mm:ss):", + "Text CAPTCHA": "Textové CAPTCHA", + "Image CAPTCHA": "Obrázkové CAPTCHA", + "Sign In": "Prihlásiť sa", + "Register": "Registrovať", + "E-mail": "E-mail", + "Google verification code": "Overovací kód Google", + "Preferences": "Nastavenia", + "Player preferences": "Nastavenia prehrávača", + "Always loop: ": "Vždy opakovať: ", + "Autoplay: ": "Automatické prehrávanie: ", + "Play next by default: ": "", + "Autoplay next video: ": "Automatické prehrávanie nasledujúceho videa: ", + "Listen by default: ": "Predvolene počúvať: ", + "Proxy videos: ": "Proxy videá: ", + "Default speed: ": "Predvolená rýchlosť: ", + "Preferred video quality: ": "Preferovaná kvalita videa: ", + "Player volume: ": "Hlasitosť prehrávača: ", + "Default comments: ": "Predvolené komentáre: ", + "youtube": "YouTube", + "reddit": "Reddit", + "Default captions: ": "Predvolené titulky: ", + "Fallback captions: ": "Náhradné titulky: ", + "Show related videos: ": "Zobraziť súvisiace videá: ", + "Show annotations by default: ": "Predvolene zobraziť anotácie: ", + "Visual preferences": "Vizuálne nastavenia", + "Player style: ": "Štýl prehrávača: ", + "Dark mode: ": "Tmavý režim: ", + "Theme: ": "Téma: ", + "dark": "tmavá", + "light": "svetlá", + "Thin mode: ": "Tenký režim: ", + "Subscription preferences": "Nastavenia predplatného", + "Show annotations by default for subscribed channels: ": "Predvolene zobraziť anotácie odoberaných kanálov: ", + "Redirect homepage to feed: ": "Presmerovanie domovskej stránky na informačný kanál: ", + "Number of videos shown in feed: ": "Počet videí zobrazených v informačnom kanáli: ", + "Sort videos by: ": "Zoradiť videá podľa: ", + "published": "zverejnené (od najnovších)", + "published - reverse": "zverejnené (od najstarších)", + "alphabetically": "abecedne (A-Z)", + "alphabetically - reverse": "abecedne (Z-A)", + "channel name": "názov kanála (A-Z)", + "channel name - reverse": "názov kanála (Z-A)", + "Only show latest video from channel: ": "Zobraziť iba najnovšie video z kanála: ", + "Only show latest unwatched video from channel: ": "Zobraziť iba najnovšie neprehrané video z kanála: ", + "Only show unwatched: ": "Zobraziť iba neprehrané: ", + "Only show notifications (if there are any): ": "Zobraziť iba upozornenia (ak existujú): ", + "Enable web notifications": "Povoliť webové upozornenia", + "`x` uploaded a video": "`x` nahral(a) video", + "`x` is live": "", + "Data preferences": "", + "Clear watch history": "", + "Import/export data": "", + "Change password": "", + "Manage subscriptions": "", + "Manage tokens": "", + "Watch history": "", + "Delete account": "", + "Administrator preferences": "", + "Default homepage: ": "", + "Feed menu: ": "", + "Top enabled: ": "", + "CAPTCHA enabled: ": "", + "Login enabled: ": "", + "Registration enabled: ": "", + "Report statistics: ": "", + "Save preferences": "", + "Subscription manager": "", + "Token manager": "", + "Token": "", + "`x` subscriptions.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` subscriptions.": "", + "`x` tokens.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` tokens.": "", + "Import/export": "", + "unsubscribe": "", + "revoke": "", + "Subscriptions": "", + "`x` unseen notifications.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` unseen notifications.": "", + "search": "", + "Log out": "", + "Released under the AGPLv3 by Omar Roth.": "", + "Source available here.": "", + "View JavaScript license information.": "", + "View privacy policy.": "", + "Trending": "", + "Public": "", + "Unlisted": "", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", + "Watch on YouTube": "", + "Hide annotations": "", + "Show annotations": "", + "Genre: ": "", + "License: ": "", + "Family friendly? ": "", + "Wilson score: ": "", + "Engagement: ": "", + "Whitelisted regions: ": "", + "Blacklisted regions: ": "", + "Shared `x`": "", + "`x` views.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` views.": "", + "Premieres in `x`": "", + "Premieres `x`": "", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "", + "View YouTube comments": "", + "View more comments on Reddit": "", + "View `x` comments.([^.,0-9]|^)1([^.,0-9]|$)": "", + "View `x` comments.": "", + "View Reddit comments": "", + "Hide replies": "", + "Show replies": "", + "Incorrect password": "", + "Quota exceeded, try again in a few hours": "", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "", + "Invalid TFA code": "", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "", + "Wrong answer": "", + "Erroneous CAPTCHA": "", + "CAPTCHA is a required field": "", + "User ID is a required field": "", + "Password is a required field": "", + "Wrong username or password": "", + "Please sign in using 'Log in with Google'": "", + "Password cannot be empty": "", + "Password cannot be longer than 55 characters": "", + "Please log in": "", + "Invidious Private Feed for `x`": "", + "channel:`x`": "", + "Deleted or invalid channel": "", + "This channel does not exist.": "", + "Could not get channel info.": "", + "Could not fetch comments": "", + "View `x` replies.([^.,0-9]|^)1([^.,0-9]|$)": "", + "View `x` replies.": "", + "`x` ago": "", + "Load more": "", + "`x` points.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` points.": "", + "Could not create mix.": "", + "Empty playlist": "", + "Not a playlist.": "", + "Playlist does not exist.": "", + "Could not pull trending pages.": "", + "Hidden field \"challenge\" is a required field": "", + "Hidden field \"token\" is a required field": "", + "Erroneous challenge": "", + "Erroneous token": "", + "No such user": "", + "Token is expired, please try again": "", + "English": "", + "English (auto-generated)": "", + "Afrikaans": "", + "Albanian": "", + "Amharic": "", + "Arabic": "", + "Armenian": "", + "Azerbaijani": "", + "Bangla": "", + "Basque": "", + "Belarusian": "", + "Bosnian": "", + "Bulgarian": "", + "Burmese": "", + "Catalan": "", + "Cebuano": "", + "Chinese (Simplified)": "", + "Chinese (Traditional)": "", + "Corsican": "", + "Croatian": "", + "Czech": "", + "Danish": "", + "Dutch": "", + "Esperanto": "", + "Estonian": "", + "Filipino": "", + "Finnish": "", + "French": "", + "Galician": "", + "Georgian": "", + "German": "", + "Greek": "", + "Gujarati": "", + "Haitian Creole": "", + "Hausa": "", + "Hawaiian": "", + "Hebrew": "", + "Hindi": "", + "Hmong": "", + "Hungarian": "", + "Icelandic": "", + "Igbo": "", + "Indonesian": "", + "Irish": "", + "Italian": "", + "Japanese": "", + "Javanese": "", + "Kannada": "", + "Kazakh": "", + "Khmer": "", + "Korean": "", + "Kurdish": "", + "Kyrgyz": "", + "Lao": "", + "Latin": "", + "Latvian": "", + "Lithuanian": "", + "Luxembourgish": "", + "Macedonian": "", + "Malagasy": "", + "Malay": "", + "Malayalam": "", + "Maltese": "", + "Maori": "", + "Marathi": "", + "Mongolian": "", + "Nepali": "", + "Norwegian Bokmål": "", + "Nyanja": "", + "Pashto": "", + "Persian": "", + "Polish": "", + "Portuguese": "", + "Punjabi": "", + "Romanian": "", + "Russian": "", + "Samoan": "", + "Scottish Gaelic": "", + "Serbian": "", + "Shona": "", + "Sindhi": "", + "Sinhala": "", + "Slovak": "", + "Slovenian": "", + "Somali": "", + "Southern Sotho": "", + "Spanish": "", + "Spanish (Latin America)": "", + "Sundanese": "", + "Swahili": "", + "Swedish": "", + "Tajik": "", + "Tamil": "", + "Telugu": "", + "Thai": "", + "Turkish": "", + "Ukrainian": "", + "Urdu": "", + "Uzbek": "", + "Vietnamese": "", + "Welsh": "", + "Western Frisian": "", + "Xhosa": "", + "Yiddish": "", + "Yoruba": "", + "Zulu": "", + "`x` years.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` years.": "", + "`x` months.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` months.": "", + "`x` weeks.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` weeks.": "", + "`x` days.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` days.": "", + "`x` hours.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` hours.": "", + "`x` minutes.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` minutes.": "", + "`x` seconds.([^.,0-9]|^)1([^.,0-9]|$)": "", + "`x` seconds.": "", + "Fallback comments: ": "", + "Popular": "", + "Top": "", + "About": "", + "Rating: ": "", + "Language: ": "", + "View as playlist": "", + "Default": "", + "Music": "", + "Gaming": "", + "News": "", + "Movies": "", + "Download": "", + "Download as: ": "", + "%A %B %-d, %Y": "", + "(edited)": "", + "YouTube comment permalink": "", + "permalink": "", + "`x` marked it with a ❤": "", + "Audio mode": "", + "Video mode": "", + "Videos": "", + "Playlists": "", + "Community": "", + "Current version: ": "" +} diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json index 786532df..0ca9a8a0 100644 --- a/locales/sr_Cyrl.json +++ b/locales/sr_Cyrl.json @@ -1,109 +1,109 @@ { - "`x` subscribers.": "", - "`x` videos.": "", - "`x` playlists.": "", - "LIVE": "", - "Shared `x` ago": "", - "Unsubscribe": "", + "`x` subscribers.": "%(count)s пратилац.", + "`x` videos.": "`x` видеа.", + "`x` playlists.": "`x` плејлиста/е.", + "LIVE": "УЖИВО", + "Shared `x` ago": "Објављено пре `x`", + "Unsubscribe": "Прекините праћење", "Subscribe": "Пратите", "View channel on YouTube": "Погледајте канал на YouTube-у", "View playlist on YouTube": "Погледајте плејлисту на YouTube-у", - "newest": "", - "oldest": "", - "popular": "", - "last": "", - "Next page": "", - "Previous page": "", - "Clear watch history?": "", - "New password": "", - "New passwords must match": "", - "Cannot change password for Google accounts": "", - "Authorize token?": "", - "Authorize token for `x`?": "", - "Yes": "", - "No": "", - "Import and Export Data": "", - "Import": "", - "Import Invidious data": "", - "Import YouTube subscriptions": "", - "Import FreeTube subscriptions (.db)": "", - "Import NewPipe subscriptions (.json)": "", - "Import NewPipe data (.zip)": "", - "Export": "", - "Export subscriptions as OPML": "", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "", - "Export data as JSON": "", - "Delete account?": "", - "History": "", - "An alternative front-end to YouTube": "", - "JavaScript license information": "", - "source": "", - "Log in": "", - "Log in/register": "", - "Log in with Google": "", - "User ID": "", - "Password": "", - "Time (h:mm:ss):": "", - "Text CAPTCHA": "", - "Image CAPTCHA": "", - "Sign In": "", - "Register": "", - "E-mail": "", - "Google verification code": "", - "Preferences": "", - "Player preferences": "", - "Always loop: ": "", - "Autoplay: ": "", - "Play next by default: ": "", - "Autoplay next video: ": "", - "Listen by default: ": "", - "Proxy videos: ": "", - "Default speed: ": "", - "Preferred video quality: ": "", - "Player volume: ": "", - "Default comments: ": "", - "youtube": "", - "reddit": "", - "Default captions: ": "", - "Fallback captions: ": "", - "Show related videos: ": "", - "Show annotations by default: ": "", - "Visual preferences": "", - "Player style: ": "", - "Dark mode: ": "", - "Theme: ": "", - "dark": "", - "light": "", - "Thin mode: ": "", - "Subscription preferences": "", - "Show annotations by default for subscribed channels: ": "", - "Redirect homepage to feed: ": "", - "Number of videos shown in feed: ": "", - "Sort videos by: ": "", - "published": "", - "published - reverse": "", - "alphabetically": "", - "alphabetically - reverse": "", - "channel name": "", - "channel name - reverse": "", - "Only show latest video from channel: ": "", - "Only show latest unwatched video from channel: ": "", - "Only show unwatched: ": "", - "Only show notifications (if there are any): ": "", - "Enable web notifications": "", - "`x` uploaded a video": "", - "`x` is live": "", - "Data preferences": "", - "Clear watch history": "", - "Import/export data": "", - "Change password": "", - "Manage subscriptions": "", - "Manage tokens": "", - "Watch history": "", - "Delete account": "", - "Administrator preferences": "", - "Default homepage: ": "", - "Feed menu: ": "", + "newest": "најновије", + "oldest": "најстарије", + "popular": "популарно", + "last": "последње", + "Next page": "Следећа страна", + "Previous page": "Претходна страна", + "Clear watch history?": "Обришите историју прегледања?", + "New password": "Нова лозинка", + "New passwords must match": "Нове лозинке се морају поклапати", + "Cannot change password for Google accounts": "Није могуће променити лозинку за Google налоге", + "Authorize token?": "Овластите токен?", + "Authorize token for `x`?": "Овластите токен за `x`?", + "Yes": "Да", + "No": "Не", + "Import and Export Data": "Увоз и извоз података", + "Import": "Увезите", + "Import Invidious data": "Увезите Invidious податке", + "Import YouTube subscriptions": "Увезите праћења са YouTube-а", + "Import FreeTube subscriptions (.db)": "Увезите праћења са FreeTube-а (.db)", + "Import NewPipe subscriptions (.json)": "Увезите праћења са NewPipe-а (.json)", + "Import NewPipe data (.zip)": "Увезите NewPipe податке (.zip)", + "Export": "Извезите", + "Export subscriptions as OPML": "Извезите праћења у OPML формату", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Извезите праћења у OPML формату (за NewPipe и FreeTube )", + "Export data as JSON": "Изветизе податке у JSON формату", + "Delete account?": "Избришите налог?", + "History": "Историја", + "An alternative front-end to YouTube": "Алтернативни фронтенд за YouTube", + "JavaScript license information": "Извештај о JavaScript лиценци", + "source": "извор", + "Log in": "Пријавите се", + "Log in/register": "Пријавите се/направите налог", + "Log in with Google": "Пријавите се помоћу Google-а", + "User ID": "ИД корисника", + "Password": "Лозинка", + "Time (h:mm:ss):": "Колико је сати? (ч:мм:сс):", + "Text CAPTCHA": "Текстуална CAPTCHA", + "Image CAPTCHA": "Сликовна CAPTCHA", + "Sign In": "Пријавите се", + "Register": "Направите налог", + "E-mail": "Е-пошта", + "Google verification code": "Google верификациони кôд", + "Preferences": "Подешавања", + "Player preferences": "Подешавања видео плејера", + "Always loop: ": "Увек понављај: ", + "Autoplay: ": "Аутоматско пуштање: ", + "Play next by default: ": "Увек пуштај следеће: ", + "Autoplay next video: ": "Аутоматско пуштање следећег видеа: ", + "Listen by default: ": "Режим слушања као подразумевано: ", + "Proxy videos: ": "Пуштање видеа кроз прокси сервер: ", + "Default speed: ": "Подразумевана брзина репродукције: ", + "Preferred video quality: ": "Претпостављени квалитет видеа: ", + "Player volume: ": "Јачина звука: ", + "Default comments: ": "Подразумевани коментари: ", + "youtube": "са YouTube-а", + "reddit": "са редита", + "Default captions: ": "Подразумевани титлови: ", + "Fallback captions: ": "Алтернативни титлови: ", + "Show related videos: ": "Прикажи сличне видее: ", + "Show annotations by default: ": "Увек приказуј анотације: ", + "Visual preferences": "Подешавања изгледа", + "Player style: ": "Стил плејера: ", + "Dark mode: ": "Тамни режим: ", + "Theme: ": "Тема: ", + "dark": "тамна", + "light": "светла", + "Thin mode: ": "Узани режим: ", + "Subscription preferences": "Подешавања о праћењима", + "Show annotations by default for subscribed channels: ": "Увек приказуј анотације за канале које пратим: ", + "Redirect homepage to feed: ": "Прикажи праћења као почетну страницу: ", + "Number of videos shown in feed: ": "Количина приказаних видеа на доводу: ", + "Sort videos by: ": "Сортирај према: ", + "published": "датуму објављивања", + "published - reverse": "датуму објављивања - обрнуто", + "alphabetically": "алфабету", + "alphabetically - reverse": "алфабету - обрнуто", + "channel name": "називу канала", + "channel name - reverse": "називу канала - обрнуто", + "Only show latest video from channel: ": "Прикажи само најновији видео са канала: ", + "Only show latest unwatched video from channel: ": "Прикажи само најновији негледани видео са канала: ", + "Only show unwatched: ": "Прикажи само негледано: ", + "Only show notifications (if there are any): ": "Прикажи само обавештења (ако их има): ", + "Enable web notifications": "Укључи обавештења преко претраживача", + "`x` uploaded a video": "`x`је објавио/ла видео", + "`x` is live": "`x` емитује уживо", + "Data preferences": "Подешавања о подацима", + "Clear watch history": "Обришите историју прегледања", + "Import/export data": "Увезите или извезите податке", + "Change password": "Промените лозинку", + "Manage subscriptions": "Управљајте праћењима", + "Manage tokens": "Управљајте токенима", + "Watch history": "Историја прегледања", + "Delete account": "Избришите налог", + "Administrator preferences": "Подешавања администратора", + "Default homepage: ": "Подразумевана главна страница: ", + "Feed menu: ": "Мени довода: ", "Top enabled: ": "", "CAPTCHA enabled: ": "", "Login enabled: ": "", @@ -333,4 +333,4 @@ "Playlists": "", "Community": "", "Current version: ": "Тренутна верзија: " -} \ No newline at end of file +} diff --git a/locales/sv-SE.json b/locales/sv-SE.json index 14e7d53e..fef5316f 100644 --- a/locales/sv-SE.json +++ b/locales/sv-SE.json @@ -195,112 +195,112 @@ "Erroneous token": "Felaktig token", "No such user": "Ogiltig användare", "Token is expired, please try again": "Token föråldrad, försök igen", - "English": "", - "English (auto-generated)": "English (auto-genererat)", - "Afrikaans": "", - "Albanian": "", - "Amharic": "", - "Arabic": "", - "Armenian": "", - "Azerbaijani": "", - "Bangla": "", - "Basque": "", - "Belarusian": "", - "Bosnian": "", - "Bulgarian": "", - "Burmese": "", - "Catalan": "", - "Cebuano": "", - "Chinese (Simplified)": "", - "Chinese (Traditional)": "", - "Corsican": "", - "Croatian": "", - "Czech": "", - "Danish": "", - "Dutch": "", - "Esperanto": "", - "Estonian": "", - "Filipino": "", - "Finnish": "", - "French": "", - "Galician": "", - "Georgian": "", - "German": "", - "Greek": "", - "Gujarati": "", - "Haitian Creole": "", - "Hausa": "", - "Hawaiian": "", - "Hebrew": "", - "Hindi": "", - "Hmong": "", - "Hungarian": "", - "Icelandic": "", - "Igbo": "", - "Indonesian": "", - "Irish": "", - "Italian": "", - "Japanese": "", - "Javanese": "", - "Kannada": "", - "Kazakh": "", - "Khmer": "", - "Korean": "", - "Kurdish": "", - "Kyrgyz": "", - "Lao": "", - "Latin": "", - "Latvian": "", - "Lithuanian": "", - "Luxembourgish": "", - "Macedonian": "", - "Malagasy": "", - "Malay": "", - "Malayalam": "", - "Maltese": "", - "Maori": "", - "Marathi": "", - "Mongolian": "", - "Nepali": "", - "Norwegian Bokmål": "", - "Nyanja": "", - "Pashto": "", - "Persian": "", - "Polish": "", - "Portuguese": "", - "Punjabi": "", - "Romanian": "", - "Russian": "", - "Samoan": "", - "Scottish Gaelic": "", - "Serbian": "", - "Shona": "", - "Sindhi": "", - "Sinhala": "", - "Slovak": "", - "Slovenian": "", - "Somali": "", - "Southern Sotho": "", - "Spanish": "", - "Spanish (Latin America)": "", - "Sundanese": "", - "Swahili": "", - "Swedish": "", - "Tajik": "", - "Tamil": "", - "Telugu": "", - "Thai": "", - "Turkish": "", - "Ukrainian": "", - "Urdu": "", - "Uzbek": "", - "Vietnamese": "", - "Welsh": "", - "Western Frisian": "", - "Xhosa": "", - "Yiddish": "", - "Yoruba": "", - "Zulu": "", + "English": "Engelska", + "English (auto-generated)": "Engelska (auto-genererat)", + "Afrikaans": "Afrikanska", + "Albanian": "Albanska", + "Amharic": "Amhariska", + "Arabic": "Arabiska", + "Armenian": "Armeniska", + "Azerbaijani": "Azerbajdzjanska", + "Bangla": "Bengaliska", + "Basque": "Baskiska", + "Belarusian": "Vitryska", + "Bosnian": "Bosniska", + "Bulgarian": "Bulgariska", + "Burmese": "Burmesiska", + "Catalan": "Katalanska", + "Cebuano": "Cebuano", + "Chinese (Simplified)": "Kinesiska (Förenklad)", + "Chinese (Traditional)": "Kinesiska (Traditionell)", + "Corsican": "Korsikanska", + "Croatian": "Kroatiska", + "Czech": "Tjeckiska", + "Danish": "Danska", + "Dutch": "Nederländska", + "Esperanto": "Esperanto", + "Estonian": "Estniska", + "Filipino": "Filipino", + "Finnish": "Finska", + "French": "Franska", + "Galician": "Galiciska", + "Georgian": "Georgiska", + "German": "Tyska", + "Greek": "Grekiska", + "Gujarati": "Gujarati", + "Haitian Creole": "Haitisk Kreol", + "Hausa": "Hausa", + "Hawaiian": "Hawaiiska", + "Hebrew": "Hebreiska", + "Hindi": "Hindi", + "Hmong": "Hmong-mienspråk", + "Hungarian": "Ungerska", + "Icelandic": "Isländska", + "Igbo": "Igbo", + "Indonesian": "Indonesiska", + "Irish": "Irländska", + "Italian": "Italienska", + "Japanese": "Japanska", + "Javanese": "Javanesiska", + "Kannada": "Kanaresiska", + "Kazakh": "Kazakiska", + "Khmer": "Kambodjanska", + "Korean": "Koreanska", + "Kurdish": "Kurdiska", + "Kyrgyz": "Kirgiziska", + "Lao": "Laotiska", + "Latin": "Latin", + "Latvian": "Lettiska", + "Lithuanian": "Litauiska", + "Luxembourgish": "Luxemburgska", + "Macedonian": "Makedonska", + "Malagasy": "Malagassiska", + "Malay": "Malajiska", + "Malayalam": "Malayalam", + "Maltese": "Maltesiska", + "Maori": "Maori", + "Marathi": "Marathi", + "Mongolian": "Mongoliska", + "Nepali": "Nepali", + "Norwegian Bokmål": "Norska Bokmål", + "Nyanja": "Nyanja", + "Pashto": "Pashto", + "Persian": "Persiska", + "Polish": "Polska", + "Portuguese": "Portugisiska", + "Punjabi": "Punjabi", + "Romanian": "Rumänska", + "Russian": "Ryska", + "Samoan": "Samoanska", + "Scottish Gaelic": "Skotsk gäliska", + "Serbian": "Serbiska", + "Shona": "Shona", + "Sindhi": "Sindhi", + "Sinhala": "Singalesiska", + "Slovak": "Slovakiska", + "Slovenian": "Slovenska", + "Somali": "Somaliska", + "Southern Sotho": "Sydsotho", + "Spanish": "Spanska", + "Spanish (Latin America)": "Spanska (Latin Amerikansk)", + "Sundanese": "Sundanesiska", + "Swahili": "Swahili", + "Swedish": "Svenska", + "Tajik": "Tadzjikiska", + "Tamil": "Tamil", + "Telugu": "Telugu", + "Thai": "Thailändska", + "Turkish": "Turkiska", + "Ukrainian": "Ukrainska", + "Urdu": "Urdu", + "Uzbek": "Uzbekiska", + "Vietnamese": "Vietnamesiska", + "Welsh": "Walesiska", + "Western Frisian": "Västfrisiska", + "Xhosa": "Xhosa", + "Yiddish": "Jiddisch", + "Yoruba": "Yoruba", + "Zulu": "Zulu", "`x` years": "`x` år", "`x` months": "`x` månader", "`x` weeks": "`x` veckor", @@ -322,7 +322,7 @@ "Movies": "Filmer", "Download": "Ladda ned", "Download as: ": "Ladda ned som: ", - "%A %B %-d, %Y": "", + "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(redigerad)", "YouTube comment permalink": "Permanent YouTube-länk till innehållet", "permalink": "permalänk", @@ -333,4 +333,4 @@ "Playlists": "Spellistor", "Community": "Gemenskap", "Current version: ": "Nuvarande version: " -} \ No newline at end of file +} diff --git a/locales/tr.json b/locales/tr.json index 652dff6d..831ecc07 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -334,11 +334,11 @@ "(edited)": "(düzenlendi)", "YouTube comment permalink": "YouTube yorumu kalıcı linki", "permalink": "kalıcı link", - "`x` marked it with a ❤": "`x` ❤ ile işaretlendi", + "`x` marked it with a ❤": "`x` ❤ ile işaretledi", "Audio mode": "Ses modu", "Video mode": "Video modu", "Videos": "Videolar", "Playlists": "Oynatma listeleri", "Community": "Topluluk", "Current version: ": "Şu anki sürüm: " -} \ No newline at end of file +} diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 288f127d..d7f12975 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -11,7 +11,7 @@ "newest": "最新", "oldest": "最老", "popular": "时下流行", - "last": "last", + "last": "", "Next page": "下一页", "Previous page": "上一页", "Clear watch history?": "清除观看历史?", @@ -52,44 +52,44 @@ "Google verification code": "Google 验证代码", "Preferences": "偏好设置", "Player preferences": "播放器偏好设置", - "Always loop: ": "循环:", - "Autoplay: ": "自动播放:", - "Play next by default: ": "默认自动播放下一个视频:", - "Autoplay next video: ": "自动播放下一个视频:", - "Listen by default: ": "默认只聆听声音:", - "Proxy videos: ": "代理视频?", - "Default speed: ": "默认速度:", - "Preferred video quality: ": "视频质量偏好:", - "Player volume: ": "播放器音量:", - "Default comments: ": "默认评论源:", + "Always loop: ": "始终循环: ", + "Autoplay: ": "自动播放: ", + "Play next by default: ": "默认自动播放下一个视频: ", + "Autoplay next video: ": "自动播放下一个视频: ", + "Listen by default: ": "默认只听声音: ", + "Proxy videos: ": "是否代理视频: ", + "Default speed: ": "默认速度: ", + "Preferred video quality: ": "视频质量偏好: ", + "Player volume: ": "播放器音量: ", + "Default comments: ": "默认评论源: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "默认字幕语言:", - "Fallback captions: ": "后备字幕语言:", - "Show related videos: ": "显示相关视频?", - "Show annotations by default: ": "默认显示视频注释?", + "Default captions: ": "默认字幕语言: ", + "Fallback captions: ": "后备字幕语言: ", + "Show related videos: ": "是否显示相关视频: ", + "Show annotations by default: ": "是否默认显示视频注释: ", "Visual preferences": "视觉选项", - "Player style: ": "播放器样式:", - "Dark mode: ": "暗色模式:", - "Theme: ": "主题", + "Player style: ": "播放器样式: ", + "Dark mode: ": "深色模式: ", + "Theme: ": "主题: ", "dark": "暗色", "light": "亮色", - "Thin mode: ": "窄页模式:", + "Thin mode: ": "窄页模式: ", "Subscription preferences": "订阅设置", - "Show annotations by default for subscribed channels: ": "在订阅频道的视频默认显示注释?", + "Show annotations by default for subscribed channels: ": "默认情况下显示已订阅频道的注释: ", "Redirect homepage to feed: ": "跳转主页到 feed: ", - "Number of videos shown in feed: ": "Feed 中显示的视频数量:", - "Sort videos by: ": "视频排序方式:", + "Number of videos shown in feed: ": "Feed 中显示的视频数量: ", + "Sort videos by: ": "视频排序方式: ", "published": "发布时间", "published - reverse": "发布时间(反向)", "alphabetically": "字母序", "alphabetically - reverse": "字母序(反向)", "channel name": "频道名称", "channel name - reverse": "频道名称(反向)", - "Only show latest video from channel: ": "只显示订阅频道的最新一条视频:", - "Only show latest unwatched video from channel: ": "只显示订阅频道的最新未看过视频:", - "Only show unwatched: ": "只显示未看过的视频:", - "Only show notifications (if there are any): ": "只显示通知(如有):", + "Only show latest video from channel: ": "只显示频道的最新视频: ", + "Only show latest unwatched video from channel: ": "只显示频道的最新未看过视频: ", + "Only show unwatched: ": "只显示未看过的视频: ", + "Only show notifications (if there are any): ": "只显示通知 (如果有的话): ", "Enable web notifications": "启用浏览器通知", "`x` uploaded a video": "`x` 上传了视频", "`x` is live": "`x` 正在直播", @@ -102,13 +102,13 @@ "Watch history": "观看历史", "Delete account": "删除账户", "Administrator preferences": "管理员选项", - "Default homepage: ": "默认主页:", - "Feed menu: ": "Feed 菜单:", - "Top enabled: ": "启用“热门视频”页?", - "CAPTCHA enabled: ": "启用验证码?", - "Login enabled: ": "启用登录?", - "Registration enabled: ": "启用注册?", - "Report statistics: ": "报告统计信息?", + "Default homepage: ": "默认主页: ", + "Feed menu: ": "Feed 菜单: ", + "Top enabled: ": "是否启用“热门视频”页: ", + "CAPTCHA enabled: ": "是否启用验证码: ", + "Login enabled: ": "是否启用登录: ", + "Registration enabled: ": "是否启用注册: ", + "Report statistics: ": "是否报告统计信息: ", "Save preferences": "保存选项", "Subscription manager": "订阅管理器", "Token manager": "令牌管理器", @@ -141,13 +141,13 @@ "Watch on YouTube": "在 YouTube 观看", "Hide annotations": "隐藏注释", "Show annotations": "显示注释", - "Genre: ": "风格:", - "License: ": "协议:", - "Family friendly? ": "家庭友好?", - "Wilson score: ": "威尔逊得分:", - "Engagement: ": "参与度:", - "Whitelisted regions: ": "白名单区域:", - "Blacklisted regions: ": "黑名单区域:", + "Genre: ": "风格: ", + "License: ": "许可: ", + "Family friendly? ": "家庭友好? ", + "Wilson score: ": "威尔逊得分: ", + "Engagement: ": "参与度: ", + "Whitelisted regions: ": "白名单地区: ", + "Blacklisted regions: ": "黑名单地区: ", "Shared `x`": "`x`发布", "`x` views": "`x` 播放", "Premieres in `x`": "首映于 `x` 后", @@ -308,12 +308,12 @@ "`x` hours": "`x` 小时", "`x` minutes": "`x` 分钟", "`x` seconds": "`x` 秒", - "Fallback comments: ": "后备评论:", + "Fallback comments: ": "后备评论: ", "Popular": "热门频道", "Top": "热门视频", "About": "关于", - "Rating: ": "评分:", - "Language: ": "语言:", + "Rating: ": "评分: ", + "Language: ": "语言: ", "View as playlist": "作为播放列表查看", "Default": "默认", "Music": "音乐", @@ -321,7 +321,7 @@ "News": "新闻", "Movies": "电影", "Download": "下载", - "Download as: ": "下载为:", + "Download as: ": "下载为: ", "%A %B %-d, %Y": "%Y年%-m月%-d日 %a", "(edited)": "(已编辑)", "YouTube comment permalink": "YouTube 评论永久链接", @@ -332,5 +332,5 @@ "Videos": "视频", "Playlists": "播放列表", "Community": "社区", - "Current version: ": "当前版本:" -} \ No newline at end of file + "Current version: ": "当前版本: " +} diff --git a/locales/zh-TW.json b/locales/zh-TW.json index a8111750..6b40db55 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1,12 +1,8 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個訂閱者", - "": "`x` 個訂閱者。" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 部影片", - "": "`x` 部影片。" - }, + "`x` subscribers.([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個訂閱者", + "`x` subscribers.": "`x` 個訂閱者", + "`x` videos.([^.,0-9]|^)1([^.,0-9]|$)": "`x` 部影片", + "`x` videos.": "`x` 部影片", "`x` playlists": "`x` 播放清單", "LIVE": "直播", "Shared `x` ago": "`x` 前分享", @@ -119,22 +115,16 @@ "Subscription manager": "訂閱管理員", "Token manager": "Token 管理員", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個訂閱", - "": "`x` 個訂閱。" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token", - "": "`x` tokens。" - }, + "`x` subscriptions.([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個訂閱", + "`x` subscriptions.": "`x` 個訂閱", + "`x` tokens.([^.,0-9]|^)1([^.,0-9]|$)": "`x` token", + "`x` tokens.": "`x` 個存取金鑰", "Import/export": "匯入/匯出", "unsubscribe": "取消訂閱", "revoke": "撤銷", "Subscriptions": "訂閱", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個未讀的通知", - "": "`x` 個未讀的通知。" - }, + "`x` unseen notifications.([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個未讀的通知", + "`x` unseen notifications.": "`x` 個未讀的通知", "search": "搜尋", "Log out": "登出", "Released under the AGPLv3 by Omar Roth.": "Omar Roth 以 AGPLv3 釋出。", @@ -164,10 +154,8 @@ "Whitelisted regions: ": "白名單區域: ", "Blacklisted regions: ": "黑名單區域: ", "Shared `x`": "`x` 發佈", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 次檢視", - "": "`x` 次檢視。" - }, + "`x` views.([^.,0-9]|^)1([^.,0-9]|$)": "`x` 次檢視", + "`x` views.": "`x` 次檢視", "Premieres in `x`": "首映於 `x`", "Premieres `x`": "首映於 `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "嗨!看來您將 JavaScript 關閉了。點擊這裡以檢視留言,請注意,它們可能需要比較長的時間載入。", @@ -198,16 +186,12 @@ "This channel does not exist.": "此頻道不存在。", "Could not get channel info.": "無法取得頻道資訊。", "Could not fetch comments": "無法擷取留言", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "檢視 `x` 則回覆", - "": "檢視 `x` 則回覆。" - }, + "View `x` replies.([^.,0-9]|^)1([^.,0-9]|$)": "檢視 `x` 則回覆", + "View `x` replies.": "檢視 `x` 則回覆", "`x` ago": "`x` 以前", "Load more": "載入更多", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 點", - "": "`x` 點。" - }, + "`x` points.([^.,0-9]|^)1([^.,0-9]|$)": "`x` 點", + "`x` points.": "`x` 點", "Could not create mix.": "無法建立混合。", "Empty playlist": "空的播放清單", "Not a playlist.": "不是播放清單。", @@ -325,34 +309,20 @@ "Yiddish": "意第緒語", "Yoruba": "約魯巴語", "Zulu": "祖魯語", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 年", - "": "`x` 年。" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 月", - "": "`x` 月。" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 週", - "": "`x` 週。" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天", - "": "`x` 天。" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 小時", - "": "`x` 小時。" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天", - "": "`x` 分鐘。" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 秒", - "": "`x` 秒。" - }, + "`x` years.([^.,0-9]|^)1([^.,0-9]|$)": "`x` 年", + "`x` years.": "`x` 年", + "`x` months.([^.,0-9]|^)1([^.,0-9]|$)": "`x` 月", + "`x` months.": "`x` 月", + "`x` weeks.([^.,0-9]|^)1([^.,0-9]|$)": "`x` 週", + "`x` weeks.": "`x` 週", + "`x` days.([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天", + "`x` days.": "`x` 天", + "`x` hours.([^.,0-9]|^)1([^.,0-9]|$)": "`x` 小時", + "`x` hours.": "`x` 小時", + "`x` minutes.([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天", + "`x` minutes.": "`x` 分鐘", + "`x` seconds.([^.,0-9]|^)1([^.,0-9]|$)": "`x` 秒", + "`x` seconds.": "`x` 秒", "Fallback comments: ": "汰退留言: ", "Popular": "熱門頻道", "Top": "熱門影片", @@ -378,4 +348,4 @@ "Playlists": "播放清單", "Community": "社群", "Current version: ": "目前版本: " -} \ No newline at end of file +} From c3ed1ad040264de74b0787952e4f976c9b32a9b8 Mon Sep 17 00:00:00 2001 From: TheFrenchGhosty Date: Sat, 23 Jan 2021 18:22:05 +0100 Subject: [PATCH 0190/2881] Change some stuff done to the french translation in #1696 --- locales/fr.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/locales/fr.json b/locales/fr.json index 664e25f5..d6bdc05a 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -48,7 +48,7 @@ "Image CAPTCHA": "CAPTCHA Image", "Sign In": "Se connecter", "Register": "S'inscrire", - "E-mail": "Courriel", + "E-mail": "E-mail", "Google verification code": "Code de vérification Google", "Preferences": "Préférences", "Player preferences": "Préférences du lecteur", @@ -170,7 +170,7 @@ "User ID is a required field": "Veuillez entrer un Identifiant Utilisateur", "Password is a required field": "Veuillez entrer un Mot de passe", "Wrong username or password": "Nom d'utilisateur ou mot de passe invalide", - "Please sign in using 'Log in with Google'": "Veuillez vous connecter en utilisant « Se connecter avec Google »", + "Please sign in using 'Log in with Google'": "Veuillez vous connecter en utilisant \"Se connecter avec Google\"", "Password cannot be empty": "Le mot de passe ne peut pas être vide", "Password cannot be longer than 55 characters": "Le mot de passe ne doit pas comporter plus de 55 caractères", "Please log in": "Veuillez vous connecter", @@ -189,7 +189,7 @@ "Not a playlist.": "La liste de lecture est invalide.", "Playlist does not exist.": "La liste de lecture n'existe pas.", "Could not pull trending pages.": "Impossible de charger les pages de tendances.", - "Hidden field \"challenge\" is a required field": "Le champ masqué « challenge » est un champ obligatoire", + "Hidden field \"challenge\" is a required field": "Le champ masqué \"challenge\" est un champ obligatoire", "Hidden field \"token\" is a required field": "Le champ caché « token » est requis", "Erroneous challenge": "Challenge invalide", "Erroneous token": "Token invalide", From f1a7ee997b5f6bc52388473dcd1b8043c5ceacff Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Sat, 23 Jan 2021 18:58:13 +0100 Subject: [PATCH 0191/2881] Add config environment variables The config file can now be specified with `INVIDIOUS_CONFIG_FILE`. A YAML formatted string can still be passed with `INVIDIOUS_CONFIG`, replacing the config file. Additionally all options can now be specified as environment variables. The syntax for variable names is `INVIDIOUS_` followed by the option name in upper case. The values are parsed as YAML. These new env vars only update the provided main configuration, but it is possible to point the config file at the example config and then use env vars for all config options: ``` INVIDIOUS_CONFIG_FILE=./config/config.example.yml \ INVIDIOUS_CHANNEL_THREADS=10 \ ./invidious ``` --- src/invidious.cr | 7 ++-- src/invidious/helpers/helpers.cr | 57 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/invidious.cr b/src/invidious.cr index deb24ac3..1d86bb11 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -30,11 +30,8 @@ require "./invidious/*" require "./invidious/routes/**" require "./invidious/jobs/**" -ENV_CONFIG_NAME = "INVIDIOUS_CONFIG" - -CONFIG_STR = ENV.has_key?(ENV_CONFIG_NAME) ? ENV.fetch(ENV_CONFIG_NAME) : File.read("config/config.yml") -CONFIG = Config.from_yaml(CONFIG_STR) -HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) +CONFIG = Config.load +HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) PG_URL = URI.new( scheme: "postgres", diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 1f56ec92..bcd78699 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -115,6 +115,63 @@ class Config return false end end + + def self.load + # Load config from file or YAML string env var + env_config_file = "INVIDIOUS_CONFIG_FILE" + env_config_yaml = "INVIDIOUS_CONFIG" + + config_file = ENV.has_key?(env_config_file) ? ENV.fetch(env_config_file) : "config/config.yml" + config_yaml = ENV.has_key?(env_config_yaml) ? ENV.fetch(env_config_yaml) : File.read(config_file) + + config = Config.from_yaml(config_yaml) + + # Update config from env vars (upcased and prefixed with "INVIDIOUS_") + {% for ivar in Config.instance_vars %} + {% env_id = "INVIDIOUS_#{ivar.id.upcase}" %} + + if ENV.has_key?({{env_id}}) + # puts %(Config.{{ivar.id}} : Loading from env var {{env_id}}) + env_value = ENV.fetch({{env_id}}) + success = false + + # Use YAML converter if specified + {% ann = ivar.annotation(::YAML::Field) %} + {% if ann && ann[:converter] %} + puts %(Config.{{ivar.id}} : Parsing "#{env_value}" as {{ivar.type}} with {{ann[:converter]}} converter) + config.{{ivar.id}} = {{ann[:converter]}}.from_yaml(YAML::ParseContext.new, YAML::Nodes.parse(ENV.fetch({{env_id}})).nodes[0]) + puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}}) + success = true + + # Use regular YAML parser otherwise + {% else %} + {% ivar_types = ivar.type.union? ? ivar.type.union_types : [ivar.type] %} + # Sort types to avoid parsing nulls and numbers as strings + {% ivar_types = ivar_types.sort_by { |ivar_type| ivar_type == Nil ? 0 : ivar_type == Int32 ? 1 : 2 } %} + {{ivar_types}}.each do |ivar_type| + if !success + begin + # puts %(Config.{{ivar.id}} : Trying to parse "#{env_value}" as #{ivar_type}) + config.{{ivar.id}} = ivar_type.from_yaml(env_value) + puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}} (#{ivar_type})) + success = true + rescue + # nop + end + end + end + {% end %} + + # Exit on fail + if !success + puts %(Config.{{ivar.id}} failed to parse #{env_value} as {{ivar.type}}) + exit(1) + end + end + {% end %} + + return config + end end struct DBConfig From b45f371911502d6687fc0402139af5268ee5b13f Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Sat, 23 Jan 2021 19:39:04 +0100 Subject: [PATCH 0192/2881] Make config a constant Instead of passing around `config` there is now the global `CONFIG`. --- src/invidious.cr | 32 +++++++++----------- src/invidious/helpers/errors.cr | 32 ++++++++++---------- src/invidious/helpers/utils.cr | 12 ++++---- src/invidious/jobs/bypass_captcha_job.cr | 29 ++++++++---------- src/invidious/jobs/refresh_channels_job.cr | 7 ++--- src/invidious/jobs/refresh_feeds_job.cr | 5 ++- src/invidious/jobs/statistics_refresh_job.cr | 5 ++- src/invidious/jobs/subscribe_to_feeds_job.cr | 9 +++--- src/invidious/routes/base_route.cr | 4 --- src/invidious/routes/login.cr | 24 +++++++-------- src/invidious/routes/user_preferences.cr | 31 +++++++++---------- src/invidious/routing.cr | 4 +-- src/invidious/views/preferences.ecr | 14 ++++----- src/invidious/views/template.ecr | 2 +- 14 files changed, 97 insertions(+), 113 deletions(-) diff --git a/src/invidious.cr b/src/invidious.cr index 1d86bb11..66a88f56 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -49,7 +49,7 @@ PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") REDDIT_URL = URI.parse("https://www.reddit.com") TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com") YT_URL = URI.parse("https://www.youtube.com") -HOST_URL = make_host_url(CONFIG, Kemal.config) +HOST_URL = make_host_url(Kemal.config) CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"} @@ -142,8 +142,6 @@ end OUTPUT = CONFIG.output.upcase == "STDOUT" ? STDOUT : File.open(CONFIG.output, mode: "a") LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level) -config = CONFIG - # Check table integrity if CONFIG.check_tables check_enum(PG_DB, "privacy", PlaylistPrivacy) @@ -164,28 +162,28 @@ end # Start jobs -Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB, config) -Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB, config) +Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB) +Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB) DECRYPT_FUNCTION = DecryptFunction.new(CONFIG.decrypt_polling) -if config.decrypt_polling +if CONFIG.decrypt_polling Invidious::Jobs.register Invidious::Jobs::UpdateDecryptFunctionJob.new end -if config.statistics_enabled - Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, config, SOFTWARE) +if CONFIG.statistics_enabled + Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, SOFTWARE) end -if (config.use_pubsub_feeds.is_a?(Bool) && config.use_pubsub_feeds.as(Bool)) || (config.use_pubsub_feeds.is_a?(Int32) && config.use_pubsub_feeds.as(Int32) > 0) - Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, config, HMAC_KEY) +if (CONFIG.use_pubsub_feeds.is_a?(Bool) && CONFIG.use_pubsub_feeds.as(Bool)) || (CONFIG.use_pubsub_feeds.is_a?(Int32) && CONFIG.use_pubsub_feeds.as(Int32) > 0) + Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, HMAC_KEY) end -if config.popular_enabled +if CONFIG.popular_enabled Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) end -if config.captcha_key - Invidious::Jobs.register Invidious::Jobs::BypassCaptchaJob.new(config) +if CONFIG.captcha_key + Invidious::Jobs.register Invidious::Jobs::BypassCaptchaJob.new end connection_channel = Channel({Bool, Channel(PQ::Notification)}).new(32) @@ -216,7 +214,7 @@ before_all do |env| env.response.headers["Content-Security-Policy"] = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; manifest-src 'self'; media-src 'self' blob:#{extra_media_csp}" env.response.headers["Referrer-Policy"] = "same-origin" - if (Kemal.config.ssl || config.https_only) && config.hsts + if (Kemal.config.ssl || CONFIG.https_only) && CONFIG.hsts env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload" end @@ -1161,7 +1159,7 @@ end get "/feed/popular" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? - if config.popular_enabled + if CONFIG.popular_enabled templated "popular" else message = translate(locale, "The Popular feed has been disabled by the administrator.") @@ -1819,7 +1817,7 @@ get "/api/v1/stats" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? env.response.content_type = "application/json" - if !config.statistics_enabled + if !CONFIG.statistics_enabled next error_json(400, "Statistics are not enabled.") end @@ -2229,7 +2227,7 @@ get "/api/v1/popular" do |env| env.response.content_type = "application/json" - if !config.popular_enabled + if !CONFIG.popular_enabled error_message = {"error" => "Administrator has disabled this endpoint."}.to_json env.response.status_code = 400 next error_message diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index 2c62d44b..68ced430 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -7,7 +7,7 @@ class InfoException < Exception end macro error_template(*args) - error_template_helper(env, config, locale, {{*args}}) + error_template_helper(env, locale, {{*args}}) end def github_details(summary : String, content : String) @@ -22,9 +22,9 @@ def github_details(summary : String, content : String) return HTML.escape(details) end -def error_template_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception) +def error_template_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception) if exception.is_a?(InfoException) - return error_template_helper(env, config, locale, status_code, exception.message || "") + return error_template_helper(env, locale, status_code, exception.message || "") end env.response.content_type = "text/html" env.response.status_code = status_code @@ -43,7 +43,7 @@ def error_template_helper(env : HTTP::Server::Context, config : Config, locale : return templated "error" end -def error_template_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String) +def error_template_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String) env.response.content_type = "text/html" env.response.status_code = status_code error_message = translate(locale, message) @@ -51,31 +51,31 @@ def error_template_helper(env : HTTP::Server::Context, config : Config, locale : end macro error_atom(*args) - error_atom_helper(env, config, locale, {{*args}}) + error_atom_helper(env, locale, {{*args}}) end -def error_atom_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception) +def error_atom_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception) if exception.is_a?(InfoException) - return error_atom_helper(env, config, locale, status_code, exception.message || "") + return error_atom_helper(env, locale, status_code, exception.message || "") end env.response.content_type = "application/atom+xml" env.response.status_code = status_code return "#{exception.inspect_with_backtrace}" end -def error_atom_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String) +def error_atom_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String) env.response.content_type = "application/atom+xml" env.response.status_code = status_code return "#{message}" end macro error_json(*args) - error_json_helper(env, config, locale, {{*args}}) + error_json_helper(env, locale, {{*args}}) end -def error_json_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception, additional_fields : Hash(String, Object) | Nil) +def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception, additional_fields : Hash(String, Object) | Nil) if exception.is_a?(InfoException) - return error_json_helper(env, config, locale, status_code, exception.message || "", additional_fields) + return error_json_helper(env, locale, status_code, exception.message || "", additional_fields) end env.response.content_type = "application/json" env.response.status_code = status_code @@ -86,11 +86,11 @@ def error_json_helper(env : HTTP::Server::Context, config : Config, locale : Has return error_message.to_json end -def error_json_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception) - return error_json_helper(env, config, locale, status_code, exception, nil) +def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception) + return error_json_helper(env, locale, status_code, exception, nil) end -def error_json_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String, additional_fields : Hash(String, Object) | Nil) +def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String, additional_fields : Hash(String, Object) | Nil) env.response.content_type = "application/json" env.response.status_code = status_code error_message = {"error" => message} @@ -100,6 +100,6 @@ def error_json_helper(env : HTTP::Server::Context, config : Config, locale : Has return error_message.to_json end -def error_json_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String) - error_json_helper(env, config, locale, status_code, message, nil) +def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String) + error_json_helper(env, locale, status_code, message, nil) end diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index f068b5f2..7d94a6e5 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -280,9 +280,9 @@ def arg_array(array, start = 1) return args end -def make_host_url(config, kemal_config) - ssl = config.https_only || kemal_config.ssl - port = config.external_port || kemal_config.port +def make_host_url(kemal_config) + ssl = CONFIG.https_only || kemal_config.ssl + port = CONFIG.external_port || kemal_config.port if ssl scheme = "https://" @@ -297,11 +297,11 @@ def make_host_url(config, kemal_config) port = "" end - if !config.domain + if !CONFIG.domain return "" end - host = config.domain.not_nil!.lchop(".") + host = CONFIG.domain.not_nil!.lchop(".") return "#{scheme}#{host}#{port}" end @@ -345,7 +345,7 @@ def sha256(text) return digest.final.hexstring end -def subscribe_pubsub(topic, key, config) +def subscribe_pubsub(topic, key) case topic when .match(/^UC[A-Za-z0-9_-]{22}$/) topic = "channel_id=#{topic}" diff --git a/src/invidious/jobs/bypass_captcha_job.cr b/src/invidious/jobs/bypass_captcha_job.cr index 22c54036..8b1aed5f 100644 --- a/src/invidious/jobs/bypass_captcha_job.cr +++ b/src/invidious/jobs/bypass_captcha_job.cr @@ -1,9 +1,4 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob - private getter config : Config - - def initialize(@config) - end - def begin loop do begin @@ -22,9 +17,9 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob headers = response.cookies.add_request_headers(HTTP::Headers.new) - response = JSON.parse(HTTP::Client.post(config.captcha_api_url + "/createTask", + response = JSON.parse(HTTP::Client.post(CONFIG.captcha_api_url + "/createTask", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: { - "clientKey" => config.captcha_key, + "clientKey" => CONFIG.captcha_key, "task" => { "type" => "NoCaptchaTaskProxyless", "websiteURL" => "https://www.youtube.com#{path}", @@ -39,9 +34,9 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob loop do sleep 10.seconds - response = JSON.parse(HTTP::Client.post(config.captcha_api_url + "/getTaskResult", + response = JSON.parse(HTTP::Client.post(CONFIG.captcha_api_url + "/getTaskResult", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: { - "clientKey" => config.captcha_key, + "clientKey" => CONFIG.captcha_key, "taskId" => task_id, }.to_json).body) @@ -58,10 +53,10 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob response.cookies .select { |cookie| cookie.name != "PREF" } - .each { |cookie| config.cookies << cookie } + .each { |cookie| CONFIG.cookies << cookie } # Persist cookies between runs - File.write("config/config.yml", config.to_yaml) + File.write("config/config.yml", CONFIG.to_yaml) elsif response.headers["Location"]?.try &.includes?("/sorry/index") location = response.headers["Location"].try { |u| URI.parse(u) } headers = HTTP::Headers{":authority" => location.host.not_nil!} @@ -77,11 +72,11 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob inputs[node["name"]] = node["value"] end - captcha_client = HTTPClient.new(URI.parse(config.captcha_api_url)) - captcha_client.family = config.force_resolve || Socket::Family::INET + captcha_client = HTTPClient.new(URI.parse(CONFIG.captcha_api_url)) + captcha_client.family = CONFIG.force_resolve || Socket::Family::INET response = JSON.parse(captcha_client.post("/createTask", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: { - "clientKey" => config.captcha_key, + "clientKey" => CONFIG.captcha_key, "task" => { "type" => "NoCaptchaTaskProxyless", "websiteURL" => location.to_s, @@ -100,7 +95,7 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob response = JSON.parse(captcha_client.post("/getTaskResult", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: { - "clientKey" => config.captcha_key, + "clientKey" => CONFIG.captcha_key, "taskId" => task_id, }.to_json).body) @@ -119,10 +114,10 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob } cookies = HTTP::Cookies.from_headers(headers) - cookies.each { |cookie| config.cookies << cookie } + cookies.each { |cookie| CONFIG.cookies << cookie } # Persist cookies between runs - File.write("config/config.yml", config.to_yaml) + File.write("config/config.yml", CONFIG.to_yaml) end end rescue ex diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index 3e94a56e..fbe6d381 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -1,12 +1,11 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob private getter db : DB::Database - private getter config : Config - def initialize(@db, @config) + def initialize(@db) end def begin - max_fibers = config.channel_threads + max_fibers = CONFIG.channel_threads lim_fibers = max_fibers active_fibers = 0 active_channel = Channel(Bool).new @@ -31,7 +30,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob spawn do begin LOGGER.trace("RefreshChannelsJob: #{id} fiber : Fetching channel") - channel = fetch_channel(id, db, config.full_refresh) + channel = fetch_channel(id, db, CONFIG.full_refresh) lim_fibers = max_fibers diff --git a/src/invidious/jobs/refresh_feeds_job.cr b/src/invidious/jobs/refresh_feeds_job.cr index 7b4ccdea..926c27fa 100644 --- a/src/invidious/jobs/refresh_feeds_job.cr +++ b/src/invidious/jobs/refresh_feeds_job.cr @@ -1,12 +1,11 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob private getter db : DB::Database - private getter config : Config - def initialize(@db, @config) + def initialize(@db) end def begin - max_fibers = config.feed_threads + max_fibers = CONFIG.feed_threads active_fibers = 0 active_channel = Channel(Bool).new diff --git a/src/invidious/jobs/statistics_refresh_job.cr b/src/invidious/jobs/statistics_refresh_job.cr index 021671be..aa46fb0e 100644 --- a/src/invidious/jobs/statistics_refresh_job.cr +++ b/src/invidious/jobs/statistics_refresh_job.cr @@ -21,9 +21,8 @@ class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob } private getter db : DB::Database - private getter config : Config - def initialize(@db, @config, @software_config : Hash(String, String)) + def initialize(@db, @software_config : Hash(String, String)) end def begin @@ -43,7 +42,7 @@ class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob "version" => @software_config["version"], "branch" => @software_config["branch"], } - STATISTICS["openRegistration"] = config.registration_enabled + STATISTICS["openRegistration"] = CONFIG.registration_enabled end private def refresh_stats diff --git a/src/invidious/jobs/subscribe_to_feeds_job.cr b/src/invidious/jobs/subscribe_to_feeds_job.cr index 750aceb8..a431a48a 100644 --- a/src/invidious/jobs/subscribe_to_feeds_job.cr +++ b/src/invidious/jobs/subscribe_to_feeds_job.cr @@ -1,15 +1,14 @@ class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob private getter db : DB::Database private getter hmac_key : String - private getter config : Config - def initialize(@db, @config, @hmac_key) + def initialize(@db, @hmac_key) end def begin max_fibers = 1 - if config.use_pubsub_feeds.is_a?(Int32) - max_fibers = config.use_pubsub_feeds.as(Int32) + if CONFIG.use_pubsub_feeds.is_a?(Int32) + max_fibers = CONFIG.use_pubsub_feeds.as(Int32) end active_fibers = 0 @@ -30,7 +29,7 @@ class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob spawn do begin - response = subscribe_pubsub(ucid, hmac_key, config) + response = subscribe_pubsub(ucid, hmac_key) if response.status_code >= 400 LOGGER.error("SubscribeToFeedsJob: #{ucid} : #{response.body}") diff --git a/src/invidious/routes/base_route.cr b/src/invidious/routes/base_route.cr index 37624267..07c6f15b 100644 --- a/src/invidious/routes/base_route.cr +++ b/src/invidious/routes/base_route.cr @@ -1,6 +1,2 @@ abstract class Invidious::Routes::BaseRoute - private getter config : Config - - def initialize(@config) - end end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 45a6d4d8..662fdf13 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -6,7 +6,7 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute return env.redirect "/feed/subscriptions" if user - if !config.login_enabled + if !CONFIG.login_enabled return error_template(400, "Login has been disabled by administrator.") end @@ -33,7 +33,7 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute referer = get_referer(env, "/feed/subscriptions") - if !config.login_enabled + if !CONFIG.login_enabled return error_template(403, "Login has been disabled by administrator.") end @@ -274,14 +274,14 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute host = URI.parse(env.request.headers["Host"]).host - if Kemal.config.ssl || config.https_only + if Kemal.config.ssl || CONFIG.https_only secure = true else secure = false end cookies.each do |cookie| - if Kemal.config.ssl || config.https_only + if Kemal.config.ssl || CONFIG.https_only cookie.secure = secure else cookie.secure = secure @@ -330,14 +330,14 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc) - if Kemal.config.ssl || config.https_only + if Kemal.config.ssl || CONFIG.https_only secure = true else secure = false end - if config.domain - env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{config.domain}", value: sid, expires: Time.utc + 2.years, + if CONFIG.domain + env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{CONFIG.domain}", value: sid, expires: Time.utc + 2.years, secure: secure, http_only: true) else env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years, @@ -354,7 +354,7 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute env.response.cookies << cookie end else - if !config.registration_enabled + if !CONFIG.registration_enabled return error_template(400, "Registration has been disabled by administrator.") end @@ -369,7 +369,7 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute password = password.byte_slice(0, 55) - if config.captcha_enabled + if CONFIG.captcha_enabled captcha_type = env.params.body["captcha_type"]? answer = env.params.body["answer"]? change_type = env.params.body["change_type"]? @@ -445,14 +445,14 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute view_name = "subscriptions_#{sha256(user.email)}" PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") - if Kemal.config.ssl || config.https_only + if Kemal.config.ssl || CONFIG.https_only secure = true else secure = false end - if config.domain - env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{config.domain}", value: sid, expires: Time.utc + 2.years, + if CONFIG.domain + env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{CONFIG.domain}", value: sid, expires: Time.utc + 2.years, secure: secure, http_only: true) else env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years, diff --git a/src/invidious/routes/user_preferences.cr b/src/invidious/routes/user_preferences.cr index 7f334115..a689a2a2 100644 --- a/src/invidious/routes/user_preferences.cr +++ b/src/invidious/routes/user_preferences.cr @@ -146,8 +146,8 @@ class Invidious::Routes::UserPreferences < Invidious::Routes::BaseRoute user = user.as(User) PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email) - if config.admins.includes? user.email - config.default_user_preferences.default_home = env.params.body["admin_default_home"]?.try &.as(String) || config.default_user_preferences.default_home + if CONFIG.admins.includes? user.email + CONFIG.default_user_preferences.default_home = env.params.body["admin_default_home"]?.try &.as(String) || CONFIG.default_user_preferences.default_home admin_feed_menu = [] of String 4.times do |index| @@ -156,40 +156,39 @@ class Invidious::Routes::UserPreferences < Invidious::Routes::BaseRoute admin_feed_menu << option 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) popular_enabled ||= "off" - config.popular_enabled = popular_enabled == "on" + CONFIG.popular_enabled = popular_enabled == "on" captcha_enabled = env.params.body["captcha_enabled"]?.try &.as(String) captcha_enabled ||= "off" - config.captcha_enabled = captcha_enabled == "on" + CONFIG.captcha_enabled = captcha_enabled == "on" login_enabled = env.params.body["login_enabled"]?.try &.as(String) login_enabled ||= "off" - config.login_enabled = login_enabled == "on" + CONFIG.login_enabled = login_enabled == "on" registration_enabled = env.params.body["registration_enabled"]?.try &.as(String) registration_enabled ||= "off" - config.registration_enabled = registration_enabled == "on" + CONFIG.registration_enabled = registration_enabled == "on" statistics_enabled = env.params.body["statistics_enabled"]?.try &.as(String) statistics_enabled ||= "off" - config.statistics_enabled = statistics_enabled == "on" + CONFIG.statistics_enabled = statistics_enabled == "on" - CONFIG.default_user_preferences = config.default_user_preferences - File.write("config/config.yml", config.to_yaml) + File.write("config/config.yml", CONFIG.to_yaml) end else - if Kemal.config.ssl || config.https_only + if Kemal.config.ssl || CONFIG.https_only secure = true else secure = false end - if config.domain - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{config.domain}", value: preferences, expires: Time.utc + 2.years, + if CONFIG.domain + env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: preferences, expires: Time.utc + 2.years, secure: secure, http_only: true) else env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: preferences, expires: Time.utc + 2.years, @@ -234,14 +233,14 @@ class Invidious::Routes::UserPreferences < Invidious::Routes::BaseRoute preferences = preferences.to_json - if Kemal.config.ssl || config.https_only + if Kemal.config.ssl || CONFIG.https_only secure = true else secure = false end - if config.domain - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{config.domain}", value: preferences, expires: Time.utc + 2.years, + if CONFIG.domain + env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: preferences, expires: Time.utc + 2.years, secure: secure, http_only: true) else env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: preferences, expires: Time.utc + 2.years, diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 593c7372..82d0028b 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -1,14 +1,14 @@ module Invidious::Routing macro get(path, controller, method = :handle) get {{ path }} do |env| - controller_instance = {{ controller }}.new(config) + controller_instance = {{ controller }}.new controller_instance.{{ method.id }}(env) end end macro post(path, controller, method = :handle) post {{ path }} do |env| - controller_instance = {{ controller }}.new(config) + controller_instance = {{ controller }}.new controller_instance.{{ method.id }}(env) end end diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index 1ef080be..14d63536 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -208,14 +208,14 @@
<% # Web notifications are only supported over HTTPS %> - <% if Kemal.config.ssl || config.https_only %> + <% if Kemal.config.ssl || CONFIG.https_only %> <% end %> <% end %> - <% if env.get?("user") && config.admins.includes? env.get?("user").as(User).email %> + <% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(User).email %> <%= translate(locale, "Administrator preferences") %>
@@ -240,28 +240,28 @@
- checked<% end %>> + checked<% end %>>
- checked<% end %>> + checked<% end %>>
- checked<% end %>> + checked<% end %>>
- checked<% end %>> + checked<% end %>>
- checked<% end %>> + checked<% end %>>
<% end %> diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index f6e5262d..61b900e3 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -87,7 +87,7 @@
- <% if config.login_enabled %> + <% if CONFIG.login_enabled %>
" class="pure-menu-heading"> <%= translate(locale, "Log in") %> From 70e14f92a46dd15bf118d41fd6ab5bcd2c3ee807 Mon Sep 17 00:00:00 2001 From: saltycrys <73420320+saltycrys@users.noreply.github.com> Date: Sat, 23 Jan 2021 19:41:50 +0100 Subject: [PATCH 0193/2881] Only start refresh jobs when necessary If `channel_threads` or `feed_threads` is set to zero the corresponding job is now not started. --- src/invidious.cr | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/invidious.cr b/src/invidious.cr index 66a88f56..983e6196 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -162,8 +162,13 @@ end # Start jobs -Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB) -Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB) +if CONFIG.channel_threads > 0 + Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB) +end + +if CONFIG.feed_threads > 0 + Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB) +end DECRYPT_FUNCTION = DecryptFunction.new(CONFIG.decrypt_polling) if CONFIG.decrypt_polling From d0dbbd1cb1b34761046f275c4e76f8c4e6a982cb Mon Sep 17 00:00:00 2001 From: Andrew Zhao Date: Wed, 27 Jan 2021 12:36:24 -0500 Subject: [PATCH 0194/2881] remove https from channel thumbnail in search --- src/invidious/helpers/helpers.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 1f56ec92..6bbb18cb 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -225,7 +225,7 @@ def extract_item(item : JSON::Any, author_fallback : String? = nil, author_id_fa author = i["title"]["simpleText"]?.try &.as_s || author_fallback || "" author_id = i["channelId"]?.try &.as_s || author_id_fallback || "" - author_thumbnail = i["thumbnail"]["thumbnails"]?.try &.as_a[0]?.try { |u| "https:#{u["url"]}" } || "" + author_thumbnail = i["thumbnail"]["thumbnails"]?.try &.as_a[0]?.try &.["url"]?.try &.as_s || "" subscriber_count = i["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s.try { |s| short_text_to_number(s.split(" ")[0]) } || 0 auto_generated = false From e35345f1358d98fa36b8d3b8b5b420260a8aef10 Mon Sep 17 00:00:00 2001 From: Perflyst Date: Thu, 28 Jan 2021 12:51:34 +0100 Subject: [PATCH 0195/2881] Remove container release on PR --- .github/workflows/container-release.yml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index 137308d0..1f811b7c 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -4,8 +4,6 @@ on: push: branches: - "master" - pull_request: - branches: "*" schedule: - cron: 0 0 * * * @@ -39,13 +37,3 @@ jobs: labels: quay.expires-after=12w push: true tags: quay.io/invidious/invidious:${{ github.sha }},quay.io/invidious/invidious:latest - - - name: Build and push for Pull Request - if: github.ref != 'refs/heads/master' - uses: docker/build-push-action@v2 - with: - context: . - file: docker/Dockerfile - labels: quay.expires-after=6w - push: true - tags: quay.io/invidious/invidious:${{ github.sha }} From fedaef5d1704778978c27a98cf385367e149aaca Mon Sep 17 00:00:00 2001 From: Andrew Zhao Date: Fri, 29 Jan 2021 12:36:19 -0500 Subject: [PATCH 0196/2881] install crystal 35.1 in ci --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fde6506e..fc0e8096 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: - name: Install Crystal uses: oprypin/install-crystal@v1.2.4 + with: + crystal: 0.35.1 - name: Cache Shards uses: actions/cache@v2 From 4a0b10984ad4151a8f7c8b0a9db3ed378a6df57e Mon Sep 17 00:00:00 2001 From: Andrew Zhao Date: Wed, 27 Jan 2021 10:45:03 -0500 Subject: [PATCH 0197/2881] Bump videojs and fix webworker --- assets/css/video-js.min.css | 2 +- assets/js/global.js | 3 --- assets/js/video.min.js | 25 +++++++++++-------- src/invidious.cr | 2 +- .../views/components/player_sources.ecr | 1 - 5 files changed, 17 insertions(+), 16 deletions(-) delete mode 100644 assets/js/global.js diff --git a/assets/css/video-js.min.css b/assets/css/video-js.min.css index b453b88c..17702b1b 100644 --- a/assets/css/video-js.min.css +++ b/assets/css/video-js.min.css @@ -1 +1 @@ -@charset "UTF-8";.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.video-js .vjs-modal-dialog,.vjs-button>.vjs-icon-placeholder:before,.vjs-modal-dialog .vjs-modal-dialog-content{position:absolute;top:0;left:0;width:100%;height:100%}.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.vjs-button>.vjs-icon-placeholder:before{text-align:center}@font-face{font-family:VideoJS;src:url(data:application/font-woff;charset=utf-8;base64,d09GRgABAAAAABDkAAsAAAAAG6gAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABHU1VCAAABCAAAADsAAABUIIslek9TLzIAAAFEAAAAPgAAAFZRiV3hY21hcAAAAYQAAADaAAADPv749/pnbHlmAAACYAAAC3AAABHQZg6OcWhlYWQAAA3QAAAAKwAAADYZw251aGhlYQAADfwAAAAdAAAAJA+RCLFobXR4AAAOHAAAABMAAACM744AAGxvY2EAAA4wAAAASAAAAEhF6kqubWF4cAAADngAAAAfAAAAIAE0AIFuYW1lAAAOmAAAASUAAAIK1cf1oHBvc3QAAA/AAAABJAAAAdPExYuNeJxjYGRgYOBiMGCwY2BycfMJYeDLSSzJY5BiYGGAAJA8MpsxJzM9kYEDxgPKsYBpDiBmg4gCACY7BUgAeJxjYGS7wTiBgZWBgaWQ5RkDA8MvCM0cwxDOeI6BgYmBlZkBKwhIc01hcPjI+FGJHcRdyA4RZgQRADK3CxEAAHic7dFZbsMgAEXRS0ycyZnnOeG7y+qC8pU1dHusIOXxuoxaOlwZYWQB0Aea4quIEN4E9LzKbKjzDeM6H/mua6Lmc/p8yhg0lvdYx15ZG8uOLQOGjMp3EzqmzJizYMmKNRu27Nhz4MiJMxeu3Ljz4Ekqm7T8P52G8PP3lnTOVk++Z6iN6QZzNN1F7ptuN7eGOjDUoaGODHVsuvU8MdTO9Hd5aqgzQ50b6sJQl4a6MtS1oW4MdWuoO0PdG+rBUI+GejLUs6FeDPVqqDdDvRvqw1CfhpqM9At0iFLaAAB4nJ1YDXBTVRZ+5/22TUlJ8we0pHlJm7RJf5O8F2j6EymlSPkpxaL8U2xpa3DKj0CBhc2IW4eWKSokIoLsuMqssM64f+jA4HSdWXXXscBq67IOs3FXZ1ZYWVyRFdo899yXtIBQZ90k7717zz3v3HPPOfd854YCCj9cL9dL0RQFOqCbGJnrHb5EayiKIWN8iA/hWBblo6hUWm8TtCDwE80WMJus/irwyxOdxeB0MDb14VNJHnXYoLLSl6FfCUYO9nYPTA8Epg9090LprfbBbZ2hY0UlJUXHQp3/vtWkS6EBv8+rPMq5u9692f/dNxJNiqwC1xPE9TCUgCsSdQWgE3XQD25lkG4CN2xmTcOXWBOyser6RN6KnGbKSbmQ3+d0OI1m2W8QzLLkI2sykrWAgJJEtA8vGGW/2Q+CmT3n8zS9wZwu2DCvtuZKZN3xkrLh36yCZuUomQSqGpY8t/25VfHVhw8z4ebGBtfLb0ya9PCaDc+8dGTvk2dsh6z7WzvowlXKUSWo9MJ15a3KrEP2loOr2Ojhw6iW6hf2BDdEccQvZGpaAy7YovSwq8kr7HGllxpd71rkS6G0Sf11sl9OvMK1+jwPPODxjUwkOim9CU3ix1wNjXDfmJSEn618Bs6lpWwUpU+8PCqLMY650zjq8VhCIP17NEKTx3eaLL+s5Pi6yJWaWjTHLR1jYzPSV9VF/6Ojdb/1kO3Mk3uhHC0x6gc1BjlKQ+nQFxTYdaJkZ7ySVxLBbhR1dsboNXp1tCYKW2LRaEzpYcIx2BKNxaL0ZaUnSqfFoiNhHKR/GkX6PWUSAaJelQaqZL1EpoHNsajSEyPSoJ9IjhIxTdjHLmwZvhRDOiFTY/YeQnvrVZmiTQtGncECXtFTBZLOVwwMRgoXHAkXzMzPn1nAJJ8jYSbMDaqN2waGLzNhih/bZynUBMpIWSg7VYi7DRx2m8ALkIdRCJwI6ArJx2EI8kaDWeTQKeAFk9fjl/1AvwktjQ1P7NjyMGQyfd4vjipX6M/i52D7Cq80kqlcxEcGXRr/FEcgs0u5uGgB4VWuMFfpdn2Re6Hi3PqzmxWKsz6+ae2Pn9hXXw/fqM859UiGC0oKYYILJBqJrsn1Z1E5qOs9rQCiUQRREjm8yJcbHF5cUJufX1vAHlefw0XgUoboS3ETfQlTxBC4SOtuE8VPRJTBSCQSjZCpk7Gqzu+masaZ2y7Zjehho4F3g82BNDkAHpORG4+OCS+f6JTPmtRn/PH1kch6d04sp7AQb25aQ/pqUyXeQ8vrebG8OYQdXOQ+585u0sdW9rqalzRURiJ+9F4MweRFrKUjl1GUYhH1A27WOHw5cTFSFPMo9EeUIGnQTZHIaJ7AHLaOKsOODaNF9jkBjYG2QEsQ2xjMUAx2bBEbeTBWMHwskBjngq56S/yfgkBnWBa4K9sqKtq2t1UI8S9He5XuBRbawAdatrQEAi30Aks2+LM8WeCbalVZkWNylvJ+dqJnzVb+OHlSoKW8nPCP7Rd+CcZ2DdWAGqJ2CBFOphgywFFCFBNtfAbGtNPBCwxvygHeYMZMY9ZboBqwq/pVrsbgN5tkv152ODlbMfiqwGMBgxa4Exz3QhovRIUp6acqZmQzRq0ypDXS2TPLT02YIkQETnOE445oOGxOmXAqUJNNG7XgupMjPq2ua9asrj5yY/yuKteO1Kx0YNJTufrirLe1mZnat7OL6rnUdCWenpW6I8mAnbsY8KWs1PuSovCW9A/Z25PQ24a7cNOqgmTkLmBMgh4THgc4b9k2IVv1/g/F5nGljwPLfOgHAzJzh45V/4+WenTzmMtR5Z7us2Tys909UHqrPY7KbckoxRvRHhmVc3cJGE97uml0R1S0jdULVl7EvZtDFVBF35N9cEdjpgmAiOlFZ+Dtoh93+D3zzHr8RRNZQhnCNMNbcegOvpEwZoL+06cJQ07h+th3fZ/7PVbVC6ngTAV/KoLFuO6+2KFcU651gEb5ugPSIb1D+Xp8V4+k3sEIGnw5mYe4If4k1lFYr6SCzmM2EQ8iWtmwjnBI9kTwe1TlfAmXh7H02by9fW2gsjKwtv0aaURKil4OdV7rDL1MXIFNrhdxohcZXYTnq47WisrKitaObbf5+yvkLi5J6lCNZZ+B6GC38VNBZBDidSS/+mSvh6s+srgC8pyKMvDtt+de3c9fU76ZPfuM8ud4Kv0fyP/LqfepMT/3oZxSqpZaTa1DaQYLY8TFsHYbWYsPoRhRWfL5eSSQbhUGgGC3YLbVMk6PitTFNGpAsNrC6D1VNBKgBHMejaiuRWEWGgsSDBTJjqWIl8kJLlsaLJ2tXDr6xGfT85bM2Q06a46x2HTgvdnV8z5YDy/27J4zt6x2VtkzjoYpkq36kaBr4eQSg7tyiVweWubXZugtadl58ydapfbORfKsDTuZ0OBgx4cfdjCf5tbWNITnL120fdOi1RV1C3uKGzNdwYLcMvZ3BxoPyTOCD1XvXTp7U10gWCVmTV9b3r2z0SkGWovb2hp9I89O8a2smlyaO8muMU+dRmtzp60IzAoFpjLr1n388boLyf0dRvxhsHZ0qbWqDkwqvvpkj4l0fY6EIXRi5sQSrAvsVYwXRy4qJ2EVtD1AN7a0HWth9ymvL1xc3WTUKK/TAHA/bXDVtVWfOMfuGxGZv4Ln/jVr9jc3j1yMv0tndmyt9Vq88Y9gH1wtLX3KWjot5++jWHgAoZZkQ14wGQ20Fli71UmKJAy4xKMSTGbVdybW7FDDAut9XpD5AzWrYO7zQ8qffqF8+Ynd/clrHcdyxGy3a/3+mfNnzC/cBsveTjnTvXf1o6vzOlZw7WtqtdmPK/Errz/6NNtD72zmNOZfbmYdTGHfoofqI79Oc+R2n1lrnL6pOm0Up7kwxhTW12Amm7WYkXR2qYrF2AmgmbAsxZjwy1xpg/m1Je2vrp8v/nz2xpmlBg4E9hrMU341wVpTOh/OfmGvAnra8q6uctr60ZQHV3Q+WMQJykMj8ZsWn2QBOmmHMB+m5pDIpTFonYigiaKAhGEiAHF7EliVnQkjoLVIMPtJpBKHYd3A8GYH9jJzrWwmHx5Qjp7vDAX0suGRym1vtm/9W1/HyR8vczfMs6Sk8DSv855/5dlX9oQq52hT8syyp2rx5Id17IAyAM3wIjQPMOHzytEB64q6D5zT91yNbnx3V/nqnd017S9Y0605k3izoXLpsxde2n38yoOV9s1LcjwzNjbdX6asnBVaBj/6/DwKwPkpcqbDG7BnsXoSqWnUAmottYF6jMSdVyYZh3zVXCjwTiwwHH6sGuRiEHQGzuRX6whZkp123oy1BWE2mEfJ/tvIRtM4ZM5bDXiMsPMaAKOTyc5uL57rqyyc5y5JE5pm1i2S2iUX0CcaQ6lC6Zog7JqSqZmYlosl2K6pwNA84zRnQW6SaALYZQGW5lhCtU/W34N6o+bKfZ8cf3/Cl/+iTX3wBzpOY4mRkeNf3rptycGSshQWgGbYt5jFc2e0+DglIrwl6DVWQ7BuwaJ3Xk1J4VL5urnLl/Wf+gHU/hZoZdKNym6lG+I34FaNeZKcSpJIo2IeCVvpdsDGfKvzJnAwmeD37Ow65ZWwSowpgwX5T69s/rB55dP5BcpgDKFV8p7q2sn/1uc93bVzT/w6UrCqDTWvfCq/oCD/qZXNoUj8BL5Kp6GU017frfNXkAtiiyf/SOCEeLqnd8R/Ql9GlCRfctS6k5chvIBuQ1zCCjoCHL2DHNHIXxMJ3kQeO8lbsUXONeSfA5EjcG6/E+KdhN4bP04vBhdi883+BFBzQbxFbvZzQeY9LNBZc0FNfn5NwfDn6rCTnTw6R8o+gfpf5hCom33cRuiTlss3KHmZjD+BPN+5gXuA2ziS/Q73mLxUkpbKN/eqwz5uK0X9F3h2d1V4nGNgZGBgAOJd776+iue3+crAzc4AAje5Bfcg0xz9YHEOBiYQBQA8FQlFAHicY2BkYGBnAAGOPgaG//85+hkYGVCBMgBGGwNYAAAAeJxjYGBgYB8EmKOPgQEAQ04BfgAAAAAAAA4AaAB+AMwA4AECAUIBbAGYAcICGAJYArQC4AMwA7AD3gQwBJYE3AUkBWYFigYgBmYGtAbqB1gIEghYCG4IhAi2COh4nGNgZGBgUGYoZWBnAAEmIOYCQgaG/2A+AwAYCQG2AHicXZBNaoNAGIZfE5PQCKFQ2lUps2oXBfOzzAESyDKBQJdGR2NQR3QSSE/QE/QEPUUPUHqsvsrXjTMw83zPvPMNCuAWP3DQDAejdm1GjzwS7pMmwi75XngAD4/CQ/oX4TFe4Qt7uMMbOzjuDc0EmXCP/C7cJ38Iu+RP4QEe8CU8pP8WHmOPX2EPz87TPo202ey2OjlnQSXV/6arOjWFmvszMWtd6CqwOlKHq6ovycLaWMWVydXKFFZnmVFlZU46tP7R2nI5ncbi/dDkfDtFBA2DDXbYkhKc+V0Bqs5Zt9JM1HQGBRTm/EezTmZNKtpcAMs9Yu6AK9caF76zoLWIWcfMGOSkVduvSWechqZsz040Ib2PY3urxBJTzriT95lipz+TN1fmAAAAeJxtkMl2wjAMRfOAhABlKm2h80C3+ajgCKKDY6cegP59TYBzukAL+z1Zsq8ctaJTTKPrsUQLbXQQI0EXKXroY4AbDDHCGBNMcYsZ7nCPB8yxwCOe8IwXvOIN7/jAJ76wxHfUqWX+OzgumWAjJMV17i0Ndlr6irLKO+qftdT7i6y4uFSUvCknay+lFYZIZaQcmfH/xIFdYn98bqhra1aKTM/6lWMnyaYirx1rFUQZFBkb2zJUtoXeJCeg0WnLtHeSFc3OtrnozNwqi0TkSpBMDB1nSde5oJXW23hTS2/T0LilglXX7dmFVxLnq5U0vYATHFk3zX3BOisoQHNDFDeZnqKDy9hRNawN7Vh727hFzcJ5c8TILrKZfH7tIPxAFP0BpLeJPA==) format("woff");font-weight:400;font-style:normal}.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.video-js .vjs-play-control .vjs-icon-placeholder,.vjs-icon-play{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.video-js .vjs-play-control .vjs-icon-placeholder:before,.vjs-icon-play:before{content:"\f101"}.vjs-icon-play-circle{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-play-circle:before{content:"\f102"}.video-js .vjs-play-control.vjs-playing .vjs-icon-placeholder,.vjs-icon-pause{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-play-control.vjs-playing .vjs-icon-placeholder:before,.vjs-icon-pause:before{content:"\f103"}.video-js .vjs-mute-control.vjs-vol-0 .vjs-icon-placeholder,.vjs-icon-volume-mute{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control.vjs-vol-0 .vjs-icon-placeholder:before,.vjs-icon-volume-mute:before{content:"\f104"}.video-js .vjs-mute-control.vjs-vol-1 .vjs-icon-placeholder,.vjs-icon-volume-low{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control.vjs-vol-1 .vjs-icon-placeholder:before,.vjs-icon-volume-low:before{content:"\f105"}.video-js .vjs-mute-control.vjs-vol-2 .vjs-icon-placeholder,.vjs-icon-volume-mid{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control.vjs-vol-2 .vjs-icon-placeholder:before,.vjs-icon-volume-mid:before{content:"\f106"}.video-js .vjs-mute-control .vjs-icon-placeholder,.vjs-icon-volume-high{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control .vjs-icon-placeholder:before,.vjs-icon-volume-high:before{content:"\f107"}.video-js .vjs-fullscreen-control .vjs-icon-placeholder,.vjs-icon-fullscreen-enter{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-fullscreen-control .vjs-icon-placeholder:before,.vjs-icon-fullscreen-enter:before{content:"\f108"}.video-js.vjs-fullscreen .vjs-fullscreen-control .vjs-icon-placeholder,.vjs-icon-fullscreen-exit{font-family:VideoJS;font-weight:400;font-style:normal}.video-js.vjs-fullscreen .vjs-fullscreen-control .vjs-icon-placeholder:before,.vjs-icon-fullscreen-exit:before{content:"\f109"}.vjs-icon-square{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-square:before{content:"\f10a"}.vjs-icon-spinner{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-spinner:before{content:"\f10b"}.video-js .vjs-subs-caps-button .vjs-icon-placeholder,.video-js .vjs-subtitles-button .vjs-icon-placeholder,.video-js.video-js:lang(en-AU) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js.video-js:lang(en-GB) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js.video-js:lang(en-IE) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js.video-js:lang(en-NZ) .vjs-subs-caps-button .vjs-icon-placeholder,.vjs-icon-subtitles{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js .vjs-subtitles-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-AU) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-GB) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-IE) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-NZ) .vjs-subs-caps-button .vjs-icon-placeholder:before,.vjs-icon-subtitles:before{content:"\f10c"}.video-js .vjs-captions-button .vjs-icon-placeholder,.video-js:lang(en) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js:lang(fr-CA) .vjs-subs-caps-button .vjs-icon-placeholder,.vjs-icon-captions{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-captions-button .vjs-icon-placeholder:before,.video-js:lang(en) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js:lang(fr-CA) .vjs-subs-caps-button .vjs-icon-placeholder:before,.vjs-icon-captions:before{content:"\f10d"}.video-js .vjs-chapters-button .vjs-icon-placeholder,.vjs-icon-chapters{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-chapters-button .vjs-icon-placeholder:before,.vjs-icon-chapters:before{content:"\f10e"}.vjs-icon-share{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-share:before{content:"\f10f"}.vjs-icon-cog{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-cog:before{content:"\f110"}.video-js .vjs-play-progress,.video-js .vjs-volume-level,.vjs-icon-circle,.vjs-seek-to-live-control .vjs-icon-placeholder{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-play-progress:before,.video-js .vjs-volume-level:before,.vjs-icon-circle:before,.vjs-seek-to-live-control .vjs-icon-placeholder:before{content:"\f111"}.vjs-icon-circle-outline{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-circle-outline:before{content:"\f112"}.vjs-icon-circle-inner-circle{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-circle-inner-circle:before{content:"\f113"}.vjs-icon-hd{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-hd:before{content:"\f114"}.video-js .vjs-control.vjs-close-button .vjs-icon-placeholder,.vjs-icon-cancel{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-control.vjs-close-button .vjs-icon-placeholder:before,.vjs-icon-cancel:before{content:"\f115"}.video-js .vjs-play-control.vjs-ended .vjs-icon-placeholder,.vjs-icon-replay{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-play-control.vjs-ended .vjs-icon-placeholder:before,.vjs-icon-replay:before{content:"\f116"}.vjs-icon-facebook{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-facebook:before{content:"\f117"}.vjs-icon-gplus{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-gplus:before{content:"\f118"}.vjs-icon-linkedin{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-linkedin:before{content:"\f119"}.vjs-icon-twitter{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-twitter:before{content:"\f11a"}.vjs-icon-tumblr{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-tumblr:before{content:"\f11b"}.vjs-icon-pinterest{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-pinterest:before{content:"\f11c"}.video-js .vjs-descriptions-button .vjs-icon-placeholder,.vjs-icon-audio-description{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-descriptions-button .vjs-icon-placeholder:before,.vjs-icon-audio-description:before{content:"\f11d"}.video-js .vjs-audio-button .vjs-icon-placeholder,.vjs-icon-audio{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-audio-button .vjs-icon-placeholder:before,.vjs-icon-audio:before{content:"\f11e"}.vjs-icon-next-item{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-next-item:before{content:"\f11f"}.vjs-icon-previous-item{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-previous-item:before{content:"\f120"}.video-js .vjs-picture-in-picture-control .vjs-icon-placeholder,.vjs-icon-picture-in-picture-enter{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-picture-in-picture-control .vjs-icon-placeholder:before,.vjs-icon-picture-in-picture-enter:before{content:"\f121"}.video-js.vjs-picture-in-picture .vjs-picture-in-picture-control .vjs-icon-placeholder,.vjs-icon-picture-in-picture-exit{font-family:VideoJS;font-weight:400;font-style:normal}.video-js.vjs-picture-in-picture .vjs-picture-in-picture-control .vjs-icon-placeholder:before,.vjs-icon-picture-in-picture-exit:before{content:"\f122"}.video-js{display:block;vertical-align:top;box-sizing:border-box;color:#fff;background-color:#000;position:relative;padding:0;font-size:10px;line-height:1;font-weight:400;font-style:normal;font-family:Arial,Helvetica,sans-serif;word-break:initial}.video-js:-moz-full-screen{position:absolute}.video-js:-webkit-full-screen{width:100%!important;height:100%!important}.video-js[tabindex="-1"]{outline:0}.video-js *,.video-js :after,.video-js :before{box-sizing:inherit}.video-js ul{font-family:inherit;font-size:inherit;line-height:inherit;list-style-position:outside;margin-left:0;margin-right:0;margin-top:0;margin-bottom:0}.video-js.vjs-16-9,.video-js.vjs-4-3,.video-js.vjs-fluid{width:100%;max-width:100%;height:0}.video-js.vjs-16-9{padding-top:56.25%}.video-js.vjs-4-3{padding-top:75%}.video-js.vjs-fill{width:100%;height:100%}.video-js .vjs-tech{position:absolute;top:0;left:0;width:100%;height:100%}body.vjs-full-window{padding:0;margin:0;height:100%}.vjs-full-window .video-js.vjs-fullscreen{position:fixed;overflow:hidden;z-index:1000;left:0;top:0;bottom:0;right:0}.video-js.vjs-fullscreen{width:100%!important;height:100%!important;padding-top:0!important}.video-js.vjs-fullscreen.vjs-user-inactive{cursor:none}.vjs-hidden{display:none!important}.vjs-disabled{opacity:.5;cursor:default}.video-js .vjs-offscreen{height:1px;left:-9999px;position:absolute;top:0;width:1px}.vjs-lock-showing{display:block!important;opacity:1;visibility:visible}.vjs-no-js{padding:20px;color:#fff;background-color:#000;font-size:18px;font-family:Arial,Helvetica,sans-serif;text-align:center;width:300px;height:150px;margin:0 auto}.vjs-no-js a,.vjs-no-js a:visited{color:#66a8cc}.video-js .vjs-big-play-button{font-size:3em;line-height:1.5em;height:1.63332em;width:3em;display:block;position:absolute;top:10px;left:10px;padding:0;cursor:pointer;opacity:1;border:.06666em solid #fff;background-color:#2b333f;background-color:rgba(43,51,63,.7);border-radius:.3em;transition:all .4s}.vjs-big-play-centered .vjs-big-play-button{top:50%;left:50%;margin-top:-.81666em;margin-left:-1.5em}.video-js .vjs-big-play-button:focus,.video-js:hover .vjs-big-play-button{border-color:#fff;background-color:#73859f;background-color:rgba(115,133,159,.5);transition:all 0s}.vjs-controls-disabled .vjs-big-play-button,.vjs-error .vjs-big-play-button,.vjs-has-started .vjs-big-play-button,.vjs-using-native-controls .vjs-big-play-button{display:none}.vjs-has-started.vjs-paused.vjs-show-big-play-button-on-pause .vjs-big-play-button{display:block}.video-js button{background:0 0;border:none;color:inherit;display:inline-block;font-size:inherit;line-height:inherit;text-transform:none;text-decoration:none;transition:none;-webkit-appearance:none;-moz-appearance:none;appearance:none}.vjs-control .vjs-button{width:100%;height:100%}.video-js .vjs-control.vjs-close-button{cursor:pointer;height:3em;position:absolute;right:0;top:.5em;z-index:2}.video-js .vjs-modal-dialog{background:rgba(0,0,0,.8);background:linear-gradient(180deg,rgba(0,0,0,.8),rgba(255,255,255,0));overflow:auto}.video-js .vjs-modal-dialog>*{box-sizing:border-box}.vjs-modal-dialog .vjs-modal-dialog-content{font-size:1.2em;line-height:1.5;padding:20px 24px;z-index:1}.vjs-menu-button{cursor:pointer}.vjs-menu-button.vjs-disabled{cursor:default}.vjs-workinghover .vjs-menu-button.vjs-disabled:hover .vjs-menu{display:none}.vjs-menu .vjs-menu-content{display:block;padding:0;margin:0;font-family:Arial,Helvetica,sans-serif;overflow:auto}.vjs-menu .vjs-menu-content>*{box-sizing:border-box}.vjs-scrubbing .vjs-control.vjs-menu-button:hover .vjs-menu{display:none}.vjs-menu li{list-style:none;margin:0;padding:.2em 0;line-height:1.4em;font-size:1.2em;text-align:center;text-transform:lowercase}.js-focus-visible .vjs-menu li.vjs-menu-item:hover,.vjs-menu li.vjs-menu-item:focus,.vjs-menu li.vjs-menu-item:hover{background-color:#73859f;background-color:rgba(115,133,159,.5)}.js-focus-visible .vjs-menu li.vjs-selected:hover,.vjs-menu li.vjs-selected,.vjs-menu li.vjs-selected:focus,.vjs-menu li.vjs-selected:hover{background-color:#fff;color:#2b333f}.vjs-menu li.vjs-menu-title{text-align:center;text-transform:uppercase;font-size:1em;line-height:2em;padding:0;margin:0 0 .3em 0;font-weight:700;cursor:default}.vjs-menu-button-popup .vjs-menu{display:none;position:absolute;bottom:0;width:10em;left:-3em;height:0;margin-bottom:1.5em;border-top-color:rgba(43,51,63,.7)}.vjs-menu-button-popup .vjs-menu .vjs-menu-content{background-color:#2b333f;background-color:rgba(43,51,63,.7);position:absolute;width:100%;bottom:1.5em;max-height:15em}.vjs-layout-tiny .vjs-menu-button-popup .vjs-menu .vjs-menu-content,.vjs-layout-x-small .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:5em}.vjs-layout-small .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:10em}.vjs-layout-medium .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:14em}.vjs-layout-huge .vjs-menu-button-popup .vjs-menu .vjs-menu-content,.vjs-layout-large .vjs-menu-button-popup .vjs-menu .vjs-menu-content,.vjs-layout-x-large .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:25em}.vjs-menu-button-popup .vjs-menu.vjs-lock-showing,.vjs-workinghover .vjs-menu-button-popup.vjs-hover .vjs-menu{display:block}.video-js .vjs-menu-button-inline{transition:all .4s;overflow:hidden}.video-js .vjs-menu-button-inline:before{width:2.222222222em}.video-js .vjs-menu-button-inline.vjs-slider-active,.video-js .vjs-menu-button-inline:focus,.video-js .vjs-menu-button-inline:hover,.video-js.vjs-no-flex .vjs-menu-button-inline{width:12em}.vjs-menu-button-inline .vjs-menu{opacity:0;height:100%;width:auto;position:absolute;left:4em;top:0;padding:0;margin:0;transition:all .4s}.vjs-menu-button-inline.vjs-slider-active .vjs-menu,.vjs-menu-button-inline:focus .vjs-menu,.vjs-menu-button-inline:hover .vjs-menu{display:block;opacity:1}.vjs-no-flex .vjs-menu-button-inline .vjs-menu{display:block;opacity:1;position:relative;width:auto}.vjs-no-flex .vjs-menu-button-inline.vjs-slider-active .vjs-menu,.vjs-no-flex .vjs-menu-button-inline:focus .vjs-menu,.vjs-no-flex .vjs-menu-button-inline:hover .vjs-menu{width:auto}.vjs-menu-button-inline .vjs-menu-content{width:auto;height:100%;margin:0;overflow:hidden}.video-js .vjs-control-bar{display:none;width:100%;position:absolute;bottom:0;left:0;right:0;height:3em;background-color:#2b333f;background-color:rgba(43,51,63,.7)}.vjs-has-started .vjs-control-bar{display:flex;visibility:visible;opacity:1;transition:visibility .1s,opacity .1s}.vjs-has-started.vjs-user-inactive.vjs-playing .vjs-control-bar{visibility:visible;opacity:0;transition:visibility 1s,opacity 1s}.vjs-controls-disabled .vjs-control-bar,.vjs-error .vjs-control-bar,.vjs-using-native-controls .vjs-control-bar{display:none!important}.vjs-audio.vjs-has-started.vjs-user-inactive.vjs-playing .vjs-control-bar{opacity:1;visibility:visible}.vjs-has-started.vjs-no-flex .vjs-control-bar{display:table}.video-js .vjs-control{position:relative;text-align:center;margin:0;padding:0;height:100%;width:4em;flex:none}.vjs-button>.vjs-icon-placeholder:before{font-size:1.8em;line-height:1.67}.video-js .vjs-control:focus,.video-js .vjs-control:focus:before,.video-js .vjs-control:hover:before{text-shadow:0 0 1em #fff}.video-js .vjs-control-text{border:0;clip:rect(0 0 0 0);height:1px;overflow:hidden;padding:0;position:absolute;width:1px}.vjs-no-flex .vjs-control{display:table-cell;vertical-align:middle}.video-js .vjs-custom-control-spacer{display:none}.video-js .vjs-progress-control{cursor:pointer;flex:auto;display:flex;align-items:center;min-width:4em;touch-action:none}.video-js .vjs-progress-control.disabled{cursor:default}.vjs-live .vjs-progress-control{display:none}.vjs-liveui .vjs-progress-control{display:flex;align-items:center}.vjs-no-flex .vjs-progress-control{width:auto}.video-js .vjs-progress-holder{flex:auto;transition:all .2s;height:.3em}.video-js .vjs-progress-control .vjs-progress-holder{margin:0 10px}.video-js .vjs-progress-control:hover .vjs-progress-holder{font-size:1.6666666667em}.video-js .vjs-progress-control:hover .vjs-progress-holder.disabled{font-size:1em}.video-js .vjs-progress-holder .vjs-load-progress,.video-js .vjs-progress-holder .vjs-load-progress div,.video-js .vjs-progress-holder .vjs-play-progress{position:absolute;display:block;height:100%;margin:0;padding:0;width:0}.video-js .vjs-play-progress{background-color:#fff}.video-js .vjs-play-progress:before{font-size:.9em;position:absolute;right:-.5em;top:-.3333333333em;z-index:1}.video-js .vjs-load-progress{background:rgba(115,133,159,.5)}.video-js .vjs-load-progress div{background:rgba(115,133,159,.75)}.video-js .vjs-time-tooltip{background-color:#fff;background-color:rgba(255,255,255,.8);border-radius:.3em;color:#000;float:right;font-family:Arial,Helvetica,sans-serif;font-size:1em;padding:6px 8px 8px 8px;pointer-events:none;position:absolute;top:-3.4em;visibility:hidden;z-index:1}.video-js .vjs-progress-holder:focus .vjs-time-tooltip{display:none}.video-js .vjs-progress-control:hover .vjs-progress-holder:focus .vjs-time-tooltip,.video-js .vjs-progress-control:hover .vjs-time-tooltip{display:block;font-size:.6em;visibility:visible}.video-js .vjs-progress-control.disabled:hover .vjs-time-tooltip{font-size:1em}.video-js .vjs-progress-control .vjs-mouse-display{display:none;position:absolute;width:1px;height:100%;background-color:#000;z-index:1}.vjs-no-flex .vjs-progress-control .vjs-mouse-display{z-index:0}.video-js .vjs-progress-control:hover .vjs-mouse-display{display:block}.video-js.vjs-user-inactive .vjs-progress-control .vjs-mouse-display{visibility:hidden;opacity:0;transition:visibility 1s,opacity 1s}.video-js.vjs-user-inactive.vjs-no-flex .vjs-progress-control .vjs-mouse-display{display:none}.vjs-mouse-display .vjs-time-tooltip{color:#fff;background-color:#000;background-color:rgba(0,0,0,.8)}.video-js .vjs-slider{position:relative;cursor:pointer;padding:0;margin:0 .45em 0 .45em;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:#73859f;background-color:rgba(115,133,159,.5)}.video-js .vjs-slider.disabled{cursor:default}.video-js .vjs-slider:focus{text-shadow:0 0 1em #fff;box-shadow:0 0 1em #fff}.video-js .vjs-mute-control{cursor:pointer;flex:none}.video-js .vjs-volume-control{cursor:pointer;margin-right:1em;display:flex}.video-js .vjs-volume-control.vjs-volume-horizontal{width:5em}.video-js .vjs-volume-panel .vjs-volume-control{visibility:visible;opacity:0;width:1px;height:1px;margin-left:-1px}.video-js .vjs-volume-panel{transition:width 1s}.video-js .vjs-volume-panel .vjs-volume-control.vjs-slider-active,.video-js .vjs-volume-panel .vjs-volume-control:active,.video-js .vjs-volume-panel.vjs-hover .vjs-mute-control~.vjs-volume-control,.video-js .vjs-volume-panel.vjs-hover .vjs-volume-control,.video-js .vjs-volume-panel:active .vjs-volume-control,.video-js .vjs-volume-panel:focus .vjs-volume-control{visibility:visible;opacity:1;position:relative;transition:visibility .1s,opacity .1s,height .1s,width .1s,left 0s,top 0s}.video-js .vjs-volume-panel .vjs-volume-control.vjs-slider-active.vjs-volume-horizontal,.video-js .vjs-volume-panel .vjs-volume-control:active.vjs-volume-horizontal,.video-js .vjs-volume-panel.vjs-hover .vjs-mute-control~.vjs-volume-control.vjs-volume-horizontal,.video-js .vjs-volume-panel.vjs-hover .vjs-volume-control.vjs-volume-horizontal,.video-js .vjs-volume-panel:active .vjs-volume-control.vjs-volume-horizontal,.video-js .vjs-volume-panel:focus .vjs-volume-control.vjs-volume-horizontal{width:5em;height:3em;margin-right:0}.video-js .vjs-volume-panel .vjs-volume-control.vjs-slider-active.vjs-volume-vertical,.video-js .vjs-volume-panel .vjs-volume-control:active.vjs-volume-vertical,.video-js .vjs-volume-panel.vjs-hover .vjs-mute-control~.vjs-volume-control.vjs-volume-vertical,.video-js .vjs-volume-panel.vjs-hover .vjs-volume-control.vjs-volume-vertical,.video-js .vjs-volume-panel:active .vjs-volume-control.vjs-volume-vertical,.video-js .vjs-volume-panel:focus .vjs-volume-control.vjs-volume-vertical{left:-3.5em;transition:left 0s}.video-js .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-hover,.video-js .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js .vjs-volume-panel.vjs-volume-panel-horizontal:active{width:10em;transition:width .1s}.video-js .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-mute-toggle-only{width:4em}.video-js .vjs-volume-panel .vjs-volume-control.vjs-volume-vertical{height:8em;width:3em;left:-3000em;transition:visibility 1s,opacity 1s,height 1s 1s,width 1s 1s,left 1s 1s,top 1s 1s}.video-js .vjs-volume-panel .vjs-volume-control.vjs-volume-horizontal{transition:visibility 1s,opacity 1s,height 1s 1s,width 1s,left 1s 1s,top 1s 1s}.video-js.vjs-no-flex .vjs-volume-panel .vjs-volume-control.vjs-volume-horizontal{width:5em;height:3em;visibility:visible;opacity:1;position:relative;transition:none}.video-js.vjs-no-flex .vjs-volume-control.vjs-volume-vertical,.video-js.vjs-no-flex .vjs-volume-panel .vjs-volume-control.vjs-volume-vertical{position:absolute;bottom:3em;left:.5em}.video-js .vjs-volume-panel{display:flex}.video-js .vjs-volume-bar{margin:1.35em .45em}.vjs-volume-bar.vjs-slider-horizontal{width:5em;height:.3em}.vjs-volume-bar.vjs-slider-vertical{width:.3em;height:5em;margin:1.35em auto}.video-js .vjs-volume-level{position:absolute;bottom:0;left:0;background-color:#fff}.video-js .vjs-volume-level:before{position:absolute;font-size:.9em}.vjs-slider-vertical .vjs-volume-level{width:.3em}.vjs-slider-vertical .vjs-volume-level:before{top:-.5em;left:-.3em}.vjs-slider-horizontal .vjs-volume-level{height:.3em}.vjs-slider-horizontal .vjs-volume-level:before{top:-.3em;right:-.5em}.video-js .vjs-volume-panel.vjs-volume-panel-vertical{width:4em}.vjs-volume-bar.vjs-slider-vertical .vjs-volume-level{height:100%}.vjs-volume-bar.vjs-slider-horizontal .vjs-volume-level{width:100%}.video-js .vjs-volume-vertical{width:3em;height:8em;bottom:8em;background-color:#2b333f;background-color:rgba(43,51,63,.7)}.video-js .vjs-volume-horizontal .vjs-menu{left:-2em}.vjs-poster{display:inline-block;vertical-align:middle;background-repeat:no-repeat;background-position:50% 50%;background-size:contain;background-color:#000;cursor:pointer;margin:0;padding:0;position:absolute;top:0;right:0;bottom:0;left:0;height:100%}.vjs-has-started .vjs-poster{display:none}.vjs-audio.vjs-has-started .vjs-poster{display:block}.vjs-using-native-controls .vjs-poster{display:none}.video-js .vjs-live-control{display:flex;align-items:flex-start;flex:auto;font-size:1em;line-height:3em}.vjs-no-flex .vjs-live-control{display:table-cell;width:auto;text-align:left}.video-js.vjs-liveui .vjs-live-control,.video-js:not(.vjs-live) .vjs-live-control{display:none}.video-js .vjs-seek-to-live-control{cursor:pointer;flex:none;display:inline-flex;height:100%;padding-left:.5em;padding-right:.5em;font-size:1em;line-height:3em;width:auto;min-width:4em}.vjs-no-flex .vjs-seek-to-live-control{display:table-cell;width:auto;text-align:left}.video-js.vjs-live:not(.vjs-liveui) .vjs-seek-to-live-control,.video-js:not(.vjs-live) .vjs-seek-to-live-control{display:none}.vjs-seek-to-live-control.vjs-control.vjs-at-live-edge{cursor:auto}.vjs-seek-to-live-control .vjs-icon-placeholder{margin-right:.5em;color:#888}.vjs-seek-to-live-control.vjs-control.vjs-at-live-edge .vjs-icon-placeholder{color:red}.video-js .vjs-time-control{flex:none;font-size:1em;line-height:3em;min-width:2em;width:auto;padding-left:1em;padding-right:1em}.vjs-live .vjs-time-control{display:none}.video-js .vjs-current-time,.vjs-no-flex .vjs-current-time{display:none}.video-js .vjs-duration,.vjs-no-flex .vjs-duration{display:none}.vjs-time-divider{display:none;line-height:3em}.vjs-live .vjs-time-divider{display:none}.video-js .vjs-play-control{cursor:pointer}.video-js .vjs-play-control .vjs-icon-placeholder{flex:none}.vjs-text-track-display{position:absolute;bottom:3em;left:0;right:0;top:0;pointer-events:none}.video-js.vjs-user-inactive.vjs-playing .vjs-text-track-display{bottom:1em}.video-js .vjs-text-track{font-size:1.4em;text-align:center;margin-bottom:.1em}.vjs-subtitles{color:#fff}.vjs-captions{color:#fc6}.vjs-tt-cue{display:block}video::-webkit-media-text-track-display{transform:translateY(-3em)}.video-js.vjs-user-inactive.vjs-playing video::-webkit-media-text-track-display{transform:translateY(-1.5em)}.video-js .vjs-picture-in-picture-control{cursor:pointer;flex:none}.video-js .vjs-fullscreen-control{cursor:pointer;flex:none}.vjs-playback-rate .vjs-playback-rate-value,.vjs-playback-rate>.vjs-menu-button{position:absolute;top:0;left:0;width:100%;height:100%}.vjs-playback-rate .vjs-playback-rate-value{pointer-events:none;font-size:1.5em;line-height:2;text-align:center}.vjs-playback-rate .vjs-menu{width:4em;left:0}.vjs-error .vjs-error-display .vjs-modal-dialog-content{font-size:1.4em;text-align:center}.vjs-error .vjs-error-display:before{color:#fff;content:"X";font-family:Arial,Helvetica,sans-serif;font-size:4em;left:0;line-height:1;margin-top:-.5em;position:absolute;text-shadow:.05em .05em .1em #000;text-align:center;top:50%;vertical-align:middle;width:100%}.vjs-loading-spinner{display:none;position:absolute;top:50%;left:50%;margin:-25px 0 0 -25px;opacity:.85;text-align:left;border:6px solid rgba(43,51,63,.7);box-sizing:border-box;background-clip:padding-box;width:50px;height:50px;border-radius:25px;visibility:hidden}.vjs-seeking .vjs-loading-spinner,.vjs-waiting .vjs-loading-spinner{display:block;-webkit-animation:vjs-spinner-show 0s linear .3s forwards;animation:vjs-spinner-show 0s linear .3s forwards}.vjs-loading-spinner:after,.vjs-loading-spinner:before{content:"";position:absolute;margin:-6px;box-sizing:inherit;width:inherit;height:inherit;border-radius:inherit;opacity:1;border:inherit;border-color:transparent;border-top-color:#fff}.vjs-seeking .vjs-loading-spinner:after,.vjs-seeking .vjs-loading-spinner:before,.vjs-waiting .vjs-loading-spinner:after,.vjs-waiting .vjs-loading-spinner:before{-webkit-animation:vjs-spinner-spin 1.1s cubic-bezier(.6,.2,0,.8) infinite,vjs-spinner-fade 1.1s linear infinite;animation:vjs-spinner-spin 1.1s cubic-bezier(.6,.2,0,.8) infinite,vjs-spinner-fade 1.1s linear infinite}.vjs-seeking .vjs-loading-spinner:before,.vjs-waiting .vjs-loading-spinner:before{border-top-color:#fff}.vjs-seeking .vjs-loading-spinner:after,.vjs-waiting .vjs-loading-spinner:after{border-top-color:#fff;-webkit-animation-delay:.44s;animation-delay:.44s}@keyframes vjs-spinner-show{to{visibility:visible}}@-webkit-keyframes vjs-spinner-show{to{visibility:visible}}@keyframes vjs-spinner-spin{100%{transform:rotate(360deg)}}@-webkit-keyframes vjs-spinner-spin{100%{-webkit-transform:rotate(360deg)}}@keyframes vjs-spinner-fade{0%{border-top-color:#73859f}20%{border-top-color:#73859f}35%{border-top-color:#fff}60%{border-top-color:#73859f}100%{border-top-color:#73859f}}@-webkit-keyframes vjs-spinner-fade{0%{border-top-color:#73859f}20%{border-top-color:#73859f}35%{border-top-color:#fff}60%{border-top-color:#73859f}100%{border-top-color:#73859f}}.vjs-chapters-button .vjs-menu ul{width:24em}.video-js .vjs-subs-caps-button+.vjs-menu .vjs-captions-menu-item .vjs-menu-item-text .vjs-icon-placeholder{vertical-align:middle;display:inline-block;margin-bottom:-.1em}.video-js .vjs-subs-caps-button+.vjs-menu .vjs-captions-menu-item .vjs-menu-item-text .vjs-icon-placeholder:before{font-family:VideoJS;content:"";font-size:1.5em;line-height:inherit}.video-js .vjs-audio-button+.vjs-menu .vjs-main-desc-menu-item .vjs-menu-item-text .vjs-icon-placeholder{vertical-align:middle;display:inline-block;margin-bottom:-.1em}.video-js .vjs-audio-button+.vjs-menu .vjs-main-desc-menu-item .vjs-menu-item-text .vjs-icon-placeholder:before{font-family:VideoJS;content:" ";font-size:1.5em;line-height:inherit}.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-audio-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-captions-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-chapters-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-current-time,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-descriptions-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-duration,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-playback-rate,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-remaining-time,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-subtitles-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-time-divider,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-control,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-audio-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-captions-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-chapters-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-current-time,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-descriptions-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-duration,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-playback-rate,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-remaining-time,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-subtitles-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-time-divider,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-control,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-audio-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-captions-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-chapters-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-current-time,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-descriptions-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-duration,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-playback-rate,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-remaining-time,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-subtitles-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-time-divider,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-control{display:none}.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-panel.vjs-volume-panel-horizontal:active,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-panel.vjs-volume-panel-horizontal:hover,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-panel.vjs-volume-panel-horizontal:active,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-panel.vjs-volume-panel-horizontal:hover,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-panel.vjs-volume-panel-horizontal:active,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-panel.vjs-volume-panel-horizontal:hover{width:auto;width:initial}.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-subs-caps-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small:not(.vjs-live) .vjs-subs-caps-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small:not(.vjs-liveui) .vjs-subs-caps-button{display:none}.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-custom-control-spacer,.video-js:not(.vjs-fullscreen).vjs-layout-x-small.vjs-liveui .vjs-custom-control-spacer{flex:auto;display:block}.video-js:not(.vjs-fullscreen).vjs-layout-tiny.vjs-no-flex .vjs-custom-control-spacer,.video-js:not(.vjs-fullscreen).vjs-layout-x-small.vjs-liveui.vjs-no-flex .vjs-custom-control-spacer{width:auto}.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-progress-control,.video-js:not(.vjs-fullscreen).vjs-layout-x-small.vjs-liveui .vjs-progress-control{display:none}.vjs-modal-dialog.vjs-text-track-settings{background-color:#2b333f;background-color:rgba(43,51,63,.75);color:#fff;height:70%}.vjs-text-track-settings .vjs-modal-dialog-content{display:table}.vjs-text-track-settings .vjs-track-settings-colors,.vjs-text-track-settings .vjs-track-settings-controls,.vjs-text-track-settings .vjs-track-settings-font{display:table-cell}.vjs-text-track-settings .vjs-track-settings-controls{text-align:right;vertical-align:bottom}@supports (display:grid){.vjs-text-track-settings .vjs-modal-dialog-content{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr;padding:20px 24px 0 24px}.vjs-track-settings-controls .vjs-default-button{margin-bottom:20px}.vjs-text-track-settings .vjs-track-settings-controls{grid-column:1/-1}.vjs-layout-small .vjs-text-track-settings .vjs-modal-dialog-content,.vjs-layout-tiny .vjs-text-track-settings .vjs-modal-dialog-content,.vjs-layout-x-small .vjs-text-track-settings .vjs-modal-dialog-content{grid-template-columns:1fr}}.vjs-track-setting>select{margin-right:1em;margin-bottom:.5em}.vjs-text-track-settings fieldset{margin:5px;padding:3px;border:none}.vjs-text-track-settings fieldset span{display:inline-block}.vjs-text-track-settings fieldset span>select{max-width:7.3em}.vjs-text-track-settings legend{color:#fff;margin:0 0 5px 0}.vjs-text-track-settings .vjs-label{position:absolute;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px);display:block;margin:0 0 5px 0;padding:0;border:0;height:1px;width:1px;overflow:hidden}.vjs-track-settings-controls button:active,.vjs-track-settings-controls button:focus{outline-style:solid;outline-width:medium;background-image:linear-gradient(0deg,#fff 88%,#73859f 100%)}.vjs-track-settings-controls button:hover{color:rgba(43,51,63,.75)}.vjs-track-settings-controls button{background-color:#fff;background-image:linear-gradient(-180deg,#fff 88%,#73859f 100%);color:#2b333f;cursor:pointer;border-radius:2px}.vjs-track-settings-controls .vjs-default-button{margin-right:1em}@media print{.video-js>:not(.vjs-tech):not(.vjs-poster){visibility:hidden}}.vjs-resize-manager{position:absolute;top:0;left:0;width:100%;height:100%;border:none;z-index:-1000}.js-focus-visible .video-js :focus:not(.focus-visible){outline:0;background:0 0}.video-js .vjs-menu :focus:not(:focus-visible),.video-js :focus:not(:focus-visible){outline:0;background:0 0} \ No newline at end of file +@charset "UTF-8";.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.video-js .vjs-modal-dialog,.vjs-button>.vjs-icon-placeholder:before,.vjs-modal-dialog .vjs-modal-dialog-content{position:absolute;top:0;left:0;width:100%;height:100%}.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.vjs-button>.vjs-icon-placeholder:before{text-align:center}@font-face{font-family:VideoJS;src:url(data:application/font-woff;charset=utf-8;base64,d09GRgABAAAAABDkAAsAAAAAG6gAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABHU1VCAAABCAAAADsAAABUIIslek9TLzIAAAFEAAAAPgAAAFZRiV3hY21hcAAAAYQAAADaAAADPv749/pnbHlmAAACYAAAC3AAABHQZg6OcWhlYWQAAA3QAAAAKwAAADYZw251aGhlYQAADfwAAAAdAAAAJA+RCLFobXR4AAAOHAAAABMAAACM744AAGxvY2EAAA4wAAAASAAAAEhF6kqubWF4cAAADngAAAAfAAAAIAE0AIFuYW1lAAAOmAAAASUAAAIK1cf1oHBvc3QAAA/AAAABJAAAAdPExYuNeJxjYGRgYOBiMGCwY2BycfMJYeDLSSzJY5BiYGGAAJA8MpsxJzM9kYEDxgPKsYBpDiBmg4gCACY7BUgAeJxjYGS7wTiBgZWBgaWQ5RkDA8MvCM0cwxDOeI6BgYmBlZkBKwhIc01hcPjI+FGJHcRdyA4RZgQRADK3CxEAAHic7dFZbsMgAEXRS0ycyZnnOeG7y+qC8pU1dHusIOXxuoxaOlwZYWQB0Aea4quIEN4E9LzKbKjzDeM6H/mua6Lmc/p8yhg0lvdYx15ZG8uOLQOGjMp3EzqmzJizYMmKNRu27Nhz4MiJMxeu3Ljz4Ekqm7T8P52G8PP3lnTOVk++Z6iN6QZzNN1F7ptuN7eGOjDUoaGODHVsuvU8MdTO9Hd5aqgzQ50b6sJQl4a6MtS1oW4MdWuoO0PdG+rBUI+GejLUs6FeDPVqqDdDvRvqw1CfhpqM9At0iFLaAAB4nJ1YDXBTVRZ+5/22TUlJ8we0pHlJm7RJf5O8F2j6EymlSPkpxaL8U2xpa3DKj0CBhc2IW4eWKSokIoLsuMqssM64f+jA4HSdWXXXscBq67IOs3FXZ1ZYWVyRFdo899yXtIBQZ90k7717zz3v3HPPOfd854YCCj9cL9dL0RQFOqCbGJnrHb5EayiKIWN8iA/hWBblo6hUWm8TtCDwE80WMJus/irwyxOdxeB0MDb14VNJHnXYoLLSl6FfCUYO9nYPTA8Epg9090LprfbBbZ2hY0UlJUXHQp3/vtWkS6EBv8+rPMq5u9692f/dNxJNiqwC1xPE9TCUgCsSdQWgE3XQD25lkG4CN2xmTcOXWBOyser6RN6KnGbKSbmQ3+d0OI1m2W8QzLLkI2sykrWAgJJEtA8vGGW/2Q+CmT3n8zS9wZwu2DCvtuZKZN3xkrLh36yCZuUomQSqGpY8t/25VfHVhw8z4ebGBtfLb0ya9PCaDc+8dGTvk2dsh6z7WzvowlXKUSWo9MJ15a3KrEP2loOr2Ojhw6iW6hf2BDdEccQvZGpaAy7YovSwq8kr7HGllxpd71rkS6G0Sf11sl9OvMK1+jwPPODxjUwkOim9CU3ix1wNjXDfmJSEn618Bs6lpWwUpU+8PCqLMY650zjq8VhCIP17NEKTx3eaLL+s5Pi6yJWaWjTHLR1jYzPSV9VF/6Ojdb/1kO3Mk3uhHC0x6gc1BjlKQ+nQFxTYdaJkZ7ySVxLBbhR1dsboNXp1tCYKW2LRaEzpYcIx2BKNxaL0ZaUnSqfFoiNhHKR/GkX6PWUSAaJelQaqZL1EpoHNsajSEyPSoJ9IjhIxTdjHLmwZvhRDOiFTY/YeQnvrVZmiTQtGncECXtFTBZLOVwwMRgoXHAkXzMzPn1nAJJ8jYSbMDaqN2waGLzNhih/bZynUBMpIWSg7VYi7DRx2m8ALkIdRCJwI6ArJx2EI8kaDWeTQKeAFk9fjl/1AvwktjQ1P7NjyMGQyfd4vjipX6M/i52D7Cq80kqlcxEcGXRr/FEcgs0u5uGgB4VWuMFfpdn2Re6Hi3PqzmxWKsz6+ae2Pn9hXXw/fqM859UiGC0oKYYILJBqJrsn1Z1E5qOs9rQCiUQRREjm8yJcbHF5cUJufX1vAHlefw0XgUoboS3ETfQlTxBC4SOtuE8VPRJTBSCQSjZCpk7Gqzu+masaZ2y7Zjehho4F3g82BNDkAHpORG4+OCS+f6JTPmtRn/PH1kch6d04sp7AQb25aQ/pqUyXeQ8vrebG8OYQdXOQ+585u0sdW9rqalzRURiJ+9F4MweRFrKUjl1GUYhH1A27WOHw5cTFSFPMo9EeUIGnQTZHIaJ7AHLaOKsOODaNF9jkBjYG2QEsQ2xjMUAx2bBEbeTBWMHwskBjngq56S/yfgkBnWBa4K9sqKtq2t1UI8S9He5XuBRbawAdatrQEAi30Aks2+LM8WeCbalVZkWNylvJ+dqJnzVb+OHlSoKW8nPCP7Rd+CcZ2DdWAGqJ2CBFOphgywFFCFBNtfAbGtNPBCwxvygHeYMZMY9ZboBqwq/pVrsbgN5tkv152ODlbMfiqwGMBgxa4Exz3QhovRIUp6acqZmQzRq0ypDXS2TPLT02YIkQETnOE445oOGxOmXAqUJNNG7XgupMjPq2ua9asrj5yY/yuKteO1Kx0YNJTufrirLe1mZnat7OL6rnUdCWenpW6I8mAnbsY8KWs1PuSovCW9A/Z25PQ24a7cNOqgmTkLmBMgh4THgc4b9k2IVv1/g/F5nGljwPLfOgHAzJzh45V/4+WenTzmMtR5Z7us2Tys909UHqrPY7KbckoxRvRHhmVc3cJGE97uml0R1S0jdULVl7EvZtDFVBF35N9cEdjpgmAiOlFZ+Dtoh93+D3zzHr8RRNZQhnCNMNbcegOvpEwZoL+06cJQ07h+th3fZ/7PVbVC6ngTAV/KoLFuO6+2KFcU651gEb5ugPSIb1D+Xp8V4+k3sEIGnw5mYe4If4k1lFYr6SCzmM2EQ8iWtmwjnBI9kTwe1TlfAmXh7H02by9fW2gsjKwtv0aaURKil4OdV7rDL1MXIFNrhdxohcZXYTnq47WisrKitaObbf5+yvkLi5J6lCNZZ+B6GC38VNBZBDidSS/+mSvh6s+srgC8pyKMvDtt+de3c9fU76ZPfuM8ud4Kv0fyP/LqfepMT/3oZxSqpZaTa1DaQYLY8TFsHYbWYsPoRhRWfL5eSSQbhUGgGC3YLbVMk6PitTFNGpAsNrC6D1VNBKgBHMejaiuRWEWGgsSDBTJjqWIl8kJLlsaLJ2tXDr6xGfT85bM2Q06a46x2HTgvdnV8z5YDy/27J4zt6x2VtkzjoYpkq36kaBr4eQSg7tyiVweWubXZugtadl58ydapfbORfKsDTuZ0OBgx4cfdjCf5tbWNITnL120fdOi1RV1C3uKGzNdwYLcMvZ3BxoPyTOCD1XvXTp7U10gWCVmTV9b3r2z0SkGWovb2hp9I89O8a2smlyaO8muMU+dRmtzp60IzAoFpjLr1n388boLyf0dRvxhsHZ0qbWqDkwqvvpkj4l0fY6EIXRi5sQSrAvsVYwXRy4qJ2EVtD1AN7a0HWth9ymvL1xc3WTUKK/TAHA/bXDVtVWfOMfuGxGZv4Ln/jVr9jc3j1yMv0tndmyt9Vq88Y9gH1wtLX3KWjot5++jWHgAoZZkQ14wGQ20Fli71UmKJAy4xKMSTGbVdybW7FDDAut9XpD5AzWrYO7zQ8qffqF8+Ynd/clrHcdyxGy3a/3+mfNnzC/cBsveTjnTvXf1o6vzOlZw7WtqtdmPK/Errz/6NNtD72zmNOZfbmYdTGHfoofqI79Oc+R2n1lrnL6pOm0Up7kwxhTW12Amm7WYkXR2qYrF2AmgmbAsxZjwy1xpg/m1Je2vrp8v/nz2xpmlBg4E9hrMU341wVpTOh/OfmGvAnra8q6uctr60ZQHV3Q+WMQJykMj8ZsWn2QBOmmHMB+m5pDIpTFonYigiaKAhGEiAHF7EliVnQkjoLVIMPtJpBKHYd3A8GYH9jJzrWwmHx5Qjp7vDAX0suGRym1vtm/9W1/HyR8vczfMs6Sk8DSv855/5dlX9oQq52hT8syyp2rx5Id17IAyAM3wIjQPMOHzytEB64q6D5zT91yNbnx3V/nqnd017S9Y0605k3izoXLpsxde2n38yoOV9s1LcjwzNjbdX6asnBVaBj/6/DwKwPkpcqbDG7BnsXoSqWnUAmottYF6jMSdVyYZh3zVXCjwTiwwHH6sGuRiEHQGzuRX6whZkp123oy1BWE2mEfJ/tvIRtM4ZM5bDXiMsPMaAKOTyc5uL57rqyyc5y5JE5pm1i2S2iUX0CcaQ6lC6Zog7JqSqZmYlosl2K6pwNA84zRnQW6SaALYZQGW5lhCtU/W34N6o+bKfZ8cf3/Cl/+iTX3wBzpOY4mRkeNf3rptycGSshQWgGbYt5jFc2e0+DglIrwl6DVWQ7BuwaJ3Xk1J4VL5urnLl/Wf+gHU/hZoZdKNym6lG+I34FaNeZKcSpJIo2IeCVvpdsDGfKvzJnAwmeD37Ow65ZWwSowpgwX5T69s/rB55dP5BcpgDKFV8p7q2sn/1uc93bVzT/w6UrCqDTWvfCq/oCD/qZXNoUj8BL5Kp6GU017frfNXkAtiiyf/SOCEeLqnd8R/Ql9GlCRfctS6k5chvIBuQ1zCCjoCHL2DHNHIXxMJ3kQeO8lbsUXONeSfA5EjcG6/E+KdhN4bP04vBhdi883+BFBzQbxFbvZzQeY9LNBZc0FNfn5NwfDn6rCTnTw6R8o+gfpf5hCom33cRuiTlss3KHmZjD+BPN+5gXuA2ziS/Q73mLxUkpbKN/eqwz5uK0X9F3h2d1V4nGNgZGBgAOJd776+iue3+crAzc4AAje5Bfcg0xz9YHEOBiYQBQA8FQlFAHicY2BkYGBnAAGOPgaG//85+hkYGVCBMgBGGwNYAAAAeJxjYGBgYB8EmKOPgQEAQ04BfgAAAAAAAA4AaAB+AMwA4AECAUIBbAGYAcICGAJYArQC4AMwA7AD3gQwBJYE3AUkBWYFigYgBmYGtAbqB1gIEghYCG4IhAi2COh4nGNgZGBgUGYoZWBnAAEmIOYCQgaG/2A+AwAYCQG2AHicXZBNaoNAGIZfE5PQCKFQ2lUps2oXBfOzzAESyDKBQJdGR2NQR3QSSE/QE/QEPUUPUHqsvsrXjTMw83zPvPMNCuAWP3DQDAejdm1GjzwS7pMmwi75XngAD4/CQ/oX4TFe4Qt7uMMbOzjuDc0EmXCP/C7cJ38Iu+RP4QEe8CU8pP8WHmOPX2EPz87TPo202ey2OjlnQSXV/6arOjWFmvszMWtd6CqwOlKHq6ovycLaWMWVydXKFFZnmVFlZU46tP7R2nI5ncbi/dDkfDtFBA2DDXbYkhKc+V0Bqs5Zt9JM1HQGBRTm/EezTmZNKtpcAMs9Yu6AK9caF76zoLWIWcfMGOSkVduvSWechqZsz040Ib2PY3urxBJTzriT95lipz+TN1fmAAAAeJxtkMl2wjAMRfOAhABlKm2h80C3+ajgCKKDY6cegP59TYBzukAL+z1Zsq8ctaJTTKPrsUQLbXQQI0EXKXroY4AbDDHCGBNMcYsZ7nCPB8yxwCOe8IwXvOIN7/jAJ76wxHfUqWX+OzgumWAjJMV17i0Ndlr6irLKO+qftdT7i6y4uFSUvCknay+lFYZIZaQcmfH/xIFdYn98bqhra1aKTM/6lWMnyaYirx1rFUQZFBkb2zJUtoXeJCeg0WnLtHeSFc3OtrnozNwqi0TkSpBMDB1nSde5oJXW23hTS2/T0LilglXX7dmFVxLnq5U0vYATHFk3zX3BOisoQHNDFDeZnqKDy9hRNawN7Vh727hFzcJ5c8TILrKZfH7tIPxAFP0BpLeJPA==) format("woff");font-weight:400;font-style:normal}.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.video-js .vjs-play-control .vjs-icon-placeholder,.vjs-icon-play{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.video-js .vjs-play-control .vjs-icon-placeholder:before,.vjs-icon-play:before{content:"\f101"}.vjs-icon-play-circle{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-play-circle:before{content:"\f102"}.video-js .vjs-play-control.vjs-playing .vjs-icon-placeholder,.vjs-icon-pause{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-play-control.vjs-playing .vjs-icon-placeholder:before,.vjs-icon-pause:before{content:"\f103"}.video-js .vjs-mute-control.vjs-vol-0 .vjs-icon-placeholder,.vjs-icon-volume-mute{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control.vjs-vol-0 .vjs-icon-placeholder:before,.vjs-icon-volume-mute:before{content:"\f104"}.video-js .vjs-mute-control.vjs-vol-1 .vjs-icon-placeholder,.vjs-icon-volume-low{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control.vjs-vol-1 .vjs-icon-placeholder:before,.vjs-icon-volume-low:before{content:"\f105"}.video-js .vjs-mute-control.vjs-vol-2 .vjs-icon-placeholder,.vjs-icon-volume-mid{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control.vjs-vol-2 .vjs-icon-placeholder:before,.vjs-icon-volume-mid:before{content:"\f106"}.video-js .vjs-mute-control .vjs-icon-placeholder,.vjs-icon-volume-high{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control .vjs-icon-placeholder:before,.vjs-icon-volume-high:before{content:"\f107"}.video-js .vjs-fullscreen-control .vjs-icon-placeholder,.vjs-icon-fullscreen-enter{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-fullscreen-control .vjs-icon-placeholder:before,.vjs-icon-fullscreen-enter:before{content:"\f108"}.video-js.vjs-fullscreen .vjs-fullscreen-control .vjs-icon-placeholder,.vjs-icon-fullscreen-exit{font-family:VideoJS;font-weight:400;font-style:normal}.video-js.vjs-fullscreen .vjs-fullscreen-control .vjs-icon-placeholder:before,.vjs-icon-fullscreen-exit:before{content:"\f109"}.vjs-icon-square{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-square:before{content:"\f10a"}.vjs-icon-spinner{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-spinner:before{content:"\f10b"}.video-js .vjs-subs-caps-button .vjs-icon-placeholder,.video-js .vjs-subtitles-button .vjs-icon-placeholder,.video-js.video-js:lang(en-AU) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js.video-js:lang(en-GB) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js.video-js:lang(en-IE) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js.video-js:lang(en-NZ) .vjs-subs-caps-button .vjs-icon-placeholder,.vjs-icon-subtitles{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js .vjs-subtitles-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-AU) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-GB) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-IE) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-NZ) .vjs-subs-caps-button .vjs-icon-placeholder:before,.vjs-icon-subtitles:before{content:"\f10c"}.video-js .vjs-captions-button .vjs-icon-placeholder,.video-js:lang(en) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js:lang(fr-CA) .vjs-subs-caps-button .vjs-icon-placeholder,.vjs-icon-captions{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-captions-button .vjs-icon-placeholder:before,.video-js:lang(en) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js:lang(fr-CA) .vjs-subs-caps-button .vjs-icon-placeholder:before,.vjs-icon-captions:before{content:"\f10d"}.video-js .vjs-chapters-button .vjs-icon-placeholder,.vjs-icon-chapters{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-chapters-button .vjs-icon-placeholder:before,.vjs-icon-chapters:before{content:"\f10e"}.vjs-icon-share{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-share:before{content:"\f10f"}.vjs-icon-cog{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-cog:before{content:"\f110"}.video-js .vjs-play-progress,.video-js .vjs-volume-level,.vjs-icon-circle,.vjs-seek-to-live-control .vjs-icon-placeholder{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-play-progress:before,.video-js .vjs-volume-level:before,.vjs-icon-circle:before,.vjs-seek-to-live-control .vjs-icon-placeholder:before{content:"\f111"}.vjs-icon-circle-outline{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-circle-outline:before{content:"\f112"}.vjs-icon-circle-inner-circle{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-circle-inner-circle:before{content:"\f113"}.vjs-icon-hd{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-hd:before{content:"\f114"}.video-js .vjs-control.vjs-close-button .vjs-icon-placeholder,.vjs-icon-cancel{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-control.vjs-close-button .vjs-icon-placeholder:before,.vjs-icon-cancel:before{content:"\f115"}.video-js .vjs-play-control.vjs-ended .vjs-icon-placeholder,.vjs-icon-replay{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-play-control.vjs-ended .vjs-icon-placeholder:before,.vjs-icon-replay:before{content:"\f116"}.vjs-icon-facebook{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-facebook:before{content:"\f117"}.vjs-icon-gplus{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-gplus:before{content:"\f118"}.vjs-icon-linkedin{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-linkedin:before{content:"\f119"}.vjs-icon-twitter{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-twitter:before{content:"\f11a"}.vjs-icon-tumblr{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-tumblr:before{content:"\f11b"}.vjs-icon-pinterest{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-pinterest:before{content:"\f11c"}.video-js .vjs-descriptions-button .vjs-icon-placeholder,.vjs-icon-audio-description{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-descriptions-button .vjs-icon-placeholder:before,.vjs-icon-audio-description:before{content:"\f11d"}.video-js .vjs-audio-button .vjs-icon-placeholder,.vjs-icon-audio{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-audio-button .vjs-icon-placeholder:before,.vjs-icon-audio:before{content:"\f11e"}.vjs-icon-next-item{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-next-item:before{content:"\f11f"}.vjs-icon-previous-item{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-previous-item:before{content:"\f120"}.video-js .vjs-picture-in-picture-control .vjs-icon-placeholder,.vjs-icon-picture-in-picture-enter{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-picture-in-picture-control .vjs-icon-placeholder:before,.vjs-icon-picture-in-picture-enter:before{content:"\f121"}.video-js.vjs-picture-in-picture .vjs-picture-in-picture-control .vjs-icon-placeholder,.vjs-icon-picture-in-picture-exit{font-family:VideoJS;font-weight:400;font-style:normal}.video-js.vjs-picture-in-picture .vjs-picture-in-picture-control .vjs-icon-placeholder:before,.vjs-icon-picture-in-picture-exit:before{content:"\f122"}.video-js{display:block;vertical-align:top;box-sizing:border-box;color:#fff;background-color:#000;position:relative;padding:0;font-size:10px;line-height:1;font-weight:400;font-style:normal;font-family:Arial,Helvetica,sans-serif;word-break:initial}.video-js:-moz-full-screen{position:absolute}.video-js:-webkit-full-screen{width:100%!important;height:100%!important}.video-js[tabindex="-1"]{outline:0}.video-js *,.video-js :after,.video-js :before{box-sizing:inherit}.video-js ul{font-family:inherit;font-size:inherit;line-height:inherit;list-style-position:outside;margin-left:0;margin-right:0;margin-top:0;margin-bottom:0}.video-js.vjs-16-9,.video-js.vjs-4-3,.video-js.vjs-fluid{width:100%;max-width:100%;height:0}.video-js.vjs-16-9{padding-top:56.25%}.video-js.vjs-4-3{padding-top:75%}.video-js.vjs-fill{width:100%;height:100%}.video-js .vjs-tech{position:absolute;top:0;left:0;width:100%;height:100%}body.vjs-full-window{padding:0;margin:0;height:100%}.vjs-full-window .video-js.vjs-fullscreen{position:fixed;overflow:hidden;z-index:1000;left:0;top:0;bottom:0;right:0}.video-js.vjs-fullscreen:not(.vjs-ios-native-fs){width:100%!important;height:100%!important;padding-top:0!important}.video-js.vjs-fullscreen.vjs-user-inactive{cursor:none}.vjs-hidden{display:none!important}.vjs-disabled{opacity:.5;cursor:default}.video-js .vjs-offscreen{height:1px;left:-9999px;position:absolute;top:0;width:1px}.vjs-lock-showing{display:block!important;opacity:1;visibility:visible}.vjs-no-js{padding:20px;color:#fff;background-color:#000;font-size:18px;font-family:Arial,Helvetica,sans-serif;text-align:center;width:300px;height:150px;margin:0 auto}.vjs-no-js a,.vjs-no-js a:visited{color:#66a8cc}.video-js .vjs-big-play-button{font-size:3em;line-height:1.5em;height:1.63332em;width:3em;display:block;position:absolute;top:10px;left:10px;padding:0;cursor:pointer;opacity:1;border:.06666em solid #fff;background-color:#2b333f;background-color:rgba(43,51,63,.7);border-radius:.3em;transition:all .4s}.vjs-big-play-centered .vjs-big-play-button{top:50%;left:50%;margin-top:-.81666em;margin-left:-1.5em}.video-js .vjs-big-play-button:focus,.video-js:hover .vjs-big-play-button{border-color:#fff;background-color:#73859f;background-color:rgba(115,133,159,.5);transition:all 0s}.vjs-controls-disabled .vjs-big-play-button,.vjs-error .vjs-big-play-button,.vjs-has-started .vjs-big-play-button,.vjs-using-native-controls .vjs-big-play-button{display:none}.vjs-has-started.vjs-paused.vjs-show-big-play-button-on-pause .vjs-big-play-button{display:block}.video-js button{background:0 0;border:none;color:inherit;display:inline-block;font-size:inherit;line-height:inherit;text-transform:none;text-decoration:none;transition:none;-webkit-appearance:none;-moz-appearance:none;appearance:none}.vjs-control .vjs-button{width:100%;height:100%}.video-js .vjs-control.vjs-close-button{cursor:pointer;height:3em;position:absolute;right:0;top:.5em;z-index:2}.video-js .vjs-modal-dialog{background:rgba(0,0,0,.8);background:linear-gradient(180deg,rgba(0,0,0,.8),rgba(255,255,255,0));overflow:auto}.video-js .vjs-modal-dialog>*{box-sizing:border-box}.vjs-modal-dialog .vjs-modal-dialog-content{font-size:1.2em;line-height:1.5;padding:20px 24px;z-index:1}.vjs-menu-button{cursor:pointer}.vjs-menu-button.vjs-disabled{cursor:default}.vjs-workinghover .vjs-menu-button.vjs-disabled:hover .vjs-menu{display:none}.vjs-menu .vjs-menu-content{display:block;padding:0;margin:0;font-family:Arial,Helvetica,sans-serif;overflow:auto}.vjs-menu .vjs-menu-content>*{box-sizing:border-box}.vjs-scrubbing .vjs-control.vjs-menu-button:hover .vjs-menu{display:none}.vjs-menu li{list-style:none;margin:0;padding:.2em 0;line-height:1.4em;font-size:1.2em;text-align:center;text-transform:lowercase}.js-focus-visible .vjs-menu li.vjs-menu-item:hover,.vjs-menu li.vjs-menu-item:focus,.vjs-menu li.vjs-menu-item:hover{background-color:#73859f;background-color:rgba(115,133,159,.5)}.js-focus-visible .vjs-menu li.vjs-selected:hover,.vjs-menu li.vjs-selected,.vjs-menu li.vjs-selected:focus,.vjs-menu li.vjs-selected:hover{background-color:#fff;color:#2b333f}.vjs-menu li.vjs-menu-title{text-align:center;text-transform:uppercase;font-size:1em;line-height:2em;padding:0;margin:0 0 .3em 0;font-weight:700;cursor:default}.vjs-menu-button-popup .vjs-menu{display:none;position:absolute;bottom:0;width:10em;left:-3em;height:0;margin-bottom:1.5em;border-top-color:rgba(43,51,63,.7)}.vjs-menu-button-popup .vjs-menu .vjs-menu-content{background-color:#2b333f;background-color:rgba(43,51,63,.7);position:absolute;width:100%;bottom:1.5em;max-height:15em}.vjs-layout-tiny .vjs-menu-button-popup .vjs-menu .vjs-menu-content,.vjs-layout-x-small .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:5em}.vjs-layout-small .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:10em}.vjs-layout-medium .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:14em}.vjs-layout-huge .vjs-menu-button-popup .vjs-menu .vjs-menu-content,.vjs-layout-large .vjs-menu-button-popup .vjs-menu .vjs-menu-content,.vjs-layout-x-large .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:25em}.vjs-menu-button-popup .vjs-menu.vjs-lock-showing,.vjs-workinghover .vjs-menu-button-popup.vjs-hover .vjs-menu{display:block}.video-js .vjs-menu-button-inline{transition:all .4s;overflow:hidden}.video-js .vjs-menu-button-inline:before{width:2.222222222em}.video-js .vjs-menu-button-inline.vjs-slider-active,.video-js .vjs-menu-button-inline:focus,.video-js .vjs-menu-button-inline:hover,.video-js.vjs-no-flex .vjs-menu-button-inline{width:12em}.vjs-menu-button-inline .vjs-menu{opacity:0;height:100%;width:auto;position:absolute;left:4em;top:0;padding:0;margin:0;transition:all .4s}.vjs-menu-button-inline.vjs-slider-active .vjs-menu,.vjs-menu-button-inline:focus .vjs-menu,.vjs-menu-button-inline:hover .vjs-menu{display:block;opacity:1}.vjs-no-flex .vjs-menu-button-inline .vjs-menu{display:block;opacity:1;position:relative;width:auto}.vjs-no-flex .vjs-menu-button-inline.vjs-slider-active .vjs-menu,.vjs-no-flex .vjs-menu-button-inline:focus .vjs-menu,.vjs-no-flex .vjs-menu-button-inline:hover .vjs-menu{width:auto}.vjs-menu-button-inline .vjs-menu-content{width:auto;height:100%;margin:0;overflow:hidden}.video-js .vjs-control-bar{display:none;width:100%;position:absolute;bottom:0;left:0;right:0;height:3em;background-color:#2b333f;background-color:rgba(43,51,63,.7)}.vjs-has-started .vjs-control-bar{display:flex;visibility:visible;opacity:1;transition:visibility .1s,opacity .1s}.vjs-has-started.vjs-user-inactive.vjs-playing .vjs-control-bar{visibility:visible;opacity:0;transition:visibility 1s,opacity 1s}.vjs-controls-disabled .vjs-control-bar,.vjs-error .vjs-control-bar,.vjs-using-native-controls .vjs-control-bar{display:none!important}.vjs-audio.vjs-has-started.vjs-user-inactive.vjs-playing .vjs-control-bar{opacity:1;visibility:visible}.vjs-has-started.vjs-no-flex .vjs-control-bar{display:table}.video-js .vjs-control{position:relative;text-align:center;margin:0;padding:0;height:100%;width:4em;flex:none}.vjs-button>.vjs-icon-placeholder:before{font-size:1.8em;line-height:1.67}.video-js .vjs-control:focus,.video-js .vjs-control:focus:before,.video-js .vjs-control:hover:before{text-shadow:0 0 1em #fff}.video-js .vjs-control-text{border:0;clip:rect(0 0 0 0);height:1px;overflow:hidden;padding:0;position:absolute;width:1px}.vjs-no-flex .vjs-control{display:table-cell;vertical-align:middle}.video-js .vjs-custom-control-spacer{display:none}.video-js .vjs-progress-control{cursor:pointer;flex:auto;display:flex;align-items:center;min-width:4em;touch-action:none}.video-js .vjs-progress-control.disabled{cursor:default}.vjs-live .vjs-progress-control{display:none}.vjs-liveui .vjs-progress-control{display:flex;align-items:center}.vjs-no-flex .vjs-progress-control{width:auto}.video-js .vjs-progress-holder{flex:auto;transition:all .2s;height:.3em}.video-js .vjs-progress-control .vjs-progress-holder{margin:0 10px}.video-js .vjs-progress-control:hover .vjs-progress-holder{font-size:1.6666666667em}.video-js .vjs-progress-control:hover .vjs-progress-holder.disabled{font-size:1em}.video-js .vjs-progress-holder .vjs-load-progress,.video-js .vjs-progress-holder .vjs-load-progress div,.video-js .vjs-progress-holder .vjs-play-progress{position:absolute;display:block;height:100%;margin:0;padding:0;width:0}.video-js .vjs-play-progress{background-color:#fff}.video-js .vjs-play-progress:before{font-size:.9em;position:absolute;right:-.5em;top:-.3333333333em;z-index:1}.video-js .vjs-load-progress{background:rgba(115,133,159,.5)}.video-js .vjs-load-progress div{background:rgba(115,133,159,.75)}.video-js .vjs-time-tooltip{background-color:#fff;background-color:rgba(255,255,255,.8);border-radius:.3em;color:#000;float:right;font-family:Arial,Helvetica,sans-serif;font-size:1em;padding:6px 8px 8px 8px;pointer-events:none;position:absolute;top:-3.4em;visibility:hidden;z-index:1}.video-js .vjs-progress-holder:focus .vjs-time-tooltip{display:none}.video-js .vjs-progress-control:hover .vjs-progress-holder:focus .vjs-time-tooltip,.video-js .vjs-progress-control:hover .vjs-time-tooltip{display:block;font-size:.6em;visibility:visible}.video-js .vjs-progress-control.disabled:hover .vjs-time-tooltip{font-size:1em}.video-js .vjs-progress-control .vjs-mouse-display{display:none;position:absolute;width:1px;height:100%;background-color:#000;z-index:1}.vjs-no-flex .vjs-progress-control .vjs-mouse-display{z-index:0}.video-js .vjs-progress-control:hover .vjs-mouse-display{display:block}.video-js.vjs-user-inactive .vjs-progress-control .vjs-mouse-display{visibility:hidden;opacity:0;transition:visibility 1s,opacity 1s}.video-js.vjs-user-inactive.vjs-no-flex .vjs-progress-control .vjs-mouse-display{display:none}.vjs-mouse-display .vjs-time-tooltip{color:#fff;background-color:#000;background-color:rgba(0,0,0,.8)}.video-js .vjs-slider{position:relative;cursor:pointer;padding:0;margin:0 .45em 0 .45em;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:#73859f;background-color:rgba(115,133,159,.5)}.video-js .vjs-slider.disabled{cursor:default}.video-js .vjs-slider:focus{text-shadow:0 0 1em #fff;box-shadow:0 0 1em #fff}.video-js .vjs-mute-control{cursor:pointer;flex:none}.video-js .vjs-volume-control{cursor:pointer;margin-right:1em;display:flex}.video-js .vjs-volume-control.vjs-volume-horizontal{width:5em}.video-js .vjs-volume-panel .vjs-volume-control{visibility:visible;opacity:0;width:1px;height:1px;margin-left:-1px}.video-js .vjs-volume-panel{transition:width 1s}.video-js .vjs-volume-panel .vjs-volume-control.vjs-slider-active,.video-js .vjs-volume-panel .vjs-volume-control:active,.video-js .vjs-volume-panel.vjs-hover .vjs-mute-control~.vjs-volume-control,.video-js .vjs-volume-panel.vjs-hover .vjs-volume-control,.video-js .vjs-volume-panel:active .vjs-volume-control,.video-js .vjs-volume-panel:focus .vjs-volume-control{visibility:visible;opacity:1;position:relative;transition:visibility .1s,opacity .1s,height .1s,width .1s,left 0s,top 0s}.video-js .vjs-volume-panel .vjs-volume-control.vjs-slider-active.vjs-volume-horizontal,.video-js .vjs-volume-panel .vjs-volume-control:active.vjs-volume-horizontal,.video-js .vjs-volume-panel.vjs-hover .vjs-mute-control~.vjs-volume-control.vjs-volume-horizontal,.video-js .vjs-volume-panel.vjs-hover .vjs-volume-control.vjs-volume-horizontal,.video-js .vjs-volume-panel:active .vjs-volume-control.vjs-volume-horizontal,.video-js .vjs-volume-panel:focus .vjs-volume-control.vjs-volume-horizontal{width:5em;height:3em;margin-right:0}.video-js .vjs-volume-panel .vjs-volume-control.vjs-slider-active.vjs-volume-vertical,.video-js .vjs-volume-panel .vjs-volume-control:active.vjs-volume-vertical,.video-js .vjs-volume-panel.vjs-hover .vjs-mute-control~.vjs-volume-control.vjs-volume-vertical,.video-js .vjs-volume-panel.vjs-hover .vjs-volume-control.vjs-volume-vertical,.video-js .vjs-volume-panel:active .vjs-volume-control.vjs-volume-vertical,.video-js .vjs-volume-panel:focus .vjs-volume-control.vjs-volume-vertical{left:-3.5em;transition:left 0s}.video-js .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-hover,.video-js .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js .vjs-volume-panel.vjs-volume-panel-horizontal:active{width:10em;transition:width .1s}.video-js .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-mute-toggle-only{width:4em}.video-js .vjs-volume-panel .vjs-volume-control.vjs-volume-vertical{height:8em;width:3em;left:-3000em;transition:visibility 1s,opacity 1s,height 1s 1s,width 1s 1s,left 1s 1s,top 1s 1s}.video-js .vjs-volume-panel .vjs-volume-control.vjs-volume-horizontal{transition:visibility 1s,opacity 1s,height 1s 1s,width 1s,left 1s 1s,top 1s 1s}.video-js.vjs-no-flex .vjs-volume-panel .vjs-volume-control.vjs-volume-horizontal{width:5em;height:3em;visibility:visible;opacity:1;position:relative;transition:none}.video-js.vjs-no-flex .vjs-volume-control.vjs-volume-vertical,.video-js.vjs-no-flex .vjs-volume-panel .vjs-volume-control.vjs-volume-vertical{position:absolute;bottom:3em;left:.5em}.video-js .vjs-volume-panel{display:flex}.video-js .vjs-volume-bar{margin:1.35em .45em}.vjs-volume-bar.vjs-slider-horizontal{width:5em;height:.3em}.vjs-volume-bar.vjs-slider-vertical{width:.3em;height:5em;margin:1.35em auto}.video-js .vjs-volume-level{position:absolute;bottom:0;left:0;background-color:#fff}.video-js .vjs-volume-level:before{position:absolute;font-size:.9em}.vjs-slider-vertical .vjs-volume-level{width:.3em}.vjs-slider-vertical .vjs-volume-level:before{top:-.5em;left:-.3em}.vjs-slider-horizontal .vjs-volume-level{height:.3em}.vjs-slider-horizontal .vjs-volume-level:before{top:-.3em;right:-.5em}.video-js .vjs-volume-panel.vjs-volume-panel-vertical{width:4em}.vjs-volume-bar.vjs-slider-vertical .vjs-volume-level{height:100%}.vjs-volume-bar.vjs-slider-horizontal .vjs-volume-level{width:100%}.video-js .vjs-volume-vertical{width:3em;height:8em;bottom:8em;background-color:#2b333f;background-color:rgba(43,51,63,.7)}.video-js .vjs-volume-horizontal .vjs-menu{left:-2em}.vjs-poster{display:inline-block;vertical-align:middle;background-repeat:no-repeat;background-position:50% 50%;background-size:contain;background-color:#000;cursor:pointer;margin:0;padding:0;position:absolute;top:0;right:0;bottom:0;left:0;height:100%}.vjs-has-started .vjs-poster{display:none}.vjs-audio.vjs-has-started .vjs-poster{display:block}.vjs-using-native-controls .vjs-poster{display:none}.video-js .vjs-live-control{display:flex;align-items:flex-start;flex:auto;font-size:1em;line-height:3em}.vjs-no-flex .vjs-live-control{display:table-cell;width:auto;text-align:left}.video-js.vjs-liveui .vjs-live-control,.video-js:not(.vjs-live) .vjs-live-control{display:none}.video-js .vjs-seek-to-live-control{cursor:pointer;flex:none;display:inline-flex;height:100%;padding-left:.5em;padding-right:.5em;font-size:1em;line-height:3em;width:auto;min-width:4em}.vjs-no-flex .vjs-seek-to-live-control{display:table-cell;width:auto;text-align:left}.video-js.vjs-live:not(.vjs-liveui) .vjs-seek-to-live-control,.video-js:not(.vjs-live) .vjs-seek-to-live-control{display:none}.vjs-seek-to-live-control.vjs-control.vjs-at-live-edge{cursor:auto}.vjs-seek-to-live-control .vjs-icon-placeholder{margin-right:.5em;color:#888}.vjs-seek-to-live-control.vjs-control.vjs-at-live-edge .vjs-icon-placeholder{color:red}.video-js .vjs-time-control{flex:none;font-size:1em;line-height:3em;min-width:2em;width:auto;padding-left:1em;padding-right:1em}.vjs-live .vjs-time-control{display:none}.video-js .vjs-current-time,.vjs-no-flex .vjs-current-time{display:none}.video-js .vjs-duration,.vjs-no-flex .vjs-duration{display:none}.vjs-time-divider{display:none;line-height:3em}.vjs-live .vjs-time-divider{display:none}.video-js .vjs-play-control{cursor:pointer}.video-js .vjs-play-control .vjs-icon-placeholder{flex:none}.vjs-text-track-display{position:absolute;bottom:3em;left:0;right:0;top:0;pointer-events:none}.video-js.vjs-user-inactive.vjs-playing .vjs-text-track-display{bottom:1em}.video-js .vjs-text-track{font-size:1.4em;text-align:center;margin-bottom:.1em}.vjs-subtitles{color:#fff}.vjs-captions{color:#fc6}.vjs-tt-cue{display:block}video::-webkit-media-text-track-display{transform:translateY(-3em)}.video-js.vjs-user-inactive.vjs-playing video::-webkit-media-text-track-display{transform:translateY(-1.5em)}.video-js .vjs-picture-in-picture-control{cursor:pointer;flex:none}.video-js .vjs-fullscreen-control{cursor:pointer;flex:none}.vjs-playback-rate .vjs-playback-rate-value,.vjs-playback-rate>.vjs-menu-button{position:absolute;top:0;left:0;width:100%;height:100%}.vjs-playback-rate .vjs-playback-rate-value{pointer-events:none;font-size:1.5em;line-height:2;text-align:center}.vjs-playback-rate .vjs-menu{width:4em;left:0}.vjs-error .vjs-error-display .vjs-modal-dialog-content{font-size:1.4em;text-align:center}.vjs-error .vjs-error-display:before{color:#fff;content:"X";font-family:Arial,Helvetica,sans-serif;font-size:4em;left:0;line-height:1;margin-top:-.5em;position:absolute;text-shadow:.05em .05em .1em #000;text-align:center;top:50%;vertical-align:middle;width:100%}.vjs-loading-spinner{display:none;position:absolute;top:50%;left:50%;margin:-25px 0 0 -25px;opacity:.85;text-align:left;border:6px solid rgba(43,51,63,.7);box-sizing:border-box;background-clip:padding-box;width:50px;height:50px;border-radius:25px;visibility:hidden}.vjs-seeking .vjs-loading-spinner,.vjs-waiting .vjs-loading-spinner{display:block;-webkit-animation:vjs-spinner-show 0s linear .3s forwards;animation:vjs-spinner-show 0s linear .3s forwards}.vjs-loading-spinner:after,.vjs-loading-spinner:before{content:"";position:absolute;margin:-6px;box-sizing:inherit;width:inherit;height:inherit;border-radius:inherit;opacity:1;border:inherit;border-color:transparent;border-top-color:#fff}.vjs-seeking .vjs-loading-spinner:after,.vjs-seeking .vjs-loading-spinner:before,.vjs-waiting .vjs-loading-spinner:after,.vjs-waiting .vjs-loading-spinner:before{-webkit-animation:vjs-spinner-spin 1.1s cubic-bezier(.6,.2,0,.8) infinite,vjs-spinner-fade 1.1s linear infinite;animation:vjs-spinner-spin 1.1s cubic-bezier(.6,.2,0,.8) infinite,vjs-spinner-fade 1.1s linear infinite}.vjs-seeking .vjs-loading-spinner:before,.vjs-waiting .vjs-loading-spinner:before{border-top-color:#fff}.vjs-seeking .vjs-loading-spinner:after,.vjs-waiting .vjs-loading-spinner:after{border-top-color:#fff;-webkit-animation-delay:.44s;animation-delay:.44s}@keyframes vjs-spinner-show{to{visibility:visible}}@-webkit-keyframes vjs-spinner-show{to{visibility:visible}}@keyframes vjs-spinner-spin{100%{transform:rotate(360deg)}}@-webkit-keyframes vjs-spinner-spin{100%{-webkit-transform:rotate(360deg)}}@keyframes vjs-spinner-fade{0%{border-top-color:#73859f}20%{border-top-color:#73859f}35%{border-top-color:#fff}60%{border-top-color:#73859f}100%{border-top-color:#73859f}}@-webkit-keyframes vjs-spinner-fade{0%{border-top-color:#73859f}20%{border-top-color:#73859f}35%{border-top-color:#fff}60%{border-top-color:#73859f}100%{border-top-color:#73859f}}.vjs-chapters-button .vjs-menu ul{width:24em}.video-js .vjs-subs-caps-button+.vjs-menu .vjs-captions-menu-item .vjs-menu-item-text .vjs-icon-placeholder{vertical-align:middle;display:inline-block;margin-bottom:-.1em}.video-js .vjs-subs-caps-button+.vjs-menu .vjs-captions-menu-item .vjs-menu-item-text .vjs-icon-placeholder:before{font-family:VideoJS;content:"";font-size:1.5em;line-height:inherit}.video-js .vjs-audio-button+.vjs-menu .vjs-main-desc-menu-item .vjs-menu-item-text .vjs-icon-placeholder{vertical-align:middle;display:inline-block;margin-bottom:-.1em}.video-js .vjs-audio-button+.vjs-menu .vjs-main-desc-menu-item .vjs-menu-item-text .vjs-icon-placeholder:before{font-family:VideoJS;content:" ";font-size:1.5em;line-height:inherit}.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-audio-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-captions-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-chapters-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-current-time,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-descriptions-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-duration,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-playback-rate,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-remaining-time,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-subtitles-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-time-divider,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-control,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-audio-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-captions-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-chapters-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-current-time,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-descriptions-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-duration,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-playback-rate,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-remaining-time,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-subtitles-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-time-divider,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-control,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-audio-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-captions-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-chapters-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-current-time,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-descriptions-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-duration,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-playback-rate,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-remaining-time,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-subtitles-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-time-divider,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-control{display:none}.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-panel.vjs-volume-panel-horizontal:active,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-panel.vjs-volume-panel-horizontal:hover,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-panel.vjs-volume-panel-horizontal:active,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-panel.vjs-volume-panel-horizontal:hover,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-panel.vjs-volume-panel-horizontal:active,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-panel.vjs-volume-panel-horizontal:hover{width:auto;width:initial}.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-subs-caps-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small:not(.vjs-live) .vjs-subs-caps-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small:not(.vjs-liveui) .vjs-subs-caps-button{display:none}.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-custom-control-spacer,.video-js:not(.vjs-fullscreen).vjs-layout-x-small.vjs-liveui .vjs-custom-control-spacer{flex:auto;display:block}.video-js:not(.vjs-fullscreen).vjs-layout-tiny.vjs-no-flex .vjs-custom-control-spacer,.video-js:not(.vjs-fullscreen).vjs-layout-x-small.vjs-liveui.vjs-no-flex .vjs-custom-control-spacer{width:auto}.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-progress-control,.video-js:not(.vjs-fullscreen).vjs-layout-x-small.vjs-liveui .vjs-progress-control{display:none}.vjs-modal-dialog.vjs-text-track-settings{background-color:#2b333f;background-color:rgba(43,51,63,.75);color:#fff;height:70%}.vjs-text-track-settings .vjs-modal-dialog-content{display:table}.vjs-text-track-settings .vjs-track-settings-colors,.vjs-text-track-settings .vjs-track-settings-controls,.vjs-text-track-settings .vjs-track-settings-font{display:table-cell}.vjs-text-track-settings .vjs-track-settings-controls{text-align:right;vertical-align:bottom}@supports (display:grid){.vjs-text-track-settings .vjs-modal-dialog-content{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr;padding:20px 24px 0 24px}.vjs-track-settings-controls .vjs-default-button{margin-bottom:20px}.vjs-text-track-settings .vjs-track-settings-controls{grid-column:1/-1}.vjs-layout-small .vjs-text-track-settings .vjs-modal-dialog-content,.vjs-layout-tiny .vjs-text-track-settings .vjs-modal-dialog-content,.vjs-layout-x-small .vjs-text-track-settings .vjs-modal-dialog-content{grid-template-columns:1fr}}.vjs-track-setting>select{margin-right:1em;margin-bottom:.5em}.vjs-text-track-settings fieldset{margin:5px;padding:3px;border:none}.vjs-text-track-settings fieldset span{display:inline-block}.vjs-text-track-settings fieldset span>select{max-width:7.3em}.vjs-text-track-settings legend{color:#fff;margin:0 0 5px 0}.vjs-text-track-settings .vjs-label{position:absolute;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px);display:block;margin:0 0 5px 0;padding:0;border:0;height:1px;width:1px;overflow:hidden}.vjs-track-settings-controls button:active,.vjs-track-settings-controls button:focus{outline-style:solid;outline-width:medium;background-image:linear-gradient(0deg,#fff 88%,#73859f 100%)}.vjs-track-settings-controls button:hover{color:rgba(43,51,63,.75)}.vjs-track-settings-controls button{background-color:#fff;background-image:linear-gradient(-180deg,#fff 88%,#73859f 100%);color:#2b333f;cursor:pointer;border-radius:2px}.vjs-track-settings-controls .vjs-default-button{margin-right:1em}@media print{.video-js>:not(.vjs-tech):not(.vjs-poster){visibility:hidden}}.vjs-resize-manager{position:absolute;top:0;left:0;width:100%;height:100%;border:none;z-index:-1000}.js-focus-visible .video-js :focus:not(.focus-visible){outline:0;background:0 0}.video-js .vjs-menu :focus:not(:focus-visible),.video-js :focus:not(:focus-visible){outline:0;background:0 0} \ No newline at end of file diff --git a/assets/js/global.js b/assets/js/global.js deleted file mode 100644 index efb447fb..00000000 --- a/assets/js/global.js +++ /dev/null @@ -1,3 +0,0 @@ -// Disable Web Workers. Fixes Video.js CSP violation (created by `new Worker(objURL)`): -// Refused to create a worker from 'blob:http://host/id' because it violates the following Content Security Policy directive: "worker-src 'self'". -window.Worker = undefined; diff --git a/assets/js/video.min.js b/assets/js/video.min.js index ed10c74e..890a3c84 100644 --- a/assets/js/video.min.js +++ b/assets/js/video.min.js @@ -1,6 +1,6 @@ /** * @license - * Video.js 7.6.6 + * Video.js 7.10.2 * Copyright Brightcove, Inc. * Available under Apache License Version 2.0 * @@ -9,13 +9,18 @@ * Available under Apache License Version 2.0 * */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("global/window"),require("global/document")):"function"==typeof define&&define.amd?define(["global/window","global/document"],t):(e=e||self).videojs=t(e.window,e.document)}(this,function(v,h){v=v&&v.hasOwnProperty("default")?v.default:v,h=h&&h.hasOwnProperty("default")?h.default:h;var d="7.6.6",u=[],e=function(s,o){return function(e,t,i){var n=o.levels[t],r=new RegExp("^("+n+")$");if("log"!==e&&i.unshift(e.toUpperCase()+":"),i.unshift(s+":"),u&&u.push([].concat(i)),v.console){var a=v.console[e];a||"debug"!==e||(a=v.console.info||v.console.log),a&&n&&r.test(e)&&a[Array.isArray(i)?"apply":"call"](v.console,i)}}};var p=function t(i){function n(){for(var e=arguments.length,t=new Array(e),i=0;i',i=n.firstChild,n.setAttribute("style","display:none; position:absolute;"),h.body.appendChild(n));for(var a={},s=0;sx',e=t.firstChild.href}return e}function Mt(e){if("string"==typeof e){var t=/^(\/?)([\s\S]*?)((?:\.{1,2}|[^\/]+?)(\.([^\.\/\?]+)))(?:[\/]*|[\?].*)$/.exec(e);if(t)return t.pop().toLowerCase()}return""}function Nt(e){var t=v.location,i=Ut(e);return(":"===i.protocol?t.protocol:i.protocol)+i.host!==t.protocol+t.host}var Bt=function(n){function e(e){var t;void 0===e&&(e=[]);for(var i=e.length-1;0<=i;i--)if(e[i].enabled){xt(e,e[i]);break}return(t=n.call(this,e)||this).changing_=!1,t}De(e,n);var t=e.prototype;return t.addTrack=function(e){var t=this;e.enabled&&xt(this,e),n.prototype.addTrack.call(this,e),e.addEventListener&&(e.enabledChange_=function(){t.changing_||(t.changing_=!0,xt(t,e),t.changing_=!1,t.trigger("change"))},e.addEventListener("enabledchange",e.enabledChange_))},t.removeTrack=function(e){n.prototype.removeTrack.call(this,e),e.removeEventListener&&e.enabledChange_&&(e.removeEventListener("enabledchange",e.enabledChange_),e.enabledChange_=null)},e}(Lt),jt=function(n){function e(e){var t;void 0===e&&(e=[]);for(var i=e.length-1;0<=i;i--)if(e[i].selected){Dt(e,e[i]);break}return(t=n.call(this,e)||this).changing_=!1,Object.defineProperty(Me(t),"selectedIndex",{get:function(){for(var e=0;e>0},ToUint32:function(e){return this.ToNumber(e)>>>0},ToUint16:function(e){var t=this.ToNumber(e);return zi(t)||0===t||!Gi(t)?0:function(e,t){var i=e%t;return Math.floor(0<=i?i:i+t)}(Pi(t)*Math.floor(Math.abs(t)),65536)},ToString:function(e){return nn(e)},ToObject:function(e){return this.CheckObjectCoercible(e),en(e)},CheckObjectCoercible:function(e,t){if(null==e)throw new tn(t||"Cannot call method on "+e);return e},IsCallable:Li,SameValue:function(e,t){return e===t?0!==e||1/e==1/t:zi(e)&&zi(t)},Type:function(e){return null===e?"Null":"undefined"==typeof e?"Undefined":"function"==typeof e||"object"==typeof e?"Object":"number"==typeof e?"Number":"boolean"==typeof e?"Boolean":"string"==typeof e?"String":void 0},IsPropertyDescriptor:function(e){if("Object"!==this.Type(e))return!1;var t={"[[Configurable]]":!0,"[[Enumerable]]":!0,"[[Get]]":!0,"[[Set]]":!0,"[[Value]]":!0,"[[Writable]]":!0};for(var i in e)if(Hi(e,i)&&!t[i])return!1;var n=Hi(e,"[[Value]]"),r=Hi(e,"[[Get]]")||Hi(e,"[[Set]]");if(n&&r)throw new tn("Property Descriptors may not be both accessor and data descriptors");return!0},IsAccessorDescriptor:function(e){return"undefined"!=typeof e&&(Ai(this,"Property Descriptor","Desc",e),!(!Hi(e,"[[Get]]")&&!Hi(e,"[[Set]]")))},IsDataDescriptor:function(e){return"undefined"!=typeof e&&(Ai(this,"Property Descriptor","Desc",e),!(!Hi(e,"[[Value]]")&&!Hi(e,"[[Writable]]")))},IsGenericDescriptor:function(e){return"undefined"!=typeof e&&(Ai(this,"Property Descriptor","Desc",e),!this.IsAccessorDescriptor(e)&&!this.IsDataDescriptor(e))},FromPropertyDescriptor:function(e){if("undefined"==typeof e)return e;if(Ai(this,"Property Descriptor","Desc",e),this.IsDataDescriptor(e))return{value:e["[[Value]]"],writable:!!e["[[Writable]]"],enumerable:!!e["[[Enumerable]]"],configurable:!!e["[[Configurable]]"]};if(this.IsAccessorDescriptor(e))return{get:e["[[Get]]"],set:e["[[Set]]"],enumerable:!!e["[[Enumerable]]"],configurable:!!e["[[Configurable]]"]};throw new tn("FromPropertyDescriptor must be called with a fully populated Property Descriptor")},ToPropertyDescriptor:function(e){if("Object"!==this.Type(e))throw new tn("ToPropertyDescriptor requires an object");var t={};if(Hi(e,"enumerable")&&(t["[[Enumerable]]"]=this.ToBoolean(e.enumerable)),Hi(e,"configurable")&&(t["[[Configurable]]"]=this.ToBoolean(e.configurable)),Hi(e,"value")&&(t["[[Value]]"]=e.value),Hi(e,"writable")&&(t["[[Writable]]"]=this.ToBoolean(e.writable)),Hi(e,"get")){var i=e.get;if("undefined"!=typeof i&&!this.IsCallable(i))throw new TypeError("getter must be a function");t["[[Get]]"]=i}if(Hi(e,"set")){var n=e.set;if("undefined"!=typeof n&&!this.IsCallable(n))throw new tn("setter must be a function");t["[[Set]]"]=n}if((Hi(t,"[[Get]]")||Hi(t,"[[Set]]"))&&(Hi(t,"[[Value]]")||Hi(t,"[[Writable]]")))throw new tn("Invalid property descriptor. Cannot both specify accessors and a value or writable attribute");return t}},an=ti.call(Function.call,String.prototype.replace),sn=/^[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]+/,on=/[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]+$/,un=ti.call(Function.call,Di());Ri(un,{getPolyfill:Di,implementation:xi,shim:function(){var e=Di();return Ri(String.prototype,{trim:e},{trim:function(){return String.prototype.trim!==e}}),e}});var ln=un,cn=Object.prototype.toString,hn=Object.prototype.hasOwnProperty,dn=function(e,t,i){if(!Li(t))throw new TypeError("iterator must be a function");var n;3<=arguments.length&&(n=i),"[object Array]"===cn.call(e)?function(e,t,i){for(var n=0,r=e.length;n=e?t.push(r):r.startTime===r.endTime&&r.startTime<=e&&r.startTime+.5>=e&&t.push(r)}if(o=!1,t.length!==this.activeCues_.length)o=!0;else for(var a=0;a","‎":"‎","‏":"‏"," ":" "},Mn={c:"span",i:"i",b:"b",u:"u",ruby:"ruby",rt:"rt",v:"span",lang:"span"},Nn={v:"title",lang:"lang"},Bn={rt:"ruby"};function jn(a,i){function e(){if(!i)return null;var e,t=i.match(/^([^<]*)(<[^>]*>?)?/);return e=t[1]?t[1]:t[2],i=i.substr(e.length),e}function t(e){return Rn[e]}function n(e){for(;f=e.match(/&(amp|lt|gt|lrm|rlm|nbsp);/);)e=e.replace(f[0],t);return e}function r(e,t){var i=Mn[e];if(!i)return null;var n=a.document.createElement(i);n.localName=i;var r=Nn[e];return r&&t&&(n[r]=t.trim()),n}for(var s,o,u,l=a.document.createElement("div"),c=l,h=[];null!==(s=e());)if("<"!==s[0])c.appendChild(a.document.createTextNode(n(s)));else{if("/"===s[1]){h.length&&h[h.length-1]===s.substr(2).replace(">","")&&(h.pop(),c=c.parentNode);continue}var d,p=On(s.substr(1,s.length-2));if(p){d=a.document.createProcessingInstruction("timestamp",p),c.appendChild(d);continue}var f=s.match(/^<([^.\s/0-9>]+)(\.[^\s\\>]+)?([^>\\]+)?(\\?)>?$/);if(!f)continue;if(!(d=r(f[1],f[3])))continue;if(o=c,Bn[(u=d).localName]&&Bn[u.localName]!==o.localName)continue;f[2]&&(d.className=f[2].substr(1).replace("."," ")),h.push(f[1]),c.appendChild(d),c=d}return l}var Fn=[[1470,1470],[1472,1472],[1475,1475],[1478,1478],[1488,1514],[1520,1524],[1544,1544],[1547,1547],[1549,1549],[1563,1563],[1566,1610],[1645,1647],[1649,1749],[1765,1766],[1774,1775],[1786,1805],[1807,1808],[1810,1839],[1869,1957],[1969,1969],[1984,2026],[2036,2037],[2042,2042],[2048,2069],[2074,2074],[2084,2084],[2088,2088],[2096,2110],[2112,2136],[2142,2142],[2208,2208],[2210,2220],[8207,8207],[64285,64285],[64287,64296],[64298,64310],[64312,64316],[64318,64318],[64320,64321],[64323,64324],[64326,64449],[64467,64829],[64848,64911],[64914,64967],[65008,65020],[65136,65140],[65142,65276],[67584,67589],[67592,67592],[67594,67637],[67639,67640],[67644,67644],[67647,67669],[67671,67679],[67840,67867],[67872,67897],[67903,67903],[67968,68023],[68030,68031],[68096,68096],[68112,68115],[68117,68119],[68121,68147],[68160,68167],[68176,68184],[68192,68223],[68352,68405],[68416,68437],[68440,68466],[68472,68479],[68608,68680],[126464,126467],[126469,126495],[126497,126498],[126500,126500],[126503,126503],[126505,126514],[126516,126519],[126521,126521],[126523,126523],[126530,126530],[126535,126535],[126537,126537],[126539,126539],[126541,126543],[126545,126546],[126548,126548],[126551,126551],[126553,126553],[126555,126555],[126557,126557],[126559,126559],[126561,126562],[126564,126564],[126567,126570],[126572,126578],[126580,126583],[126585,126588],[126590,126590],[126592,126601],[126603,126619],[126625,126627],[126629,126633],[126635,126651],[1114109,1114109]];function Hn(e){for(var t=0;t=i[0]&&e<=i[1])return!0}return!1}function Vn(){}function qn(e,t,i){Vn.call(this),this.cue=t,this.cueDiv=jn(e,t.text);var n={color:"rgba(255, 255, 255, 1)",backgroundColor:"rgba(0, 0, 0, 0.8)",position:"relative",left:0,right:0,top:0,bottom:0,display:"inline",writingMode:""===t.vertical?"horizontal-tb":"lr"===t.vertical?"vertical-lr":"vertical-rl",unicodeBidi:"plaintext"};this.applyStyles(n,this.cueDiv),this.div=e.document.createElement("div"),n={direction:function(e){var t=[],i="";if(!e||!e.childNodes)return"ltr";function r(e,t){for(var i=t.childNodes.length-1;0<=i;i--)e.push(t.childNodes[i])}function a(e){if(!e||!e.length)return null;var t=e.pop(),i=t.textContent||t.innerText;if(i){var n=i.match(/^.*(\n|\r)/);return n?n[e.length=0]:i}return"ruby"===t.tagName?a(e):t.childNodes?(r(e,t),a(e)):void 0}for(r(t,e);i=a(t);)for(var n=0;nh&&(c=c<0?-1:1,c*=Math.ceil(h/l)*l),r<0&&(c+=""===n.vertical?o.height:o.width,a=a.reverse()),i.move(d,c)}else{var p=i.lineHeight/o.height*100;switch(n.lineAlign){case"middle":r-=p/2;break;case"end":r-=p}switch(n.vertical){case"":t.applyStyles({top:t.formatStyle(r,"%")});break;case"rl":t.applyStyles({left:t.formatStyle(r,"%")});break;case"lr":t.applyStyles({right:t.formatStyle(r,"%")})}a=["+y","-x","+x","-y"],i=new Wn(t)}var f=function(e,t){for(var i,n=new Wn(e),r=1,a=0;ae.left&&this.tope.top},Wn.prototype.overlapsAny=function(e){for(var t=0;t=e.top&&this.bottom<=e.bottom&&this.left>=e.left&&this.right<=e.right},Wn.prototype.overlapsOppositeAxis=function(e,t){switch(t){case"+x":return this.lefte.right;case"+y":return this.tope.bottom}},Wn.prototype.intersectPercentage=function(e){return Math.max(0,Math.min(this.right,e.right)-Math.max(this.left,e.left))*Math.max(0,Math.min(this.bottom,e.bottom)-Math.max(this.top,e.top))/(this.height*this.width)},Wn.prototype.toCSSCompatValues=function(e){return{top:this.top-e.top,bottom:e.bottom-this.bottom,left:this.left-e.left,right:e.right-this.right,height:this.height,width:this.width}},Wn.getSimpleBoxPosition=function(e){var t=e.div?e.div.offsetHeight:e.tagName?e.offsetHeight:0,i=e.div?e.div.offsetWidth:e.tagName?e.offsetWidth:0,n=e.div?e.div.offsetTop:e.tagName?e.offsetTop:0;return{left:(e=e.div?e.div.getBoundingClientRect():e.tagName?e.getBoundingClientRect():e).left,right:e.right,top:e.top||n,height:e.height||t,bottom:e.bottom||n+(e.height||t),width:e.width||i}},$n.StringDecoder=function(){return{decode:function(e){if(!e)return"";if("string"!=typeof e)throw new Error("Error - expected string data.");return decodeURIComponent(encodeURIComponent(e))}}},$n.convertCueToDOMTree=function(e,t){return e&&t?jn(e,t):null};$n.processCues=function(n,r,e){if(!n||!r||!e)return null;for(;e.firstChild;)e.removeChild(e.firstChild);var a=n.document.createElement("div");if(a.style.position="absolute",a.style.left="0",a.style.right="0",a.style.top="0",a.style.bottom="0",a.style.margin="1.5%",e.appendChild(a),function(e){for(var t=0;t