summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/ISSUE_TEMPLATE/bug_report.md6
-rw-r--r--.github/workflows/build-nightly-container.yml13
-rw-r--r--.github/workflows/build-stable-container.yml13
-rw-r--r--.github/workflows/ci.yml6
-rw-r--r--assets/js/handlers.js4
-rw-r--r--assets/js/pagination.js93
-rw-r--r--assets/js/player.js30
-rw-r--r--assets/js/playlist_widget.js6
-rw-r--r--assets/js/subscribe_widget.js4
-rw-r--r--assets/js/watch.js4
-rw-r--r--assets/js/watched_widget.js4
-rw-r--r--config/config.example.yml10
-rw-r--r--locales/ar.json6
-rw-r--r--locales/cs.json30
-rw-r--r--locales/de.json10
-rw-r--r--locales/el.json14
-rw-r--r--locales/en-US.json1
-rw-r--r--locales/es.json4
-rw-r--r--locales/fa.json3
-rw-r--r--locales/fi.json5
-rw-r--r--locales/fr.json6
-rw-r--r--locales/hr.json4
-rw-r--r--locales/is.json4
-rw-r--r--locales/it.json8
-rw-r--r--locales/ja.json4
-rw-r--r--locales/ko.json5
-rw-r--r--locales/nb-NO.json3
-rw-r--r--locales/nl.json4
-rw-r--r--locales/pl.json4
-rw-r--r--locales/pt-BR.json4
-rw-r--r--locales/pt.json4
-rw-r--r--locales/ru.json8
-rw-r--r--locales/sl.json19
-rw-r--r--locales/sq.json4
-rw-r--r--locales/sr.json4
-rw-r--r--locales/sr_Cyrl.json4
-rw-r--r--locales/sv-SE.json4
-rw-r--r--locales/ta.json502
-rw-r--r--locales/tok.json1
-rw-r--r--locales/tr.json3
-rw-r--r--locales/uk.json4
-rw-r--r--locales/zh-CN.json4
-rw-r--r--locales/zh-TW.json4
-rw-r--r--src/invidious.cr5
-rw-r--r--src/invidious/channels/channels.cr12
-rw-r--r--src/invidious/config.cr12
-rw-r--r--src/invidious/database/users.cr10
-rw-r--r--src/invidious/frontend/pagination.cr32
-rw-r--r--src/invidious/frontend/watch_page.cr2
-rw-r--r--src/invidious/helpers/crystal_class_overrides.cr34
-rw-r--r--src/invidious/helpers/errors.cr4
-rw-r--r--src/invidious/helpers/handlers.cr1
-rw-r--r--src/invidious/helpers/i18n.cr1
-rw-r--r--src/invidious/helpers/serialized_yt_data.cr21
-rw-r--r--src/invidious/jobs/notification_job.cr90
-rw-r--r--src/invidious/jsonify/api_v1/video_json.cr6
-rw-r--r--src/invidious/mixes.cr4
-rw-r--r--src/invidious/playlists.cr4
-rw-r--r--src/invidious/routes/account.cr14
-rw-r--r--src/invidious/routes/api/manifest.cr40
-rw-r--r--src/invidious/routes/api/v1/misc.cr14
-rw-r--r--src/invidious/routes/api/v1/videos.cr86
-rw-r--r--src/invidious/routes/embed.cr6
-rw-r--r--src/invidious/routes/feeds.cr67
-rw-r--r--src/invidious/routes/playlists.cr31
-rw-r--r--src/invidious/routes/subscriptions.cr14
-rw-r--r--src/invidious/routes/video_playback.cr9
-rw-r--r--src/invidious/routes/watch.cr20
-rw-r--r--src/invidious/routing.cr1
-rw-r--r--src/invidious/search/filters.cr2
-rw-r--r--src/invidious/search/query.cr2
-rw-r--r--src/invidious/user/imports.cr57
-rw-r--r--src/invidious/videos.cr2
-rw-r--r--src/invidious/videos/parser.cr8
-rw-r--r--src/invidious/videos/storyboard.cr2
-rw-r--r--src/invidious/videos/transcript.cr35
-rw-r--r--src/invidious/views/channel.ecr6
-rw-r--r--src/invidious/views/components/item.ecr6
-rw-r--r--src/invidious/views/components/items_paginated.ecr10
-rw-r--r--src/invidious/views/components/subscribe_widget.ecr4
-rw-r--r--src/invidious/views/feeds/history.ecr2
-rw-r--r--src/invidious/views/user/subscription_manager.ecr2
-rw-r--r--src/invidious/views/user/token_manager.ecr2
-rw-r--r--src/invidious/views/watch.ecr3
-rw-r--r--src/invidious/yt_backend/extractors.cr7
-rw-r--r--src/invidious/yt_backend/youtube_api.cr12
86 files changed, 1212 insertions, 367 deletions
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 4c1a6330..02bc3795 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -10,8 +10,10 @@ assignees: ''
<!--
BEFORE TRYING TO REPORT A BUG:
- * Read the FAQ!
- * Use the search function to check if there is already an issue open for your problem!
+ * Read the FAQ: https://docs.invidious.io/faq/!
+ * Use the search function to check if there is already an issue open for your problem: https://github.com/search?q=repo%3Aiv-org%2Finvidious+replace+me+with+your+bug&type=issues!
+
+ MAKE SURE TO FOLLOW THE TWO STEPS ABOVE BEFORE REPORTING A BUG. A BUG THAT ALREADY EXIST WILL IMMEDIATELY CLOSED.
If you want to suggest a new feature please use "Feature request" instead
If you want to suggest an enhancement to an existing feature please use "Enhancement" instead
diff --git a/.github/workflows/build-nightly-container.yml b/.github/workflows/build-nightly-container.yml
index bee27600..5ff3322f 100644
--- a/.github/workflows/build-nightly-container.yml
+++ b/.github/workflows/build-nightly-container.yml
@@ -23,19 +23,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- - name: Install Crystal
- uses: crystal-lang/install-crystal@v1.8.2
- with:
- crystal: 1.12.2
-
- - name: Run lint
- run: |
- if ! crystal tool format --check; then
- crystal tool format
- git diff
- exit 1
- fi
-
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
diff --git a/.github/workflows/build-stable-container.yml b/.github/workflows/build-stable-container.yml
index d2d106b6..25571ed6 100644
--- a/.github/workflows/build-stable-container.yml
+++ b/.github/workflows/build-stable-container.yml
@@ -14,19 +14,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- - name: Install Crystal
- uses: crystal-lang/install-crystal@v1.8.2
- with:
- crystal: 1.12.2
-
- - name: Run lint
- run: |
- if ! crystal tool format --check; then
- crystal tool format
- git diff
- exit 1
- fi
-
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dd472d1a..5f859613 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,11 +38,10 @@ jobs:
matrix:
stable: [true]
crystal:
- - 1.10.1
- - 1.11.2
- 1.12.1
- 1.13.2
- 1.14.0
+ - 1.15.0
include:
- crystal: nightly
stable: false
@@ -136,6 +135,7 @@ jobs:
submodules: true
- name: Install Crystal
+ id: lint_step_install_crystal
uses: crystal-lang/install-crystal@v1.8.0
with:
crystal: latest
@@ -146,7 +146,7 @@ jobs:
path: |
./lib
./bin
- key: shards-${{ hashFiles('shard.lock') }}
+ key: shards-${{ hashFiles('shard.lock') }}-${{ steps.lint_step_install_crystal.outputs.crystal }}
- name: Install Shards
run: |
diff --git a/assets/js/handlers.js b/assets/js/handlers.js
index 539974fb..67cd9081 100644
--- a/assets/js/handlers.js
+++ b/assets/js/handlers.js
@@ -91,7 +91,7 @@
var count = document.getElementById('count');
count.textContent--;
- var url = '/token_ajax?action_revoke_token=1&redirect=false' +
+ var url = '/token_ajax?action=revoke_token&redirect=false' +
'&referer=' + encodeURIComponent(location.href) +
'&session=' + target.getAttribute('data-session');
@@ -111,7 +111,7 @@
var count = document.getElementById('count');
count.textContent--;
- var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' +
+ var url = '/subscription_ajax?action=remove_subscriptions&redirect=false' +
'&referer=' + encodeURIComponent(location.href) +
'&c=' + target.getAttribute('data-ucid');
diff --git a/assets/js/pagination.js b/assets/js/pagination.js
new file mode 100644
index 00000000..2e560a34
--- /dev/null
+++ b/assets/js/pagination.js
@@ -0,0 +1,93 @@
+'use strict';
+
+const CURRENT_CONTINUATION = (new URL(document.location)).searchParams.get("continuation");
+const CONT_CACHE_KEY = `continuation_cache_${encodeURIComponent(window.location.pathname)}`;
+
+function get_data(){
+ return JSON.parse(sessionStorage.getItem(CONT_CACHE_KEY)) || [];
+}
+
+function save_data(){
+ const prev_data = get_data();
+ prev_data.push(CURRENT_CONTINUATION);
+
+ sessionStorage.setItem(CONT_CACHE_KEY, JSON.stringify(prev_data));
+}
+
+function button_press(){
+ let prev_data = get_data();
+ if (!prev_data.length) return null;
+
+ // Sanity check. Nowhere should the current continuation token exist in the cache
+ // but it can happen when using the browser's back feature. As such we'd need to travel
+ // back to the point where the current continuation token first appears in order to
+ // account for the rewind.
+ const conflict_at = prev_data.indexOf(CURRENT_CONTINUATION);
+ if (conflict_at != -1) {
+ prev_data.length = conflict_at;
+ }
+
+ const prev_ctoken = prev_data.pop();
+
+ // On the first page, the stored continuation token is null.
+ if (prev_ctoken === null) {
+ sessionStorage.removeItem(CONT_CACHE_KEY);
+ let url = set_continuation();
+ window.location.href = url;
+
+ return;
+ }
+
+ sessionStorage.setItem(CONT_CACHE_KEY, JSON.stringify(prev_data));
+ let url = set_continuation(prev_ctoken);
+
+ window.location.href = url;
+};
+
+// Method to set the current page's continuation token
+// Removes the continuation parameter when a continuation token is not given
+function set_continuation(prev_ctoken = null){
+ let url = window.location.href.split('?')[0];
+ let params = window.location.href.split('?')[1];
+ let url_params = new URLSearchParams(params);
+
+ if (prev_ctoken) {
+ url_params.set("continuation", prev_ctoken);
+ } else {
+ url_params.delete('continuation');
+ };
+
+ if(Array.from(url_params).length > 0){
+ return `${url}?${url_params.toString()}`;
+ } else {
+ return url;
+ }
+}
+
+addEventListener('DOMContentLoaded', function(){
+ const pagination_data = JSON.parse(document.getElementById('pagination-data').textContent);
+ const next_page_containers = document.getElementsByClassName("page-next-container");
+
+ for (let container of next_page_containers){
+ const next_page_button = container.getElementsByClassName("pure-button")
+
+ // exists?
+ if (next_page_button.length > 0){
+ next_page_button[0].addEventListener("click", save_data);
+ }
+ }
+
+ // Only add previous page buttons when not on the first page
+ if (CURRENT_CONTINUATION) {
+ const prev_page_containers = document.getElementsByClassName("page-prev-container")
+
+ for (let container of prev_page_containers) {
+ if (pagination_data.is_rtl) {
+ container.innerHTML = `<button class="pure-button pure-button-secondary">${pagination_data.prev_page}&nbsp;&nbsp;<i class="icon ion-ios-arrow-forward"></i></button>`
+ } else {
+ container.innerHTML = `<button class="pure-button pure-button-secondary"><i class="icon ion-ios-arrow-back"></i>&nbsp;&nbsp;${pagination_data.prev_page}</button>`
+ }
+ container.getElementsByClassName("pure-button")[0].addEventListener("click", button_press);
+ }
+ }
+});
diff --git a/assets/js/player.js b/assets/js/player.js
index 353a5296..f32c9b56 100644
--- a/assets/js/player.js
+++ b/assets/js/player.js
@@ -134,26 +134,32 @@ player.on('timeupdate', function () {
// YouTube links
let elem_yt_watch = document.getElementById('link-yt-watch');
+ if (elem_yt_watch) {
+ let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url');
+ elem_yt_watch.href = addCurrentTimeToURL(base_url_yt_watch);
+ }
+
let elem_yt_embed = document.getElementById('link-yt-embed');
-
- let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url');
- let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url');
-
- elem_yt_watch.href = addCurrentTimeToURL(base_url_yt_watch);
- elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed);
+ if (elem_yt_embed) {
+ let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url');
+ elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed);
+ }
// Invidious links
let domain = window.location.origin;
let elem_iv_embed = document.getElementById('link-iv-embed');
+ if (elem_iv_embed) {
+ let base_url_iv_embed = elem_iv_embed.getAttribute('data-base-url');
+ elem_iv_embed.href = addCurrentTimeToURL(base_url_iv_embed, domain);
+ }
+
let elem_iv_other = document.getElementById('link-iv-other');
-
- let base_url_iv_embed = elem_iv_embed.getAttribute('data-base-url');
- let base_url_iv_other = elem_iv_other.getAttribute('data-base-url');
-
- elem_iv_embed.href = addCurrentTimeToURL(base_url_iv_embed, domain);
- elem_iv_other.href = addCurrentTimeToURL(base_url_iv_other, domain);
+ if (elem_iv_other) {
+ let base_url_iv_other = elem_iv_other.getAttribute('data-base-url');
+ elem_iv_other.href = addCurrentTimeToURL(base_url_iv_other, domain);
+ }
});
diff --git a/assets/js/playlist_widget.js b/assets/js/playlist_widget.js
index c92592ac..96a51d70 100644
--- a/assets/js/playlist_widget.js
+++ b/assets/js/playlist_widget.js
@@ -6,7 +6,7 @@ function add_playlist_video(target) {
var select = target.parentNode.children[0].children[1];
var option = select.children[select.selectedIndex];
- var url = '/playlist_ajax?action_add_video=1&redirect=false' +
+ var url = '/playlist_ajax?action=add_video&redirect=false' +
'&video_id=' + target.getAttribute('data-id') +
'&playlist_id=' + option.getAttribute('data-plid');
@@ -21,7 +21,7 @@ function add_playlist_item(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none';
- var url = '/playlist_ajax?action_add_video=1&redirect=false' +
+ var url = '/playlist_ajax?action=add_video&redirect=false' +
'&video_id=' + target.getAttribute('data-id') +
'&playlist_id=' + target.getAttribute('data-plid');
@@ -36,7 +36,7 @@ function remove_playlist_item(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none';
- var url = '/playlist_ajax?action_remove_video=1&redirect=false' +
+ var url = '/playlist_ajax?action=remove_video&redirect=false' +
'&set_video_id=' + target.getAttribute('data-index') +
'&playlist_id=' + target.getAttribute('data-plid');
diff --git a/assets/js/subscribe_widget.js b/assets/js/subscribe_widget.js
index 7665a00b..d462e848 100644
--- a/assets/js/subscribe_widget.js
+++ b/assets/js/subscribe_widget.js
@@ -16,7 +16,7 @@ function subscribe() {
subscribe_button.onclick = unsubscribe;
subscribe_button.innerHTML = '<b>' + subscribe_data.unsubscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
- var url = '/subscription_ajax?action_create_subscription_to_channel=1&redirect=false' +
+ var url = '/subscription_ajax?action=create_subscription_to_channel&redirect=false' +
'&c=' + subscribe_data.ucid;
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'subscribe request'}, {
@@ -32,7 +32,7 @@ function unsubscribe() {
subscribe_button.onclick = subscribe;
subscribe_button.innerHTML = '<b>' + subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
- var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' +
+ var url = '/subscription_ajax?action=remove_subscriptions&redirect=false' +
'&c=' + subscribe_data.ucid;
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'unsubscribe request'}, {
diff --git a/assets/js/watch.js b/assets/js/watch.js
index 26ad138f..d869d40d 100644
--- a/assets/js/watch.js
+++ b/assets/js/watch.js
@@ -67,6 +67,10 @@ function get_playlist(plid) {
'&format=html&hl=' + video_data.preferences.locale;
}
+ if (video_data.params.listen) {
+ plid_url += '&listen=1'
+ }
+
helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, {
on200: function (response) {
playlist.innerHTML = response.playlistHtml;
diff --git a/assets/js/watched_widget.js b/assets/js/watched_widget.js
index f1ac9cb4..06af62cc 100644
--- a/assets/js/watched_widget.js
+++ b/assets/js/watched_widget.js
@@ -6,7 +6,7 @@ function mark_watched(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none';
- var url = '/watch_ajax?action_mark_watched=1&redirect=false' +
+ var url = '/watch_ajax?action=mark_watched&redirect=false' +
'&id=' + target.getAttribute('data-id');
helpers.xhr('POST', url, {payload: payload}, {
@@ -22,7 +22,7 @@ function mark_unwatched(target) {
var count = document.getElementById('count');
count.textContent--;
- var url = '/watch_ajax?action_mark_unwatched=1&redirect=false' +
+ var url = '/watch_ajax?action=mark_unwatched&redirect=false' +
'&id=' + target.getAttribute('data-id');
helpers.xhr('POST', url, {payload: payload}, {
diff --git a/config/config.example.yml b/config/config.example.yml
index c5c3109d..b04e0a30 100644
--- a/config/config.example.yml
+++ b/config/config.example.yml
@@ -192,11 +192,11 @@ https_only: false
##
## If unset, then no HTTP proxy will be used.
##
-http_proxy:
- user:
- password:
- host:
- port:
+#http_proxy:
+# user:
+# password:
+# host:
+# port:
##
diff --git a/locales/ar.json b/locales/ar.json
index b6bab59b..a8f5e62d 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -559,10 +559,12 @@
"toggle_theme": "تبديل الموضوع",
"Add to playlist": "أضف إلى قائمة التشغيل",
"Add to playlist: ": "أضف إلى قائمة التشغيل: ",
- "Answer": "الرد",
+ "Answer": "اجابة",
"Search for videos": "ابحث عن مقاطع الفيديو",
"The Popular feed has been disabled by the administrator.": "تم تعطيل الخلاصة الشائعة من قبل المسؤول.",
"carousel_slide": "الشريحة {{current}} من {{total}}",
"carousel_skip": "تخطي الكاروسيل",
- "carousel_go_to": "انتقل إلى الشريحة `x`"
+ "carousel_go_to": "انتقل إلى الشريحة `x`",
+ "preferences_preload_label": "التحميل المسبق لبيانات الفيديو: ",
+ "Filipino (auto-generated)": "الفلبينية (المولدة تلقائيًا)"
}
diff --git a/locales/cs.json b/locales/cs.json
index 6e66178d..d28f2098 100644
--- a/locales/cs.json
+++ b/locales/cs.json
@@ -137,7 +137,7 @@
"Family friendly? ": "Vhodné pro rodiny? ",
"Engagement: ": "Zapojení: ",
"English": "Angličtina",
- "English (auto-generated)": "Angličtina (automaticky generováno)",
+ "English (auto-generated)": "Angličtina (vytvořeno automaticky)",
"Afrikaans": "Afrikánština",
"Albanian": "Albánština",
"Amharic": "Amharština",
@@ -294,8 +294,8 @@
"Chinese (China)": "Čínština (Čína)",
"Chinese (Hong Kong)": "Čínština (Hong Kong)",
"Chinese (Taiwan)": "Čínština (Taiwan)",
- "Portuguese (auto-generated)": "Portugalština (automaticky generováno)",
- "Spanish (auto-generated)": "Španělština (automaticky generováno)",
+ "Portuguese (auto-generated)": "Portugalština (vytvořeno automaticky)",
+ "Spanish (auto-generated)": "Španělština (vytvořeno automaticky)",
"Spanish (Mexico)": "Španělština (Mexiko)",
"Spanish (Spain)": "Španělština (Španělsko)",
"generic_count_years_0": "{{count}} rokem",
@@ -352,13 +352,13 @@
"comments_points_count_0": "{{count}} bod",
"comments_points_count_1": "{{count}} body",
"comments_points_count_2": "{{count}} bodů",
- "German (auto-generated)": "Němčina (automaticky generováno)",
- "Indonesian (auto-generated)": "Indonéština (automaticky generováno)",
+ "German (auto-generated)": "Němčina (vytvořeno automaticky)",
+ "Indonesian (auto-generated)": "Indonéština (vytvořeno automaticky)",
"Interlingue": "Interlingue",
- "Italian (auto-generated)": "Italština (automaticky generováno)",
- "Japanese (auto-generated)": "Japonština (automaticky generováno)",
- "Korean (auto-generated)": "Korejština (automaticky generováno)",
- "Russian (auto-generated)": "Ruština (automaticky generováno)",
+ "Italian (auto-generated)": "Italština (vytvořeno automaticky)",
+ "Japanese (auto-generated)": "Japonština (vytvořeno automaticky)",
+ "Korean (auto-generated)": "Korejština (vytvořeno automaticky)",
+ "Russian (auto-generated)": "Ruština (vytvořeno automaticky)",
"generic_count_months_0": "{{count}} měsícem",
"generic_count_months_1": "{{count}} měsíci",
"generic_count_months_2": "{{count}} měsíci",
@@ -371,7 +371,7 @@
"footer_documentation": "Dokumentace",
"next_steps_error_message_refresh": "Obnovit stránku",
"Chinese": "Čínština",
- "Dutch (auto-generated)": "Nizozemština (automaticky generováno)",
+ "Dutch (auto-generated)": "Nizozemština (vytvořeno automaticky)",
"Erroneous token": "Chybný token",
"tokens_count_0": "{{count}} token",
"tokens_count_1": "{{count}} tokeny",
@@ -380,9 +380,9 @@
"Token is expired, please try again": "Token vypršel, zkuste to prosím znovu",
"English (United States)": "Angličtina (Spojené státy)",
"Cantonese (Hong Kong)": "Kantonština (Hong Kong)",
- "French (auto-generated)": "Francouzština (automaticky generováno)",
- "Turkish (auto-generated)": "Turečtina (automaticky generováno)",
- "Vietnamese (auto-generated)": "Vietnamština (automaticky generováno)",
+ "French (auto-generated)": "Francouzština (vytvořeno automaticky)",
+ "Turkish (auto-generated)": "Turečtina (vytvořeno automaticky)",
+ "Vietnamese (auto-generated)": "Vietnamština (vytvořeno automaticky)",
"Current version: ": "Aktuální verze: ",
"next_steps_error_message": "Měli byste zkusit: ",
"footer_donate_page": "Přispět",
@@ -513,5 +513,7 @@
"The Popular feed has been disabled by the administrator.": "Kategorie Populární byla zakázána administrátorem.",
"carousel_slide": "Snímek {{current}} z {{total}}",
"carousel_skip": "Přeskočit galerii",
- "carousel_go_to": "Přejít na snímek `x`"
+ "carousel_go_to": "Přejít na snímek `x`",
+ "preferences_preload_label": "Předem načíst data videa: ",
+ "Filipino (auto-generated)": "Filipínština (vytvořeno automaticky)"
}
diff --git a/locales/de.json b/locales/de.json
index 151f2abe..ce6fde8b 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -11,6 +11,7 @@
"last": "neueste",
"Next page": "Nächste Seite",
"Previous page": "Vorherige Seite",
+ "First page": "Erste Seite",
"Clear watch history?": "Verlauf löschen?",
"New password": "Neues Passwort",
"New passwords must match": "Neue Passwörter müssen übereinstimmen",
@@ -490,12 +491,13 @@
"generic_channels_count_plural": "{{count}} Kanäle",
"Import YouTube watch history (.json)": "YouTube Wiedergabeverlauf importieren (.json)",
"Answer": "Antwort",
- "The Popular feed has been disabled by the administrator.": "Der Angesagt-Feed wurde vom Administrator deaktiviert.",
+ "The Popular feed has been disabled by the administrator.": "Der Feed für beliebte Inhalte wurde vom Administrator deaktiviert.",
"Add to playlist": "Einer Wiedergabeliste hinzufügen",
"Search for videos": "Nach Videos suchen",
"toggle_theme": "Thema wechseln",
"Add to playlist: ": "Einer Wiedergabeliste hinzufügen: ",
- "carousel_go_to": "Zu Folie `x` gehen",
- "carousel_slide": "Folie {{current}} von {{total}}",
- "carousel_skip": "Karussell überspringen"
+ "carousel_go_to": "Zu Element `x` springen",
+ "carousel_slide": "Seite {{current}} von {{total}}",
+ "carousel_skip": "Galerie überspringen",
+ "Filipino (auto-generated)": "Philippinisch (automatisch generiert)"
}
diff --git a/locales/el.json b/locales/el.json
index 38550458..32efaaf8 100644
--- a/locales/el.json
+++ b/locales/el.json
@@ -21,7 +21,7 @@
"Import and Export Data": "Εισαγωγή και Εξαγωγή Δεδομένων",
"Import": "Εισαγωγή",
"Import Invidious data": "Εsαγωγή δεδομένων Invidious JSON",
- "Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube/OPML",
+ "Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube απο CVS/OPML",
"Import FreeTube subscriptions (.db)": "Εισαγωγή συνδρομών FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Εισαγωγή συνδρομών NewPipe (.json)",
"Import NewPipe data (.zip)": "Εισαγωγή δεδομένων NewPipe (.zip)",
@@ -455,7 +455,7 @@
"channel_tab_streams_label": "Ζωντανή μετάδοση",
"playlist_button_add_items": "Προσθήκη βίντεο",
"Artist: ": "Καλλιτέχνης: ",
- "search_message_use_another_instance": " Μπορείτε επίσης <a href=\"`x`\">να αναζητήσετε σε άλλο instance</a>.",
+ "search_message_use_another_instance": "Μπορείτε επίσης <a href=\"`x`\">να αναζητήσετε σε άλλο instance</a>.",
"generic_button_save": "Αποθήκευση",
"generic_button_cancel": "Ακύρωση",
"subscriptions_unseen_notifs_count": "{{count}} μη αναγνωσμένη ειδοποίηση",
@@ -490,9 +490,13 @@
"Search for videos": "Αναζήτηση βίντεο",
"The Popular feed has been disabled by the administrator.": "Η δημοφιλής ροή έχει απενεργοποιηθεί από τον διαχειριστή.",
"Answer": "Απάντηση",
- "Add to playlist": "Λίιστα αναπαραγωγής",
- "Add to playlist: ": "Λίστα αναπαραγωγής: ",
+ "Add to playlist": "Προσθήκη στην λίιστα αναπαραγωγής",
+ "Add to playlist: ": "Προσθήκη στην λίστα αναπαραγωγής : ",
"carousel_slide": "Εικόνα {{current}}απο {{total}}",
"carousel_go_to": "Πήγαινε στην εικόνα`x`",
- "toggle_theme": "Αλλαγή θέματος"
+ "toggle_theme": "Αλλαγή θέματος",
+ "Import YouTube watch history (.json)": "Εισαγωγή ιστορικού προβολής YouTube (.json)",
+ "Filipino (auto-generated)": "Φιλιππινέζικα (αυτόματη παραγωγή)",
+ "preferences_preload_label": "Προφόρτιση δεδομένων βίντεο: ",
+ "carousel_skip": "Αποφυγή εμφάνισης εικόνων"
}
diff --git a/locales/en-US.json b/locales/en-US.json
index c23f6bc3..381bcab5 100644
--- a/locales/en-US.json
+++ b/locales/en-US.json
@@ -33,6 +33,7 @@
"last": "last",
"Next page": "Next page",
"Previous page": "Previous page",
+ "First page": "First page",
"Clear watch history?": "Clear watch history?",
"New password": "New password",
"New passwords must match": "New passwords must match",
diff --git a/locales/es.json b/locales/es.json
index fda29198..ad65e07d 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -513,5 +513,7 @@
"The Popular feed has been disabled by the administrator.": "El feed Popular ha sido desactivado por el administrador.",
"carousel_slide": "Diapositiva {{current}} de {{total}}",
"carousel_skip": "Saltar el carrusel",
- "carousel_go_to": "Ir a la diapositiva `x`"
+ "carousel_go_to": "Ir a la diapositiva `x`",
+ "preferences_preload_label": "Precargar datos del vídeo: ",
+ "Filipino (auto-generated)": "Filipino (generado automáticamente)"
}
diff --git a/locales/fa.json b/locales/fa.json
index b146385e..2326370d 100644
--- a/locales/fa.json
+++ b/locales/fa.json
@@ -496,5 +496,6 @@
"crash_page_search_issue": "دنبال <a href=\"`x`\"> گشتیم بین مشکلات در گیت هاب </a>",
"crash_page_report_issue": "اگر هیچ یک از روش های بالا کمکی نکردند لطفا <a href=\"`x`\"> (ترجیحا به انگلیسی) یک سوال جدید در گیت هاب بپرسید و </a> طوری که سوالتون شامل متن زیر باشه:",
"channel_tab_releases_label": "آثار",
- "toggle_theme": "تغییر وضعیت تم"
+ "toggle_theme": "تغییر وضعیت تم",
+ "preferences_preload_label": "پیش بار کردن داده‌های ویدیو: "
}
diff --git a/locales/fi.json b/locales/fi.json
index b0df1e46..13fef6de 100644
--- a/locales/fi.json
+++ b/locales/fi.json
@@ -460,7 +460,7 @@
"search_filters_apply_button": "Ota valitut suodattimet käyttöön",
"search_filters_date_label": "Latausaika",
"search_filters_duration_option_medium": "Keskipituinen (4 - 20 minuuttia)",
- "search_message_use_another_instance": " Voit myös <a href=\"`x`\">hakea toisella instanssilla</a>.",
+ "search_message_use_another_instance": "Voit myös <a href=\"`x`\">hakea toisella instanssilla</a>.",
"search_filters_date_option_none": "Milloin tahansa",
"search_filters_type_option_all": "Mikä tahansa tyyppi",
"Popular enabled: ": "Suosittu käytössä: ",
@@ -496,5 +496,6 @@
"generic_channels_count_plural": "{{count}} kanavaa",
"The Popular feed has been disabled by the administrator.": "Järjestelmänvalvoja on poistanut Suositut-syötteen.",
"Import YouTube watch history (.json)": "Tuo Youtube-katseluhistoria (.json)",
- "toggle_theme": "Vaihda teemaa"
+ "toggle_theme": "Vaihda teemaa",
+ "preferences_preload_label": "Esilataa video data. "
}
diff --git a/locales/fr.json b/locales/fr.json
index 6147a159..800c7aaf 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -505,7 +505,7 @@
"channel_tab_releases_label": "Parutions",
"channel_tab_podcasts_label": "Émissions audio",
"Import YouTube watch history (.json)": "Importer l'historique de visionnement YouTube (.json)",
- "Add to playlist: ": "Ajouter à la playlist : ",
+ "Add to playlist: ": "Ajouter à la playlist : ",
"Add to playlist": "Ajouter à la playlist",
"Answer": "Répondre",
"Search for videos": "Rechercher des vidéos",
@@ -513,5 +513,7 @@
"carousel_skip": "Passez le carrousel",
"carousel_slide": "Diapositive {{current}} sur {{total}}",
"carousel_go_to": "Aller à la diapositive `x`",
- "toggle_theme": "Changer le Thème"
+ "toggle_theme": "Changer le Thème",
+ "Filipino (auto-generated)": "Philippines (automatiquement générer)",
+ "preferences_preload_label": "Précharger les données de la vidéo : "
}
diff --git a/locales/hr.json b/locales/hr.json
index 7b76a41f..6adbcdc3 100644
--- a/locales/hr.json
+++ b/locales/hr.json
@@ -513,5 +513,7 @@
"toggle_theme": "Uklj./Isklj. temu",
"carousel_slide": "Kadar {{current}} od {{total}}",
"carousel_go_to": "Idi na kadar `x`",
- "carousel_skip": "Preskoči vrtuljak"
+ "carousel_skip": "Preskoči vrtuljak",
+ "Filipino (auto-generated)": "Filipinski (automatski generirano)",
+ "preferences_preload_label": "Unaprijed učitaj podatke videa: "
}
diff --git a/locales/is.json b/locales/is.json
index 9d13c5cf..d94357f1 100644
--- a/locales/is.json
+++ b/locales/is.json
@@ -496,5 +496,7 @@
"footer_documentation": "Leiðbeiningar",
"channel_tab_channels_label": "Rásir",
"Import YouTube playlist (.csv)": "Flytja inn YouTube spilunarlista (.csv)",
- "preferences_quality_option_dash": "DASH (aðlaganleg gæði)"
+ "preferences_quality_option_dash": "DASH (aðlaganleg gæði)",
+ "preferences_preload_label": "Forhlaða gögnum myndskeiðs: ",
+ "Filipino (auto-generated)": "Filippínska (sjálfvirkt útbúin)"
}
diff --git a/locales/it.json b/locales/it.json
index 309adb13..3f008ccd 100644
--- a/locales/it.json
+++ b/locales/it.json
@@ -469,8 +469,8 @@
"Spanish (auto-generated)": "Spagnolo (generati automaticamente)",
"Spanish (Mexico)": "Spagnolo (Messico)",
"Spanish (Spain)": "Spagnolo (Spagna)",
- "Turkish (auto-generated)": "Turco (auto-generato)",
- "Vietnamese (auto-generated)": "Vietnamita (auto-generato)",
+ "Turkish (auto-generated)": "Turco (generati automaticamente)",
+ "Vietnamese (auto-generated)": "Vietnamita (generati automaticamente)",
"search_filters_date_label": "Data caricamento",
"search_filters_date_option_none": "Qualunque data",
"search_filters_type_option_all": "Qualunque tipo",
@@ -513,5 +513,7 @@
"The Popular feed has been disabled by the administrator.": "La sezione dei contenuti popolari è stata disabilitata dall'amministratore.",
"carousel_slide": "Fotogramma {{current}} di {{total}}",
"carousel_skip": "Salta la galleria",
- "carousel_go_to": "Vai al fotogramma `x`"
+ "carousel_go_to": "Vai al fotogramma `x`",
+ "preferences_preload_label": "Precarica dati video: ",
+ "Filipino (auto-generated)": "Filippino (generati automaticamente)"
}
diff --git a/locales/ja.json b/locales/ja.json
index 7fc9d604..5e90148d 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -479,5 +479,7 @@
"carousel_go_to": "スライド`x`を表示",
"carousel_slide": "スライド{{current}} / 全{{total}}個中",
"carousel_skip": "画像のスライド表示をスキップ",
- "toggle_theme": "テーマの切り替え"
+ "toggle_theme": "テーマの切り替え",
+ "preferences_preload_label": "動画データを事前に読み込む: ",
+ "Filipino (auto-generated)": "フィリピノ語 (自動生成)"
}
diff --git a/locales/ko.json b/locales/ko.json
index 4864860a..c2d3f6e2 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -70,7 +70,7 @@
"Next page": "다음 페이지",
"last": "마지막",
"Shared `x` ago": "`x` 전",
- "popular": "인기",
+ "popular": "인기순",
"oldest": "과거순",
"newest": "최신순",
"View playlist on YouTube": "유튜브에서 재생목록 보기",
@@ -479,5 +479,6 @@
"carousel_go_to": "`x` 슬라이드로 이동",
"Search for videos": "비디오 검색",
"toggle_theme": "테마 전환",
- "carousel_slide": "{{total}}의 슬라이드 {{current}}"
+ "carousel_slide": "{{total}}의 슬라이드 {{current}}",
+ "preferences_preload_label": "비디오 데이터 사전 로드: "
}
diff --git a/locales/nb-NO.json b/locales/nb-NO.json
index 17d64baf..38402fed 100644
--- a/locales/nb-NO.json
+++ b/locales/nb-NO.json
@@ -496,5 +496,6 @@
"Add to playlist": "Legg til i spilleliste",
"Add to playlist: ": "Legg til i spilleliste: ",
"The Popular feed has been disabled by the administrator.": "Populært-kilden er koblet ut av administratoren.",
- "toggle_theme": "Endre utseende"
+ "toggle_theme": "Endre utseende",
+ "preferences_preload_label": "Last videodata på forhånd: "
}
diff --git a/locales/nl.json b/locales/nl.json
index f10b3593..a908e26a 100644
--- a/locales/nl.json
+++ b/locales/nl.json
@@ -496,5 +496,7 @@
"Answer": "Antwoorden",
"Search for videos": "Naar video's zoeken",
"carousel_skip": "Carousel overslaan",
- "toggle_theme": "Thema omschakelen"
+ "toggle_theme": "Thema omschakelen",
+ "preferences_preload_label": "Videogegevens vooraf laden: ",
+ "Filipino (auto-generated)": "Filipijns (automatisch gegenereerd)"
}
diff --git a/locales/pl.json b/locales/pl.json
index 73d65647..b119ab22 100644
--- a/locales/pl.json
+++ b/locales/pl.json
@@ -513,5 +513,7 @@
"Add to playlist: ": "Dodaj do playlisty: ",
"carousel_slide": "Slajd {{current}} z {{total}}",
"carousel_skip": "Pomiń karuzelę",
- "carousel_go_to": "Przejdź do slajdu `x`"
+ "carousel_go_to": "Przejdź do slajdu `x`",
+ "preferences_preload_label": "Wstępne ładowanie danych wideo: ",
+ "Filipino (auto-generated)": "filipiński (wygenerowany automatycznie)"
}
diff --git a/locales/pt-BR.json b/locales/pt-BR.json
index 1d29d2fe..3d653caf 100644
--- a/locales/pt-BR.json
+++ b/locales/pt-BR.json
@@ -513,5 +513,7 @@
"Answer": "Resposta",
"carousel_slide": "Slide {{current}} de {{total}}",
"carousel_skip": "Ignorar carrossel",
- "carousel_go_to": "Ir ao slide `x`"
+ "carousel_go_to": "Ir ao slide `x`",
+ "preferences_preload_label": "Pré-carregar dados do vídeo: ",
+ "Filipino (auto-generated)": "Filipino (gerado automaticamente)"
}
diff --git a/locales/pt.json b/locales/pt.json
index 0bb1be66..9c8562f2 100644
--- a/locales/pt.json
+++ b/locales/pt.json
@@ -513,5 +513,7 @@
"carousel_slide": "Diapositivo {{current}} de{{total}}",
"carousel_skip": "Ignorar carrossel",
"carousel_go_to": "Ir para o diapositivo`x`",
- "The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador."
+ "The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador.",
+ "preferences_preload_label": "Pré-carregamento dos dados: ",
+ "Filipino (auto-generated)": "Filipino (gerado automaticamente)"
}
diff --git a/locales/ru.json b/locales/ru.json
index 80c98de8..b7dc91cf 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -11,6 +11,7 @@
"last": "последние",
"Next page": "Следующая страница",
"Previous page": "Предыдущая страница",
+ "First page": "Первая страница",
"Clear watch history?": "Очистить историю просмотров?",
"New password": "Новый пароль",
"New passwords must match": "Новые пароли не совпадают",
@@ -48,8 +49,8 @@
"preferences_category_player": "Настройки проигрывателя",
"preferences_video_loop_label": "Всегда повторять: ",
"preferences_autoplay_label": "Автовоспроизведение: ",
- "preferences_continue_label": "Переходить к следующему видео? ",
- "preferences_continue_autoplay_label": "Автопроигрывание следующего видео: ",
+ "preferences_continue_label": "Воспроизводить следующее видео: ",
+ "preferences_continue_autoplay_label": "Автовоспроизведение следующего видео: ",
"preferences_listen_label": "Режим «только аудио» по умолчанию: ",
"preferences_local_label": "Проигрывать видео через прокси? ",
"preferences_speed_label": "Скорость видео по умолчанию: ",
@@ -513,5 +514,6 @@
"toggle_theme": "Переключатель тем",
"carousel_slide": "Пролистано {{current}} из {{total}}",
"carousel_skip": "Пропустить всё",
- "carousel_go_to": "Перейти к странице `x`"
+ "carousel_go_to": "Перейти к странице `x`",
+ "preferences_preload_label": "Предзагрузка видеоданных: "
}
diff --git a/locales/sl.json b/locales/sl.json
index 3803d09c..c36ad522 100644
--- a/locales/sl.json
+++ b/locales/sl.json
@@ -13,7 +13,7 @@
"Import and Export Data": "Uvoz in izvoz podatkov",
"Import": "Uvozi",
"Import Invidious data": "Uvozi Invidious JSON podatke",
- "Import YouTube subscriptions": "Uvozi YouTube/OPML naročnine",
+ "Import YouTube subscriptions": "Uvozi YouTube CSV ali OPML naročnine",
"Import FreeTube subscriptions (.db)": "Uvozi FreeTube (.db) naročnine",
"Import NewPipe data (.zip)": "Uvozi NewPipe (.zip) podatke",
"Export": "Izvozi",
@@ -105,7 +105,7 @@
"Show more": "Pokaži več",
"Switch Invidious Instance": "Preklopi Invidious instanco",
"search_message_change_filters_or_query": "Poskusi razširiti iskalno poizvedbo in/ali spremeniti filtre.",
- "search_message_use_another_instance": " Lahko tudi <a href=\"`x`\">iščeš v drugi istanci</a>.",
+ "search_message_use_another_instance": "Lahko tudi <a href=\"`x`\">iščeš v drugi istanci</a>.",
"Wilson score: ": "Wilsonov rezultat: ",
"Engagement: ": "Sodelovanje: ",
"Blacklisted regions: ": "Regije na seznamu nedovoljenih: ",
@@ -462,7 +462,7 @@
"search_filters_features_option_four_k": "4K",
"search_filters_features_option_hdr": "HDR",
"next_steps_error_message_refresh": "Osveži",
- "search_filters_date_option_hour": "Zadnja ura",
+ "search_filters_date_option_hour": "V zadnji uri",
"search_filters_features_option_purchased": "Kupljeno",
"search_filters_sort_label": "Razvrsti po",
"search_filters_sort_option_views": "številu ogledov",
@@ -521,5 +521,16 @@
"generic_channels_count_1": "{{count}} kanala",
"generic_channels_count_2": "{{count}} kanali",
"generic_channels_count_3": "{{count}} kanalov",
- "Import YouTube watch history (.json)": "Uvozi zgodovino gledanja YouTube (.json)"
+ "Import YouTube watch history (.json)": "Uvozi zgodovino gledanja YouTube (.json)",
+ "Add to playlist": "Dodaj na seznam predvajanja",
+ "Add to playlist: ": "Dodaj na seznam predvajanja: ",
+ "Search for videos": "Iskanje videoposnetkov",
+ "The Popular feed has been disabled by the administrator.": "Administrator je onemogočil priljubljeni vir.",
+ "Answer": "Odgovor",
+ "Filipino (auto-generated)": "filipinščina (samodejno ustvarjeno)",
+ "toggle_theme": "Preklopi temo",
+ "carousel_slide": "Diapozitiv {{current}} od {{total}}",
+ "carousel_skip": "Preskoči galerijo",
+ "carousel_go_to": "Pojdi na diapozitiv `x`",
+ "preferences_preload_label": "Predhodno naloži video podatke: "
}
diff --git a/locales/sq.json b/locales/sq.json
index ea20ce56..2a404828 100644
--- a/locales/sq.json
+++ b/locales/sq.json
@@ -492,5 +492,7 @@
"The Popular feed has been disabled by the administrator.": "Prurja Popullore është çaktivizuar nga përgjegjësi.",
"carousel_skip": "Anashkaloje Rrotullamen",
"carousel_slide": "Diapozitiv {{current}} nga {{total}}",
- "carousel_go_to": "Kalo te diapozitivi `x`"
+ "carousel_go_to": "Kalo te diapozitivi `x`",
+ "Filipino (auto-generated)": "Filipineze (të prodhuara automatikisht)",
+ "preferences_preload_label": "Parangarko të dhëna videoje: "
}
diff --git a/locales/sr.json b/locales/sr.json
index d28b2459..1d54972c 100644
--- a/locales/sr.json
+++ b/locales/sr.json
@@ -513,5 +513,7 @@
"Answer": "Odgovor",
"Search for videos": "Pretražite video snimke",
"carousel_skip": "Preskoči karusel",
- "toggle_theme": "Подеси тему"
+ "toggle_theme": "Подеси тему",
+ "preferences_preload_label": "Unapred učitaj podatke o video snimku: ",
+ "Filipino (auto-generated)": "Filipinski (automatski generisano)"
}
diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json
index 483e7fc4..e5279c8a 100644
--- a/locales/sr_Cyrl.json
+++ b/locales/sr_Cyrl.json
@@ -513,5 +513,7 @@
"Add to playlist: ": "Додајте на плејлисту: ",
"carousel_skip": "Прескочи карусел",
"The Popular feed has been disabled by the administrator.": "Администратор је онемогућио фид „Популарно“.",
- "carousel_slide": "Слајд {{current}} од {{total}}"
+ "carousel_slide": "Слајд {{current}} од {{total}}",
+ "preferences_preload_label": "Унапред учитај податке о видео снимку: ",
+ "Filipino (auto-generated)": "Филипински (аутоматски генерисано)"
}
diff --git a/locales/sv-SE.json b/locales/sv-SE.json
index f1313a4d..614132e0 100644
--- a/locales/sv-SE.json
+++ b/locales/sv-SE.json
@@ -496,5 +496,7 @@
"The Popular feed has been disabled by the administrator.": "Det populära flödet har inaktiverats av administratören.",
"carousel_slide": "Bildspel {{current}} av {{total}}",
"carousel_skip": "Hoppa över karusellen",
- "carousel_go_to": "Gå till bildspel `x`"
+ "carousel_go_to": "Gå till bildspel `x`",
+ "preferences_preload_label": "Förladda video data: ",
+ "Filipino (auto-generated)": "Filippinska (auto-genererad)"
}
diff --git a/locales/ta.json b/locales/ta.json
new file mode 100644
index 00000000..89e58668
--- /dev/null
+++ b/locales/ta.json
@@ -0,0 +1,502 @@
+{
+ "Add to playlist": "பிளேலிச்ட்டில் சேர்க்கவும்",
+ "generic_channels_count": "{{count}} சேனல்",
+ "generic_channels_count_plural": "{{count}} சேனல்கள்",
+ "generic_views_count": "{{count}} பார்வை",
+ "generic_views_count_plural": "{{count}} காட்சிகள்",
+ "generic_videos_count": "{{count}} வீடியோ",
+ "generic_videos_count_plural": "{{count}} வீடியோக்கள்",
+ "generic_playlists_count": "{{count}} பிளேலிச்ட்",
+ "generic_playlists_count_plural": "{{count}} பிளேலிச்ட்கள்",
+ "generic_subscribers_count": "{{count}} சந்தாதாரர்",
+ "generic_subscribers_count_plural": "{{count}} சந்தாதாரர்கள்",
+ "generic_button_delete": "நீக்கு",
+ "generic_button_rss": "ஆர்.எச்.எச்",
+ "LIVE": "வாழ",
+ "Shared `x` ago": "`X` முன்பு பகிரப்பட்டது",
+ "Unsubscribe": "குழுவிலகவும்",
+ "View playlist on YouTube": "யூடியூப்பில் பிளேலிச்ட்டைக் காண்க",
+ "newest": "புதியது",
+ "oldest": "பழமையானது",
+ "popular": "மக்கள்",
+ "last": "கடைசி",
+ "Next page": "அடுத்த பக்கம்",
+ "Previous page": "முந்தைய பக்கம்",
+ "Clear watch history?": "தெளிவான கண்காணிப்பு வரலாறு?",
+ "New password": "புதிய கடவுச்சொல்",
+ "New passwords must match": "புதிய கடவுச்சொற்கள் பொருந்த வேண்டும்",
+ "Authorize token?": "கிள்ளாக்கை அங்கீகரிக்கவா?",
+ "Yes": "ஆம்",
+ "Import YouTube playlist (.csv)": "யூடியூப் பிளேலிச்ட்டை இறக்குமதி செய்க (.csv)",
+ "Import YouTube watch history (.json)": "YouTube வாட்ச் வரலாற்றை இறக்குமதி செய்க (.json)",
+ "Import Invidious data": "வன்கவர்வு சாதொபொகு தரவை இறக்குமதி செய்க",
+ "Import YouTube subscriptions": "YouTube காபிம அல்லது OPML சந்தாக்களை இறக்குமதி செய்க",
+ "Import FreeTube subscriptions (.db)": "ஃப்ரீட்யூப் சந்தாக்களை இறக்குமதி செய்க (.db)",
+ "Import NewPipe data (.zip)": "நியூபைப் தரவை இறக்குமதி செய்க (.zip)",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML ஆக சந்தாக்களை ஏற்றுமதி செய்யுங்கள் (நியூபைப் & ஃப்ரீட்யூப்பிற்கு)",
+ "Export subscriptions as OPML": "OPML ஆக சந்தாக்களை ஏற்றுமதி செய்யுங்கள்",
+ "Export data as JSON": "சாதொபொகு ஆக வன்கவர்வு தரவை ஏற்றுமதி செய்யுங்கள்",
+ "Delete account?": "கணக்கை நீக்கவா?",
+ "History": "வரலாறு",
+ "JavaScript license information": "சாவாச்கிரிப்ட் உரிம செய்தி",
+ "source": "மூலம்",
+ "An alternative front-end to YouTube": "YouTube க்கு ஒரு மாற்று முன் இறுதியில்",
+ "Log in": "புகுபதிகை",
+ "Log in/register": "உள்நுழைக/பதிவு செய்யுங்கள்",
+ "User ID": "பயனர் ஐடி",
+ "Password": "கடவுச்சொல்",
+ "Time (h:mm:ss):": "நேரம் (h: மிமீ: எச்எச்):",
+ "Sign In": "விடுபதிகை",
+ "Register": "பதிவு செய்யுங்கள்",
+ "E-mail": "மின்னஞ்சல்",
+ "Preferences": "விருப்பத்தேர்வுகள்",
+ "preferences_preload_label": "வீடியோ தரவை முன்பே ஏற்றவும்: ",
+ "preferences_autoplay_label": "தன்னியக்க: ",
+ "preferences_continue_label": "இயல்பாக அடுத்து விளையாடுங்கள்: ",
+ "preferences_local_label": "பதிலாள் வீடியோக்கள்: ",
+ "preferences_watch_history_label": "கண்காணிப்பு வரலாற்றை இயக்கு: ",
+ "preferences_speed_label": "இயல்புநிலை வேகம்: ",
+ "preferences_quality_label": "விருப்பமான வீடியோ தரம்: ",
+ "preferences_quality_dash_label": "விருப்பமான கோடு வீடியோ தரம்: ",
+ "preferences_quality_dash_option_auto": "தானி",
+ "preferences_quality_dash_option_best": "சிறந்த",
+ "preferences_quality_dash_option_worst": "மோசமான",
+ "preferences_quality_dash_option_4320p": "4320 ப",
+ "preferences_quality_dash_option_1080p": "1080 ப",
+ "preferences_quality_dash_option_720p": "720 ஆ",
+ "preferences_quality_dash_option_480p": "480 ப",
+ "preferences_quality_dash_option_360p": "360 ப",
+ "preferences_quality_dash_option_144p": "144 ப",
+ "preferences_volume_label": "பிளேயர் தொகுதி: ",
+ "preferences_comments_label": "இயல்புநிலை கருத்துகள்: ",
+ "Fallback captions: ": "குறைவடையும் தலைப்புகள்: ",
+ "preferences_captions_label": "இயல்புநிலை தலைப்புகள்: ",
+ "preferences_related_videos_label": "தொடர்புடைய வீடியோக்களைக் காட்டு: ",
+ "preferences_annotations_label": "முன்னிருப்பாக சிறுகுறிப்புகளைக் காட்டு: ",
+ "preferences_vr_mode_label": "ஊடாடும் 360 டிகிரி வீடியோக்கள் (வெப்சிஎல் தேவை): ",
+ "preferences_category_visual": "காட்சி விருப்பத்தேர்வுகள்",
+ "light": "ஒளி",
+ "preferences_thin_mode_label": "மெல்லிய பயன்முறை: ",
+ "preferences_category_misc": "இதர விருப்பத்தேர்வுகள்",
+ "preferences_category_subscription": "சந்தா விருப்பத்தேர்வுகள்",
+ "preferences_annotations_subscribed_label": "சந்தா சேனல்களுக்கு முன்னிருப்பாக சிறுகுறிப்புகளைக் காட்டவா? ",
+ "Redirect homepage to feed: ": "உணவளிக்க முகப்புப்பக்கத்தை திருப்பி விடுங்கள்: ",
+ "preferences_sort_label": "வீடியோக்களை வரிசைப்படுத்துங்கள்: ",
+ "published": "வெளியிடப்பட்டது",
+ "published - reverse": "வெளியிடப்பட்டது - தலைகீழ்",
+ "alphabetically": "அகரவரிசை",
+ "preferences_unseen_only_label": "கவனக்குறைவாக மட்டுமே காட்டுங்கள்: ",
+ "preferences_notifications_only_label": "அறிவிப்புகளைக் காட்டுங்கள் (ஏதேனும் இருந்தால்): ",
+ "Enable web notifications": "வலை அறிவிப்புகளை இயக்கவும்",
+ "`x` is live": "`x` நேரலையில்",
+ "preferences_category_data": "தரவு விருப்பத்தேர்வுகள்",
+ "Manage subscriptions": "சந்தாக்களை நிர்வகிக்கவும்",
+ "Watch history": "வரலாற்றைப் பாருங்கள்",
+ "Delete account": "கணக்கை நீக்கு",
+ "preferences_category_admin": "நிர்வாகி விருப்பத்தேர்வுகள்",
+ "preferences_default_home_label": "இயல்புநிலை முகப்புப்பக்கம்: ",
+ "preferences_feed_menu_label": "ஊட்ட மெனு: ",
+ "preferences_show_nick_label": "மேலே புனைப்பெயரைக் காட்டு: ",
+ "Top enabled: ": "மேலே இயக்கப்பட்டது: ",
+ "CAPTCHA enabled: ": "கேப்ட்சா இயக்கப்பட்டது: ",
+ "Login enabled: ": "உள்நுழைவு இயக்கப்பட்டது: ",
+ "Registration enabled: ": "பதிவு இயக்கப்பட்டது: ",
+ "Report statistics: ": "அறிக்கை புள்ளிவிவரங்கள்: ",
+ "Save preferences": "விருப்பங்களை சேமிக்கவும்",
+ "Subscription manager": "சந்தா மேலாளர்",
+ "Token manager": "கிள்ளாக்கு மேலாளர்",
+ "Token": "கிள்ளாக்கு",
+ "search": "தேடல்",
+ "Released under the AGPLv3 on Github.": "கிட்அப்பில் AgPlv3 இன் கீழ் வெளியிடப்பட்டது.",
+ "View JavaScript license information.": "சாவாச்கிரிப்ட் உரிமத் தகவலைக் காண்க.",
+ "View privacy policy.": "தனியுரிமைக் கொள்கையைக் காண்க.",
+ "Trending": "டிரெண்டிங்",
+ "Public": "பொது",
+ "Unlisted": "பட்டியலிடப்படாதது",
+ "Private": "தனிப்பட்ட",
+ "View all playlists": "அனைத்து பிளேலிச்ட்களையும் காண்க",
+ "Updated `x` ago": "`X` முன்பு புதுப்பிக்கப்பட்டது",
+ "Delete playlist `x`?": "பிளேலிச்ட்டை நீக்கவா?",
+ "Playlist privacy": "பிளேலிச்ட் தனியுரிமை",
+ "Watch on YouTube": "YouTube இல் பாருங்கள்",
+ "Hide annotations": "சிறுகுறிப்புகளை மறைக்கவும்",
+ "Show replies": "பதில்களைக் காட்டு",
+ "Incorrect password": "தவறான கடவுச்சொல்",
+ "Wrong answer": "தவறான பதில்",
+ "Erroneous CAPTCHA": "தவறான கேப்ட்சா",
+ "CAPTCHA is a required field": "கேப்ட்சா ஒரு தேவையான புலம்",
+ "User ID is a required field": "பயனர் ஐடி தேவையான புலம்",
+ "Password is a required field": "கடவுச்சொல் தேவையான புலம்",
+ "Password cannot be empty": "கடவுச்சொல் காலியாக இருக்க முடியாது",
+ "Please log in": "தயவுசெய்து உள்நுழைக",
+ "This channel does not exist.": "இந்த சேனல் இல்லை.",
+ "Could not get channel info.": "சேனல் தகவலைப் பெற முடியவில்லை.",
+ "Could not fetch comments": "கருத்துகளைப் பெற முடியவில்லை",
+ "comments_points_count": "{{count}} புள்ளி",
+ "comments_points_count_plural": "{{count}} புள்ளிகள்",
+ "Could not create mix.": "கலவையை உருவாக்க முடியவில்லை.",
+ "Empty playlist": "வெற்று பிளேலிச்ட்",
+ "Not a playlist.": "ஒரு பிளேலிச்ட் அல்ல.",
+ "Playlist does not exist.": "பிளேலிச்ட் இல்லை.",
+ "Could not pull trending pages.": "பிரபலமான பக்கங்களை இழுக்க முடியவில்லை.",
+ "Erroneous challenge": "தவறான அறைகூவல்",
+ "Erroneous token": "தவறான கிள்ளாக்கு",
+ "No such user": "அத்தகைய பயனர் இல்லை",
+ "Token is expired, please try again": "கிள்ளாக்கு காலாவதியானது, தயவுசெய்து மீண்டும் முயற்சிக்கவும்",
+ "English": "ஆங்கிலம்",
+ "English (United States)": "ஆங்கிலம் (ஐக்கிய அமெரிக்க)",
+ "English (United Kingdom)": "ஆங்கிலம் (ஐக்கிய முடியரசு)",
+ "English (auto-generated)": "ஆங்கிலம் (தானாக உருவாக்கப்பட்ட)",
+ "Afrikaans": "ஆப்பிரிக்கா",
+ "Albanian": "அல்பேனிய",
+ "Amharic": "அம்ஆரிக்",
+ "Arabic": "அரபு",
+ "Armenian": "ஆர்மீனியன்",
+ "Azerbaijani": "அசர்பைசானி",
+ "Bangla": "பாங்லா",
+ "Basque": "பாச்க்",
+ "Belarusian": "பெலாருசியன்",
+ "Bosnian": "போச்னிய",
+ "Bulgarian": "பல்கேரியன்",
+ "Burmese": "பர்மீச்",
+ "Cantonese (Hong Kong)": "கான்டோனீச் (ஆங்காங்)",
+ "Catalan": "கற்றலான்",
+ "Cebuano": "செபுவானோ",
+ "Chinese": "சீன",
+ "Chinese (China)": "சீன (சீனா)",
+ "Chinese (Hong Kong)": "சீன (ஆங்காங்)",
+ "Chinese (Simplified)": "சீன (எளிமைப்படுத்தப்பட்ட)",
+ "Chinese (Taiwan)": "சீன (தைவான்)",
+ "Chinese (Traditional)": "சீன (பாரம்பரிய)",
+ "Dutch": "டச்சு",
+ "Finnish": "பின்னிச்",
+ "French": "பிரஞ்சு",
+ "German (auto-generated)": "செர்மன் (தானாக உருவாக்கப்பட்ட)",
+ "Greek": "கிரேக்கம்",
+ "Gujarati": "குசராத்தி",
+ "Haitian Creole": "ஐட்டிய கிரியோல்",
+ "Hungarian": "அங்கேரியன்",
+ "Icelandic": "ஐச்லாந்திய",
+ "Igbo": "இக்போ",
+ "Korean (auto-generated)": "கொரிய (தானாக உருவாக்கப்பட்ட)",
+ "Macedonian": "மாசிடோனியன்",
+ "Malagasy": "மலகாசி",
+ "Maltese": "மால்டிச்",
+ "Maori": "மௌரி",
+ "Malayalam": "மலையாளம்",
+ "Marathi": "மராத்தி",
+ "Mongolian": "மங்கோலியன்",
+ "Nepali": "நேபாளி",
+ "Norwegian Bokmål": "நார்வேசியன் பொக்மால்",
+ "Nyanja": "நயன்சா",
+ "Russian": "ரச்ய",
+ "Russian (auto-generated)": "ரச்ய (தானாக உருவாக்கப்பட்ட)",
+ "Samoan": "சமோவான்",
+ "Scottish Gaelic": "ச்கோட்டிச் கயாலிக்",
+ "Serbian": "செர்பிய",
+ "Shona": "சோனா",
+ "Sindhi": "சிந்தி",
+ "Somali": "சோமாலி",
+ "Southern Sotho": "தெற்கத்திய சோதோ",
+ "Spanish": "ச்பானிச்",
+ "Spanish (auto-generated)": "ச்பானிச் (தானாக உருவாக்கப்பட்ட)",
+ "Sundanese": "சுந்தானியர்கள்",
+ "Swahili": "ச்வாஇலி",
+ "Swedish": "ச்வீடிச்",
+ "Tajik": "தசிக்",
+ "Tamil": "தமிழ்",
+ "Thai": "தாய்",
+ "Turkish": "துருக்கிய",
+ "Vietnamese": "வியட்நாமிய",
+ "Welsh": "வேல்ச்",
+ "Xhosa": "ஓசா",
+ "Yiddish": "யெட்டிச்",
+ "Yoruba": "யோருபா",
+ "Top": "மேலே",
+ "About": "பற்றி",
+ "View as playlist": "பிளேலிச்ட்டாக காண்க",
+ "Gaming": "கேமிங்",
+ "News": "செய்தி",
+ "Movies": "திரைப்படங்கள்",
+ "Download as: ": "என பதிவிறக்கவும்: ",
+ "Download is disabled": "பதிவிறக்கம் முடக்கப்பட்டுள்ளது",
+ "(edited)": "(திருத்தப்பட்டது)",
+ "YouTube comment permalink": "YouTube கருத்து பெர்மாலின்க்",
+ "`x` marked it with a ❤": "`x` அதை a உடன் குறித்தது",
+ "Video mode": "வீடியோ பயன்முறை",
+ "Playlists": "பிளேலிச்ட்கள்",
+ "search_filters_date_option_today": "இன்று",
+ "search_filters_date_option_week": "இந்த வாரம்",
+ "search_filters_date_option_month": "இந்த மாதம்",
+ "search_filters_type_option_channel": "வாய்க்கால்",
+ "search_filters_type_option_playlist": "பிளேலிச்ட்",
+ "search_filters_duration_label": "காலம்",
+ "search_filters_duration_option_none": "எந்த காலமும்",
+ "search_filters_duration_option_medium": "நடுத்தர (4 - 20 நிமிடங்கள்)",
+ "search_filters_duration_option_long": "நீண்ட (> 20 நிமிடங்கள்)",
+ "search_filters_features_label": "நற்பொருத்தங்கள்",
+ "search_filters_features_option_four_k": "எச்.சி.",
+ "search_filters_features_option_live": "நேரடி",
+ "search_filters_features_option_hd": "எச்டி",
+ "search_filters_features_option_subtitles": "வசன வரிகள்/சிசி",
+ "search_filters_features_option_c_commons": "கிரியேட்டிவ் காமன்ச்",
+ "search_filters_features_option_three_sixty": "360 °",
+ "search_filters_features_option_three_d": "ZD",
+ "search_filters_features_option_hdr": "எச்.டி.ஆர்",
+ "search_filters_features_option_location": "இடம்",
+ "search_filters_sort_option_relevance": "பொருத்தமானது",
+ "search_filters_sort_option_rating": "செயல்வரம்பு",
+ "Current version: ": "தற்போதைய பதிப்பு: ",
+ "next_steps_error_message": "அதன் பிறகு நீங்கள் முயற்சி செய்ய வேண்டும்: ",
+ "next_steps_error_message_refresh": "புதுப்பிப்பு",
+ "next_steps_error_message_go_to_youtube": "YouTube க்குச் செல்லுங்கள்",
+ "footer_donate_page": "நன்கொடை",
+ "footer_modfied_source_code": "மாற்றியமைக்கப்பட்ட மூலக் குறியீடு",
+ "adminprefs_modified_source_code_url_label": "மாற்றியமைக்கப்பட்ட மூலக் குறியீடு களஞ்சியத்திற்கு முகவரி",
+ "videoinfo_started_streaming_x_ago": "`X` முன்பு ச்ட்ரீமிங் செய்யத் தொடங்கியது",
+ "videoinfo_watch_on_youTube": "YouTube இல் பாருங்கள்",
+ "download_subtitles": "வசன வரிகள் - `x` (.vtt)",
+ "user_created_playlists": "`x` உருவாக்கியது பிளேலிச்ட்கள்",
+ "user_saved_playlists": "`x` சேமித்த பிளேலிச்ட்கள்",
+ "crash_page_before_reporting": "ஒரு பிழையைப் புகாரளிப்பதற்கு முன், உங்களிடம் இருப்பதை உறுதிப்படுத்திக் கொள்ளுங்கள்:",
+ "crash_page_switch_instance": "<a href = \"` x` \"> மற்றொரு நிகழ்வைப் பயன்படுத்த முயற்சித்தேன் </a>",
+ "crash_page_search_issue": "அறிவிலிமையத்தில் உள்ள <a href=\"`x`\"> தற்போதைய சிக்கல்களைத் தேடியது</a>",
+ "channel_tab_shorts_label": "குறுக்குகள்",
+ "channel_tab_streams_label": "லைவ்ச்ட்ரீம்கள்",
+ "carousel_go_to": "`X` ச்லைடு செல்லவும்",
+ "Popular": "புகழ்பெற்ற",
+ "Subscribe": "குழுசேர்",
+ "View channel on YouTube": "YouTube இல் சேனலைக் காண்க",
+ "Authorize token for `x`?": "`X` க்கு கிள்ளாக்கை அங்கீகரிக்கவா?",
+ "No": "இல்லை",
+ "Add to playlist: ": "பிளேலிச்ட்டில் சேர்க்கவும்: ",
+ "Answer": "பதில்",
+ "Search for videos": "வீடியோக்களைத் தேடுங்கள்",
+ "The Popular feed has been disabled by the administrator.": "பிரபலமான ஊட்டத்தை நிர்வாகியால் முடக்கப்பட்டுள்ளது.",
+ "generic_subscriptions_count": "{{count}} சந்தா",
+ "generic_subscriptions_count_plural": "{{count}} சந்தாக்கள்",
+ "generic_button_edit": "தொகு",
+ "generic_button_save": "சேமி",
+ "generic_button_cancel": "ரத்துசெய்",
+ "Import and Export Data": "தரவை இறக்குமதி செய்து ஏற்றுமதி செய்யுங்கள்",
+ "Import": "இறக்குமதி",
+ "Import NewPipe subscriptions (.json)": "நியூபிப்பிப் சந்தாக்களை இறக்குமதி செய்யுங்கள் (.json)",
+ "Export": "ஏற்றுமதி",
+ "Text CAPTCHA": "உரை கேப்ட்சா",
+ "Image CAPTCHA": "பட கேப்ட்சா",
+ "preferences_category_player": "பிளேயர் விருப்பத்தேர்வுகள்",
+ "preferences_video_loop_label": "எப்போதும் லூப்: ",
+ "preferences_continue_autoplay_label": "தன்னியக்க அடுத்த வீடியோ: ",
+ "preferences_listen_label": "இயல்பாக கேளுங்கள்: ",
+ "preferences_quality_option_dash": "கோடு (தகவமைப்பு தரம்)",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_medium": "சராசரி",
+ "preferences_quality_option_small": "சிறிய",
+ "preferences_quality_dash_option_2160p": "2160 ப",
+ "preferences_quality_dash_option_1440p": "1440 ப",
+ "preferences_quality_dash_option_240p": "240 ப",
+ "youtube": "YouTube",
+ "reddit": "ரெடிட்",
+ "invidious": "வெகுவாக",
+ "preferences_extend_desc_label": "வீடியோ விளக்கத்தை தானாக நீட்டிக்கவும்: ",
+ "preferences_region_label": "உள்ளடக்க நாடு: ",
+ "preferences_player_style_label": "பிளேயர் ச்டைல்: ",
+ "Dark mode: ": "இருண்ட முறை: ",
+ "preferences_dark_mode_label": "தீம்: ",
+ "dark": "இருண்ட",
+ "preferences_automatic_instance_redirect_label": "தானியங்கி நிகழ்வு திசைதிருப்பல் (redirect.invidious.io க்கு குறைவடையும்): ",
+ "preferences_max_results_label": "ஊட்டத்தில் காட்டப்பட்டுள்ள வீடியோக்களின் எண்ணிக்கை: ",
+ "alphabetically - reverse": "அகரவரிசை - தலைகீழ்",
+ "channel name": "சேனல் பெயர்",
+ "channel name - reverse": "சேனல் பெயர் - தலைகீழ்",
+ "Only show latest video from channel: ": "சேனலில் இருந்து அண்மைக் கால வீடியோவைக் காட்டுங்கள்: ",
+ "Only show latest unwatched video from channel: ": "சேனலில் இருந்து அண்மைக் கால கவனிக்கப்படாத வீடியோவைக் காட்டுங்கள்: ",
+ "`x` uploaded a video": "`x` ஒரு வீடியோவைப் பதிவேற்றியது",
+ "Clear watch history": "தெளிவான கண்காணிப்பு வரலாறு",
+ "Log out": "விடுபதிகை",
+ "Source available here.": "சான்று இங்கே கிடைக்கிறது.",
+ "Delete playlist": "பிளேலிச்ட்டை நீக்கு",
+ "Create playlist": "பிளேலிச்ட்டை உருவாக்கவும்",
+ "Title": "தலைப்பு",
+ "Import/export data": "தரவு இறக்குமதி/ஏற்றுமதி",
+ "Change password": "கடவுச்சொல்லை மாற்றவும்",
+ "Manage tokens": "டோக்கன்களை நிர்வகிக்கவும்",
+ "Popular enabled: ": "பிரபலமான இயக்கப்பட்டது: ",
+ "tokens_count": "{{count}} கிள்ளாக்கு",
+ "tokens_count_plural": "{{count}} டோக்கன்கள்",
+ "Import/export": "இறக்குமதி/ஏற்றுமதி",
+ "unsubscribe": "குழுவிலகவும்",
+ "revoke": "ரத்து செய்யுங்கள்",
+ "Subscriptions": "சந்தாக்கள்",
+ "subscriptions_unseen_notifs_count": "{{count}} காணப்படாத அறிவிப்பு",
+ "subscriptions_unseen_notifs_count_plural": "{{count}} காணப்படாத அறிவிப்புகள்",
+ "Editing playlist `x`": "பிளேலிச்ட்டைத் திருத்துதல் `x`",
+ "playlist_button_add_items": "வீடியோக்களைச் சேர்க்கவும்",
+ "Show more": "மேலும் காட்டு",
+ "Show less": "குறைவாகக் காட்டு",
+ "Switch Invidious Instance": "அக்யோர்ட் உதாரணத்தை மாற்றவும்",
+ "search_message_no_results": "முடிவுகள் எதுவும் கிடைக்கவில்லை.",
+ "search_message_change_filters_or_query": "உங்கள் தேடல் வினவலை அகலப்படுத்த முயற்சிக்கவும்/அல்லது வடிப்பான்களை மாற்றவும்.",
+ "search_message_use_another_instance": "நீங்கள் <a href = \"` x` \"> மற்றொரு நிகழ்வில் தேடலாம் </a>.",
+ "Show annotations": "சிறுகுறிப்புகளைக் காட்டு",
+ "Genre: ": "வகை: ",
+ "License: ": "உரிமம்: ",
+ "Standard YouTube license": "நிலையான YouTube உரிமம்",
+ "Family friendly? ": "குடும்ப நட்பு? ",
+ "Wilson score: ": "வில்சன் மதிப்பெண்: ",
+ "Engagement: ": "நிச்சயதார்த்தம்: ",
+ "Whitelisted regions: ": "அனுமதிப்பட்டிய பகுதிகள்: ",
+ "Blacklisted regions: ": "தடுப்புப்பட்டியாக்கப்பட்ட பகுதிகள்: ",
+ "Music in this video": "இந்த வீடியோவில் இசை",
+ "Artist: ": "கலைஞர்: ",
+ "Song: ": "பாடல்: ",
+ "Album: ": "ஆல்பம்: ",
+ "Shared `x`": "பகிரப்பட்டது `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": "YouTube கருத்துகளைக் காண்க",
+ "View more comments on Reddit": "ரெடிட் குறித்த கூடுதல் கருத்துகளைக் காண்க",
+ "View `x` comments": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`X` கருத்தைக் காண்க",
+ "": "`X` கருத்துகளைக் காண்க"
+ },
+ "View Reddit comments": "ரெடிட் கருத்துகளைக் காண்க",
+ "Hide replies": "பதில்களை மறைக்கவும்",
+ "Wrong username or password": "தவறான பயனர்பெயர் அல்லது கடவுச்சொல்",
+ "Password cannot be longer than 55 characters": "கடவுச்சொல் 55 எழுத்துகளை விட நீளமாக இருக்க முடியாது",
+ "Invidious Private Feed for `x`": "`X` க்கான மோசமான தனியார் ஊட்டம்",
+ "channel:`x`": "சேனல்: `x`",
+ "Deleted or invalid channel": "நீக்கப்பட்ட அல்லது தவறான சேனல்",
+ "comments_view_x_replies": "{{count}} பதிலைக் காண்க",
+ "comments_view_x_replies_plural": "{{count}} பதில்களைக் காண்க",
+ "`x` ago": "`x` முன்பு",
+ "Load more": "மேலும் ஏற்றவும்",
+ "Hidden field \"challenge\" is a required field": "மறைக்கப்பட்ட புலம் \"அறைகூவல்\" என்பது தேவையான புலம்",
+ "Hidden field \"token\" is a required field": "மறைக்கப்பட்ட புலம் \"கிள்ளாக்கு\" என்பது தேவையான புலம்",
+ "Corsican": "கார்சிகன்",
+ "Croatian": "குரோசியன்",
+ "Czech": "செக்",
+ "Danish": "டேனிச்",
+ "Dutch (auto-generated)": "டச்சு (தானாக உருவாக்கப்பட்ட)",
+ "Esperanto": "எச்பெராண்டோ",
+ "Estonian": "எச்டோனிய",
+ "Filipino": "ஃபிலிபினோ",
+ "Filipino (auto-generated)": "பிலிப்பைன்ச் (தானாக உருவாக்கிய)",
+ "French (auto-generated)": "பிரஞ்சு (தானாக உருவாக்கப்பட்ட)",
+ "Galician": "காலிசியன்",
+ "Georgian": "சார்சியன்",
+ "German": "செர்மன்",
+ "Hausa": "ஔசா",
+ "Lao": "லாவோ",
+ "Latin": "லத்தீன்",
+ "Latvian": "லாட்வியன்",
+ "Hawaiian": "அவாயியன்",
+ "Hebrew": "எபிரேய",
+ "Lithuanian": "லிதுவேனியன்",
+ "Hindi": "இந்தி",
+ "Hmong": "அமோங்",
+ "Indonesian": "இந்தோனேசிய",
+ "Indonesian (auto-generated)": "இந்தோனேசிய (தானாக உருவாக்கப்பட்ட)",
+ "Interlingue": "இன்டர்லின்குய்",
+ "Irish": "ஐரிச்",
+ "Italian": "இத்தாலிய",
+ "Italian (auto-generated)": "இத்தாலியன் (தானாக உருவாக்கப்பட்ட)",
+ "Japanese": "சப்பானியர்கள்",
+ "Japanese (auto-generated)": "சப்பானிய (தானாக உருவாக்கப்பட்ட)",
+ "Javanese": "சாவானீச்",
+ "Kannada": "கன்னடா",
+ "Kazakh": "கசாக்",
+ "Khmer": "கெமர்",
+ "Korean": "கொரிய",
+ "Kurdish": "குர்திச்",
+ "Kyrgyz": "கிர்கிச்",
+ "Luxembourgish": "லக்சம்போர்கிச்",
+ "Malay": "மலாய்",
+ "Pashto": "பச்தோ",
+ "Persian": "பெர்சியன்",
+ "Polish": "போலீச்",
+ "Portuguese": "போர்த்துகீசியம்",
+ "Portuguese (auto-generated)": "போர்த்துகீசியம் (தானாக உருவாக்கிய)",
+ "generic_count_minutes": "{{count}} மணித்துளி",
+ "generic_count_minutes_plural": "{{count}} நிமிடங்கள்",
+ "generic_count_seconds": "{{count}} இரண்டாவது",
+ "generic_count_seconds_plural": "{{count}} வினாடிகள்",
+ "Fallback comments: ": "குறைவடையும் கருத்துரைகள்: ",
+ "Portuguese (Brazil)": "போர்த்துகீசியம் (பிரேசில்)",
+ "Punjabi": "பஞ்சாபி",
+ "Romanian": "ருமேனிய",
+ "Sinhala": "சிங்களம்",
+ "Slovak": "ச்லோவாக்",
+ "Slovenian": "ச்லோவேனியன்",
+ "Spanish (Latin America)": "ச்பானிச் (லத்தீன் அமெரிக்கா)",
+ "Spanish (Mexico)": "ச்பானிச் (மெக்சிகோ)",
+ "Spanish (Spain)": "ச்பானிச் (ச்பெயின்)",
+ "Telugu": "தெலுங்கு",
+ "Turkish (auto-generated)": "துருக்கிய (தானாக உருவாக்கிய)",
+ "Ukrainian": "உக்ரேனிய",
+ "Urdu": "உருது",
+ "Uzbek": "உச்பெக்",
+ "Vietnamese (auto-generated)": "வியட்நாமிய (தானாக உருவாக்கப்பட்ட)",
+ "Western Frisian": "மேற்கு ஃபிரிசியன்",
+ "Zulu": "சுலு",
+ "generic_count_years": "{{count}}} ஆண்டு",
+ "generic_count_years_plural": "{{count}} ஆண்டுகள்",
+ "generic_count_months": "{{count}} மாதம்",
+ "generic_count_months_plural": "{{count}} மாதங்கள்",
+ "generic_count_weeks": "{{count}}} வாரம்",
+ "generic_count_weeks_plural": "{{count}} வாரங்கள்",
+ "generic_count_days": "{{count}}} நாள்",
+ "generic_count_days_plural": "{{count}} நாட்கள்",
+ "generic_count_hours": "{{count}} மணிநேரம்",
+ "generic_count_hours_plural": "{{count}} மணிநேரம்",
+ "Search": "தேடல்",
+ "Rating: ": "மதிப்பீடு: ",
+ "preferences_locale_label": "மொழி: ",
+ "Default": "இயல்புநிலை",
+ "Music": "இசை",
+ "Download": "பதிவிறக்கம்",
+ "%A %B %-d, %Y": "%A %b %-d, %y",
+ "permalink": "பெர்மாலின்க்",
+ "Channel Sponsor": "சேனல் ஒப்புரவாளர்",
+ "Audio mode": "ஆடியோ பயன்முறை",
+ "search_filters_duration_option_short": "குறுகிய (<4 நிமிடங்கள்)",
+ "search_filters_title": "வடிப்பான்கள்",
+ "search_filters_date_label": "தேதி பதிவேற்றும் தேதி",
+ "search_filters_date_option_none": "எந்த தேதி",
+ "search_filters_date_option_hour": "கடைசி மணி",
+ "search_filters_date_option_year": "இந்த ஆண்டு",
+ "search_filters_type_label": "வகை",
+ "search_filters_type_option_all": "எந்த வகை",
+ "search_filters_type_option_video": "ஒளிதோற்றம்",
+ "search_filters_type_option_movie": "படம்",
+ "search_filters_type_option_show": "காட்டு",
+ "search_filters_features_option_vr180": "VR180",
+ "search_filters_features_option_purchased": "வாங்கப்பட்டது",
+ "search_filters_sort_label": "வரிசைப்படுத்தவும்",
+ "search_filters_sort_option_date": "பதிவேற்ற தேதி",
+ "search_filters_sort_option_views": "எண்ணிக்கை காண்க",
+ "search_filters_apply_button": "தேர்ந்தெடுக்கப்பட்ட வடிப்பான்களைப் பயன்படுத்துங்கள்",
+ "footer_documentation": "ஆவணப்படுத்துதல்",
+ "footer_source_code": "மூலக் குறியீடு",
+ "footer_original_source_code": "அசல் மூலக் குறியீடு",
+ "none": "எதுவுமில்லை",
+ "videoinfo_youTube_embed_link": "உட்பொதிக்கப்பட்டது",
+ "videoinfo_invidious_embed_link": "உட்பொதிப்பு இணைப்பு",
+ "Video unavailable": "வீடியோ கிடைக்கவில்லை",
+ "preferences_save_player_pos_label": "பிளேபேக் நிலையை சேமிக்கவும்: ",
+ "crash_page_you_found_a_bug": "நீங்கள் ஒரு பிழையை கண்டுபிடித்ததாகத் தெரிகிறது!",
+ "crash_page_refresh": "<a href = \"` x` \"> பக்கத்தை புதுப்பிக்க முயற்சித்தேன் </a>",
+ "crash_page_read_the_faq": "<a href = \"` x` \"> அடிக்கடி கேட்கப்படும் கேள்விகள் (கேள்விகள்) </a> ஐப் படியுங்கள்",
+ "crash_page_report_issue": "மேலே எதுவும் உதவவில்லை என்றால், தயவுசெய்து <a href = \"` x` \"> அறிவிலிமையம் </a> (முன்னுரிமை ஆங்கிலத்தில்) ஒரு புதிய சிக்கலைத் திறந்து உங்கள் செய்தியில் பின்வரும் உரையைச் சேர்க்கவும் (அந்த உரையை மொழிபெயர்க்க வேண்டாம்):",
+ "error_video_not_in_playlist": "கோரப்பட்ட வீடியோ இந்த பிளேலிச்ட்டில் இல்லை. <a href = \"` x` \"> பிளேலிச்ட் முகப்பு பக்கத்திற்கு இங்கே சொடுக்கு செய்க. </a>",
+ "channel_tab_videos_label": "வீடியோக்கள்",
+ "channel_tab_podcasts_label": "பாட்காச்ட்கள்",
+ "channel_tab_releases_label": "வெளியீடுகள்",
+ "channel_tab_playlists_label": "பிளேலிச்ட்கள்",
+ "channel_tab_community_label": "சமூகம்",
+ "channel_tab_channels_label": "சேனல்கள்",
+ "toggle_theme": "கருப்பொருளை மாற்றவும்",
+ "carousel_slide": "{{total}} இன் ச்லைடு {{current}}",
+ "carousel_skip": "கொணர்வி தவிர்க்கவும்"
+}
diff --git a/locales/tok.json b/locales/tok.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/locales/tok.json
@@ -0,0 +1 @@
+{}
diff --git a/locales/tr.json b/locales/tr.json
index 282cbf88..94c127db 100644
--- a/locales/tr.json
+++ b/locales/tr.json
@@ -496,5 +496,6 @@
"carousel_slide": "Sunum {{current}} / {{total}}",
"carousel_skip": "Kayar menüyü atla",
"carousel_go_to": "`x` sunumuna git",
- "The Popular feed has been disabled by the administrator.": "Popüler akışı yönetici tarafından devre dışı bırakıldı."
+ "The Popular feed has been disabled by the administrator.": "Popüler akışı yönetici tarafından devre dışı bırakıldı.",
+ "preferences_preload_label": "Video verilerini önceden yükle: "
}
diff --git a/locales/uk.json b/locales/uk.json
index 64329032..2472f247 100644
--- a/locales/uk.json
+++ b/locales/uk.json
@@ -513,5 +513,7 @@
"The Popular feed has been disabled by the administrator.": "Стрічка Популярні вимкнена адміністратором.",
"carousel_slide": "Слайд {{current}} з {{total}}",
"carousel_skip": "Пропустити карусель",
- "carousel_go_to": "Перейти до слайда `x`"
+ "carousel_go_to": "Перейти до слайда `x`",
+ "preferences_preload_label": "Попереднє завантаження відеоданих: ",
+ "Filipino (auto-generated)": "Філіппінська (згенеровано автоматично)"
}
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index 776c5ddb..2024bdd5 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -479,5 +479,7 @@
"The Popular feed has been disabled by the administrator.": "“流行”源已被管理员禁用。",
"carousel_slide": "当前为第 {{current}} 张图,共 {{total}} 张图",
"carousel_skip": "跳过图集",
- "carousel_go_to": "转到图 `x`"
+ "carousel_go_to": "转到图 `x`",
+ "preferences_preload_label": "预加载视频数据: ",
+ "Filipino (auto-generated)": "菲律宾语 (自动生成)"
}
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index 1e17deb6..b3d67130 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -479,5 +479,7 @@
"carousel_slide": "第 {{current}} 張投影片,共 {{total}} 張",
"carousel_skip": "略過輪播",
"carousel_go_to": "跳到投影片 `x`",
- "The Popular feed has been disabled by the administrator.": "熱門 feed 已被管理員停用。"
+ "The Popular feed has been disabled by the administrator.": "熱門 feed 已被管理員停用。",
+ "preferences_preload_label": "預先載入影片資訊 ",
+ "Filipino (auto-generated)": "菲律賓語(自動產生)"
}
diff --git a/src/invidious.cr b/src/invidious.cr
index b11137f6..52df77be 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -192,8 +192,9 @@ if CONFIG.popular_enabled
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
end
-CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32)
-Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL, CONFIG.database_url)
+NOTIFICATION_CHANNEL = ::Channel(VideoNotification).new(32)
+CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32)
+Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(NOTIFICATION_CHANNEL, CONNECTION_CHANNEL, CONFIG.database_url)
Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new
diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr
index 1478c8fc..65982325 100644
--- a/src/invidious/channels/channels.cr
+++ b/src/invidious/channels/channels.cr
@@ -249,11 +249,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
if was_insert
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
- if CONFIG.enable_user_notifications
- Invidious::Database::Users.add_notification(video)
- else
- Invidious::Database::Users.feed_needs_update(video)
- end
+ NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video))
else
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated")
end
@@ -285,11 +281,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
if Time.utc - video.published > 1.minute
was_insert = Invidious::Database::ChannelVideos.insert(video)
if was_insert
- if CONFIG.enable_user_notifications
- Invidious::Database::Users.add_notification(video)
- else
- Invidious::Database::Users.feed_needs_update(video)
- end
+ NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video))
end
end
end
diff --git a/src/invidious/config.cr b/src/invidious/config.cr
index a9b78686..453256b5 100644
--- a/src/invidious/config.cr
+++ b/src/invidious/config.cr
@@ -193,6 +193,9 @@ class Config
config = Config.from_yaml(config_yaml)
# Update config from env vars (upcased and prefixed with "INVIDIOUS_")
+ #
+ # Also checks if any top-level config options are set to "CHANGE_ME!!"
+ # TODO: Support non-top-level config options such as the ones in DBConfig
{% for ivar in Config.instance_vars %}
{% env_id = "INVIDIOUS_#{ivar.id.upcase}" %}
@@ -229,6 +232,12 @@ class Config
exit(1)
end
end
+
+ # Warn when any config attribute is set to "CHANGE_ME!!"
+ if config.{{ivar.id}} == "CHANGE_ME!!"
+ puts "Config: The value of '#{ {{ivar.stringify}} }' needs to be changed!!"
+ exit(1)
+ end
{% end %}
# HMAC_key is mandatory
@@ -236,9 +245,6 @@ class Config
if config.hmac_key.empty?
puts "Config: 'hmac_key' is required/can't be empty"
exit(1)
- elsif config.hmac_key == "CHANGE_ME!!"
- puts "Config: The value of 'hmac_key' needs to be changed!!"
- exit(1)
end
# Build database_url from db.* if it's not set directly
diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr
index d54e6a76..4a3056ea 100644
--- a/src/invidious/database/users.cr
+++ b/src/invidious/database/users.cr
@@ -119,15 +119,15 @@ module Invidious::Database::Users
# Update (notifs)
# -------------------
- def add_notification(video : ChannelVideo)
+ def add_multiple_notifications(channel_id : String, video_ids : Array(String))
request = <<-SQL
UPDATE users
- SET notifications = array_append(notifications, $1),
+ SET notifications = array_cat(notifications, $1),
feed_needs_update = true
WHERE $2 = ANY(subscriptions)
SQL
- PG_DB.exec(request, video.id, video.ucid)
+ PG_DB.exec(request, video_ids, channel_id)
end
def remove_notification(user : User, vid : String)
@@ -154,14 +154,14 @@ module Invidious::Database::Users
# Update (misc)
# -------------------
- def feed_needs_update(video : ChannelVideo)
+ def feed_needs_update(channel_id : String)
request = <<-SQL
UPDATE users
SET feed_needs_update = true
WHERE $1 = ANY(subscriptions)
SQL
- PG_DB.exec(request, video.ucid)
+ PG_DB.exec(request, channel_id)
end
def update_preferences(user : User)
diff --git a/src/invidious/frontend/pagination.cr b/src/invidious/frontend/pagination.cr
index 3f931f4e..a29f5936 100644
--- a/src/invidious/frontend/pagination.cr
+++ b/src/invidious/frontend/pagination.cr
@@ -3,6 +3,24 @@ require "uri"
module Invidious::Frontend::Pagination
extend self
+ private def first_page(str : String::Builder, locale : String?, url : String)
+ str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
+
+ if locale_is_rtl?(locale)
+ # Inverted arrow ("first" points to the right)
+ str << translate(locale, "First page")
+ str << "&nbsp;&nbsp;"
+ str << %(<i class="icon ion-ios-arrow-forward"></i>)
+ else
+ # Regular arrow ("first" points to the left)
+ str << %(<i class="icon ion-ios-arrow-back"></i>)
+ str << "&nbsp;&nbsp;"
+ str << translate(locale, "First page")
+ end
+
+ str << "</a>"
+ end
+
private def previous_page(str : String::Builder, locale : String?, url : String)
# Link
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
@@ -72,18 +90,24 @@ module Invidious::Frontend::Pagination
end
end
- def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?)
+ def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?, first_page : Bool, params : URI::Params)
return String.build do |str|
str << %(<div class="h-box">\n)
str << %(<div class="page-nav-container flexible">\n)
- str << %(<div class="page-prev-container flex-left"></div>\n)
+ str << %(<div class="page-prev-container flex-left">)
+
+ if !first_page
+ self.first_page(str, locale, base_url.to_s)
+ end
+
+ str << %(</div>\n)
str << %(<div class="page-next-container flex-right">)
if !ctoken.nil?
- params_next = URI::Params{"continuation" => ctoken}
- url_next = HttpServer::Utils.add_params_to_url(base_url, params_next)
+ params["continuation"] = ctoken
+ url_next = HttpServer::Utils.add_params_to_url(base_url, params)
self.next_page(str, locale, url_next.to_s)
end
diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr
index c8cb7110..2e2f6ad0 100644
--- a/src/invidious/frontend/watch_page.cr
+++ b/src/invidious/frontend/watch_page.cr
@@ -13,7 +13,7 @@ module Invidious::Frontend::WatchPage
@full_videos,
@video_streams,
@audio_streams,
- @captions
+ @captions,
)
end
end
diff --git a/src/invidious/helpers/crystal_class_overrides.cr b/src/invidious/helpers/crystal_class_overrides.cr
index 3040d7a0..fec3f62c 100644
--- a/src/invidious/helpers/crystal_class_overrides.cr
+++ b/src/invidious/helpers/crystal_class_overrides.cr
@@ -18,40 +18,6 @@ end
class HTTP::Client
property family : Socket::Family = Socket::Family::UNSPEC
- # Override stdlib to automatically initialize proxy if configured
- #
- # Accurate as of crystal 1.12.1
-
- def initialize(@host : String, port = nil, tls : TLSContext = nil)
- check_host_only(@host)
-
- {% if flag?(:without_openssl) %}
- if tls
- raise "HTTP::Client TLS is disabled because `-D without_openssl` was passed at compile time"
- end
- @tls = nil
- {% else %}
- @tls = case tls
- when true
- OpenSSL::SSL::Context::Client.new
- when OpenSSL::SSL::Context::Client
- tls
- when false, nil
- nil
- end
- {% end %}
-
- @port = (port || (@tls ? 443 : 80)).to_i
-
- self.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy
- end
-
- def initialize(@io : IO, @host = "", @port = 80)
- @reconnect = false
-
- self.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy
- end
-
private def io
io = @io
return io if io
diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr
index b7643194..900cb0c6 100644
--- a/src/invidious/helpers/errors.cr
+++ b/src/invidious/helpers/errors.cr
@@ -130,7 +130,7 @@ def error_json_helper(
env : HTTP::Server::Context,
status_code : Int32,
exception : Exception,
- additional_fields : Hash(String, Object) | Nil = nil
+ additional_fields : Hash(String, Object) | Nil = nil,
)
if exception.is_a?(InfoException)
return error_json_helper(env, status_code, exception.message || "", additional_fields)
@@ -152,7 +152,7 @@ def error_json_helper(
env : HTTP::Server::Context,
status_code : Int32,
message : String,
- additional_fields : Hash(String, Object) | Nil = nil
+ additional_fields : Hash(String, Object) | Nil = nil,
)
env.response.content_type = "application/json"
env.response.status_code = status_code
diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr
index f3e3b951..13ea9fe9 100644
--- a/src/invidious/helpers/handlers.cr
+++ b/src/invidious/helpers/handlers.cr
@@ -27,6 +27,7 @@ class Kemal::RouteHandler
# Processes the route if it's a match. Otherwise renders 404.
private def process_request(context)
raise Kemal::Exceptions::RouteNotFound.new(context) unless context.route_found?
+ return if context.response.closed?
content = context.route.handler.call(context)
if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(context.response.status_code) && exclude_match?(context)
diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr
index 1ba3ea61..bca2edda 100644
--- a/src/invidious/helpers/i18n.cr
+++ b/src/invidious/helpers/i18n.cr
@@ -54,6 +54,7 @@ LOCALES_LIST = {
"sr" => "Srpski (latinica)", # Serbian (Latin)
"sr_Cyrl" => "Српски (ћирилица)", # Serbian (Cyrillic)
"sv-SE" => "Svenska", # Swedish
+ "ta" => "தமிழ்", # Tamil
"tr" => "Türkçe", # Turkish
"uk" => "Українська", # Ukrainian
"vi" => "Tiếng Việt", # Vietnamese
diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr
index 1fef5f93..f8e8f187 100644
--- a/src/invidious/helpers/serialized_yt_data.cr
+++ b/src/invidious/helpers/serialized_yt_data.cr
@@ -24,6 +24,7 @@ struct SearchVideo
property length_seconds : Int32
property premiere_timestamp : Time?
property author_verified : Bool
+ property author_thumbnail : String?
property badges : VideoBadges
def to_xml(auto_generated, query_params, xml : XML::Builder)
@@ -88,6 +89,24 @@ struct SearchVideo
json.field "authorUrl", "/channel/#{self.ucid}"
json.field "authorVerified", self.author_verified
+ author_thumbnail = self.author_thumbnail
+
+ if author_thumbnail
+ json.field "authorThumbnails" do
+ json.array do
+ qualities = {32, 48, 76, 100, 176, 512}
+
+ qualities.each do |quality|
+ json.object do
+ json.field "url", author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
+ json.field "width", quality
+ json.field "height", quality
+ end
+ end
+ end
+ end
+ end
+
json.field "videoThumbnails" do
Invidious::JSONify::APIv1.thumbnails(json, self.id)
end
@@ -223,7 +242,7 @@ struct SearchChannel
qualities.each do |quality|
json.object do
- json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}")
+ json.field "url", self.author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
json.field "width", quality
json.field "height", quality
end
diff --git a/src/invidious/jobs/notification_job.cr b/src/invidious/jobs/notification_job.cr
index b445107b..f2c9d4be 100644
--- a/src/invidious/jobs/notification_job.cr
+++ b/src/invidious/jobs/notification_job.cr
@@ -1,8 +1,32 @@
+struct VideoNotification
+ getter video_id : String
+ getter channel_id : String
+ getter published : Time
+
+ def_hash @channel_id, @video_id
+
+ def ==(other)
+ video_id == other.video_id
+ end
+
+ def self.from_video(video : ChannelVideo) : self
+ VideoNotification.new(video.id, video.ucid, video.published)
+ end
+
+ def initialize(@video_id, @channel_id, @published)
+ end
+
+ def clone : VideoNotification
+ VideoNotification.new(video_id.clone, channel_id.clone, published.clone)
+ end
+end
+
class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob
+ private getter notification_channel : ::Channel(VideoNotification)
private getter connection_channel : ::Channel({Bool, ::Channel(PQ::Notification)})
private getter pg_url : URI
- def initialize(@connection_channel, @pg_url)
+ def initialize(@notification_channel, @connection_channel, @pg_url)
end
def begin
@@ -10,6 +34,70 @@ class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob
PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) }
+ # hash of channels to their videos (id+published) that need notifying
+ to_notify = Hash(String, Set(VideoNotification)).new(
+ ->(hash : Hash(String, Set(VideoNotification)), key : String) {
+ hash[key] = Set(VideoNotification).new
+ }
+ )
+ notify_mutex = Mutex.new()
+
+ # fiber to locally cache all incoming notifications (from pubsub webhooks and refresh channels job)
+ spawn do
+ begin
+ loop do
+ notification = notification_channel.receive
+ notify_mutex.synchronize do
+ to_notify[notification.channel_id] << notification
+ end
+ end
+ end
+ end
+ # fiber to regularly persist all cached notifications
+ spawn do
+ loop do
+ begin
+ LOGGER.debug("NotificationJob: waking up")
+ cloned = {} of String => Set(VideoNotification)
+ notify_mutex.synchronize do
+ cloned = to_notify.clone
+ to_notify.clear
+ end
+
+ cloned.each do |channel_id, notifications|
+ if notifications.empty?
+ next
+ end
+
+ LOGGER.info("NotificationJob: updating channel #{channel_id} with #{notifications.size} notifications")
+ if CONFIG.enable_user_notifications
+ video_ids = notifications.map { |n| n.video_id }
+ Invidious::Database::Users.add_multiple_notifications(channel_id, video_ids)
+ PG_DB.using_connection do |conn|
+ notifications.each do |n|
+ # Deliver notifications to `/api/v1/auth/notifications`
+ payload = {
+ "topic" => n.channel_id,
+ "videoId" => n.video_id,
+ "published" => n.published.to_unix,
+ }.to_json
+ conn.exec("NOTIFY notifications, E'#{payload}'")
+ end
+ end
+ else
+ Invidious::Database::Users.feed_needs_update(channel_id)
+ end
+ end
+
+ LOGGER.trace("NotificationJob: Done, sleeping")
+ rescue ex
+ LOGGER.error("NotificationJob: #{ex.message}")
+ end
+ sleep 1.minute
+ Fiber.yield
+ end
+ end
+
loop do
action, connection = connection_channel.receive
diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr
index 08cd533f..3439ae60 100644
--- a/src/invidious/jsonify/api_v1/video_json.cr
+++ b/src/invidious/jsonify/api_v1/video_json.cr
@@ -267,6 +267,12 @@ module Invidious::JSONify::APIv1
json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i
json.field "viewCountText", rv["short_view_count"]?
json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64
+ json.field "published", rv["published"]?
+ if !rv["published"]?.nil?
+ json.field "publishedText", translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].to_s), locale))
+ else
+ json.field "publishedText", ""
+ end
end
end
end
diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr
index 823ca85b..28ff0ff6 100644
--- a/src/invidious/mixes.cr
+++ b/src/invidious/mixes.cr
@@ -81,7 +81,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
})
end
-def template_mix(mix)
+def template_mix(mix, listen)
html = <<-END_HTML
<h3>
<a href="/mix?list=#{mix["mixId"]}">
@@ -95,7 +95,7 @@ def template_mix(mix)
mix["videos"].as_a.each do |video|
html += <<-END_HTML
<li class="pure-menu-item">
- <a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}">
+ <a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}#{listen ? "&listen=1" : ""}">
<div class="thumbnail">
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr
index a51e88b4..b670c009 100644
--- a/src/invidious/playlists.cr
+++ b/src/invidious/playlists.cr
@@ -505,7 +505,7 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
return videos
end
-def template_playlist(playlist)
+def template_playlist(playlist, listen)
html = <<-END_HTML
<h3>
<a href="/playlist?list=#{playlist["playlistId"]}">
@@ -519,7 +519,7 @@ def template_playlist(playlist)
playlist["videos"].as_a.each do |video|
html += <<-END_HTML
<li class="pure-menu-item" id="#{video["videoId"]}">
- <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}">
+ <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}#{listen ? "&listen=1" : ""}">
<div class="thumbnail">
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr
index dd65e7a6..c8db207c 100644
--- a/src/invidious/routes/account.cr
+++ b/src/invidious/routes/account.cr
@@ -328,17 +328,9 @@ module Invidious::Routes::Account
end
end
- if env.params.query["action_revoke_token"]?
- action = "action_revoke_token"
- else
- return env.redirect referer
- end
-
- session = env.params.query["session"]?
- session ||= ""
-
- case action
- when .starts_with? "action_revoke_token"
+ case action = env.params.query["action"]?
+ when "revoke_token"
+ session = env.params.query["session"]
Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
else
return error_json(400, "Unsupported action #{action}")
diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr
index d89e752c..6c4225e5 100644
--- a/src/invidious/routes/api/manifest.cr
+++ b/src/invidious/routes/api/manifest.cr
@@ -27,28 +27,21 @@ module Invidious::Routes::API::Manifest
haltf env, status_code: response.status_code
end
- manifest = response.body
-
- manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl|
- url = baseurl.lchop("<BaseURL>")
- url = url.rchop("</BaseURL>")
-
- if local
- uri = URI.parse(url)
- url = "#{HOST_URL}#{uri.request_target}host/#{uri.host}/"
- end
-
+ # Proxy URLs for video playback on invidious.
+ # Other API clients can get the original URLs by omiting `local=true`.
+ manifest = response.body.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl|
+ url = baseurl.lchop("<BaseURL>").rchop("</BaseURL>")
+ url = HttpServer::Utils.proxy_video_url(url, absolute: true) if local
"<BaseURL>#{url}</BaseURL>"
end
return manifest
end
- adaptive_fmts = video.adaptive_fmts
-
+ # Ditto, only proxify URLs if `local=true` is used
if local
- adaptive_fmts.each do |fmt|
- fmt["url"] = JSON::Any.new("#{HOST_URL}#{URI.parse(fmt["url"].as_s).request_target}")
+ video.adaptive_fmts.each do |fmt|
+ fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s, absolute: true))
end
end
@@ -70,17 +63,23 @@ module Invidious::Routes::API::Manifest
# OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415)
next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange"))
+ audio_track = fmt["audioTrack"]?.try &.as_h? || {} of String => JSON::Any
+ lang = audio_track["id"]?.try &.as_s.split('.')[0] || "und"
+ is_default = audio_track.has_key?("audioIsDefault") ? audio_track["audioIsDefault"].as_bool : i == 0
+ displayname = audio_track["displayName"]?.try &.as_s || "Unknown"
+ bitrate = fmt["bitrate"]
+
# Different representations of the same audio should be groupped into one AdaptationSet.
# However, most players don't support auto quality switching, so we have to trick them
# into providing a quality selector.
# See https://github.com/iv-org/invidious/issues/3074 for more details.
- xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: fmt["bitrate"].to_s + "k") do
+ xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: "#{displayname} [#{bitrate}k]", lang: lang) do
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("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: i == 0 ? "main" : "alternate")
+ xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: is_default ? "main" : "alternate")
xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do
xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011",
@@ -177,8 +176,9 @@ module Invidious::Routes::API::Manifest
manifest = response.body
if local
- manifest = manifest.gsub(/^https:\/\/\w+---.{11}\.c\.youtube\.com[^\n]*/m) do |match|
- path = URI.parse(match).path
+ manifest = manifest.gsub(/https:\/\/[^\n"]*/m) do |match|
+ uri = URI.parse(match)
+ path = uri.path
path = path.lchop("/videoplayback/")
path = path.rchop("/")
@@ -207,7 +207,7 @@ module Invidious::Routes::API::Manifest
raw_params["fvip"] = fvip["fvip"]
end
- raw_params["local"] = "true"
+ raw_params["host"] = uri.host.not_nil!
"#{HOST_URL}/videoplayback?#{raw_params}"
end
diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr
index 093669fe..4f5b58da 100644
--- a/src/invidious/routes/api/v1/misc.cr
+++ b/src/invidious/routes/api/v1/misc.cr
@@ -42,6 +42,9 @@ module Invidious::Routes::API::V1::Misc
format = env.params.query["format"]?
format ||= "json"
+ listen_param = env.params.query["listen"]?
+ listen = (listen_param == "true" || listen_param == "1")
+
if plid.starts_with? "RD"
return env.redirect "/api/v1/mixes/#{plid}"
end
@@ -85,7 +88,7 @@ module Invidious::Routes::API::V1::Misc
end
if format == "html"
- playlist_html = template_playlist(json_response)
+ playlist_html = template_playlist(json_response, listen)
index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil}
response = {
@@ -111,6 +114,9 @@ module Invidious::Routes::API::V1::Misc
format = env.params.query["format"]?
format ||= "json"
+ listen_param = env.params.query["listen"]?
+ listen = (listen_param == "true" || listen_param == "1")
+
begin
mix = fetch_mix(rdid, continuation, locale: locale)
@@ -141,9 +147,7 @@ module Invidious::Routes::API::V1::Misc
json.field "authorUrl", "/channel/#{video.ucid}"
json.field "videoThumbnails" do
- json.array do
- Invidious::JSONify::APIv1.thumbnails(json, video.id)
- end
+ Invidious::JSONify::APIv1.thumbnails(json, video.id)
end
json.field "index", video.index
@@ -157,7 +161,7 @@ module Invidious::Routes::API::V1::Misc
if format == "html"
response = JSON.parse(response)
- playlist_html = template_mix(response)
+ playlist_html = template_mix(response, listen)
next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"]
response = {
diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr
index 368304ac..6a3eb8ae 100644
--- a/src/invidious/routes/api/v1/videos.cr
+++ b/src/invidious/routes/api/v1/videos.cr
@@ -429,4 +429,90 @@ module Invidious::Routes::API::V1::Videos
end
end
end
+
+ # Fetches transcripts from YouTube
+ #
+ # Use the `lang` and `autogen` query parameter to select which transcript to fetch
+ # Request without any URL parameters to see all the available transcripts.
+ def self.transcripts(env)
+ env.response.content_type = "application/json"
+
+ id = env.params.url["id"]
+ lang = env.params.query["lang"]?
+ label = env.params.query["label"]?
+ auto_generated = env.params.query["autogen"]? ? true : false
+
+ # Return all available transcript options when none is given
+ if !label && !lang
+ begin
+ video = get_video(id)
+ rescue ex : NotFoundException
+ return error_json(404, ex)
+ rescue ex
+ return error_json(500, ex)
+ end
+
+ response = JSON.build do |json|
+ # The amount of transcripts available to fetch is the
+ # same as the amount of captions available.
+ available_transcripts = video.captions
+
+ json.object do
+ json.field "transcripts" do
+ json.array do
+ available_transcripts.each do |transcript|
+ json.object do
+ json.field "label", transcript.name
+ json.field "languageCode", transcript.language_code
+ json.field "autoGenerated", transcript.auto_generated
+
+ if transcript.auto_generated
+ json.field "url", "/api/v1/transcripts/#{id}?lang=#{URI.encode_www_form(transcript.language_code)}&autogen"
+ else
+ json.field "url", "/api/v1/transcripts/#{id}?lang=#{URI.encode_www_form(transcript.language_code)}"
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ return response
+ end
+
+ # If lang is not given then we attempt to fetch
+ # the transcript through the given label
+ if lang.nil?
+ begin
+ video = get_video(id)
+ rescue ex : NotFoundException
+ return error_json(404, ex)
+ rescue ex
+ return error_json(500, ex)
+ end
+
+ target_transcript = video.captions.select(&.name.== label)
+ if target_transcript.empty?
+ return error_json(404, NotFoundException.new("Requested transcript does not exist"))
+ else
+ target_transcript = target_transcript[0]
+ lang, auto_generated = target_transcript.language_code, target_transcript.auto_generated
+ end
+ end
+
+ params = Invidious::Videos::Transcript.generate_param(id, lang, auto_generated)
+
+ begin
+ transcript = Invidious::Videos::Transcript.from_raw(
+ YoutubeAPI.get_transcript(params), lang, auto_generated
+ )
+ rescue ex : NotFoundException
+ return error_json(404, ex)
+ rescue ex
+ return error_json(500, ex)
+ end
+
+ return transcript.to_json
+ end
end
diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr
index 266f7ba4..00f24159 100644
--- a/src/invidious/routes/embed.cr
+++ b/src/invidious/routes/embed.cr
@@ -157,10 +157,12 @@ module Invidious::Routes::Embed
adaptive_fmts = video.adaptive_fmts
if params.local
- fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
- adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
+ fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
end
+ # Always proxy DASH streams, otherwise youtube CORS headers will prevent playback
+ adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
+
video_streams = video.video_streams
audio_streams = video.audio_streams
diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr
index ea7fb396..7f9a0edb 100644
--- a/src/invidious/routes/feeds.cr
+++ b/src/invidious/routes/feeds.cr
@@ -143,32 +143,25 @@ module Invidious::Routes::Feeds
# RSS feeds
def self.rss_channel(env)
- locale = env.get("preferences").as(Preferences).locale
-
env.response.headers["Content-Type"] = "application/atom+xml"
env.response.content_type = "application/atom+xml"
- ucid = env.params.url["ucid"]
+ if env.params.url["ucid"].matches?(/^[\w-]+$/)
+ ucid = env.params.url["ucid"]
+ else
+ return error_atom(400, InfoException.new("Invalid channel ucid provided."))
+ end
params = HTTP::Params.parse(env.params.query["params"]? || "")
- begin
- channel = get_about_info(ucid, locale)
- rescue ex : ChannelRedirect
- return env.redirect env.request.resource.gsub(ucid, ex.channel_id)
- rescue ex : NotFoundException
- return error_atom(404, ex)
- rescue ex
- return error_atom(500, ex)
- end
-
namespaces = {
"yt" => "http://www.youtube.com/xml/schemas/2015",
"media" => "http://search.yahoo.com/mrss/",
"default" => "http://www.w3.org/2005/Atom",
}
- response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}")
+ response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}")
+ return error_atom(404, NotFoundException.new("Channel does not exist.")) if response.status_code == 404
rss = XML.parse(response.body)
videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry|
@@ -179,7 +172,7 @@ module Invidious::Routes::Feeds
updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content)
author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content
- ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content
+ video_ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content
description_html = entry.xpath_node("media:group/media:description", namespaces).not_nil!.to_s
views = entry.xpath_node("media:group/media:community/media:statistics", namespaces).not_nil!.["views"].to_i64
@@ -187,41 +180,44 @@ module Invidious::Routes::Feeds
title: title,
id: video_id,
author: author,
- ucid: ucid,
+ ucid: video_ucid,
published: published,
views: views,
description_html: description_html,
length_seconds: 0,
premiere_timestamp: nil,
author_verified: false,
+ author_thumbnail: nil,
badges: VideoBadges::None,
})
end
+ author = ""
+ author = videos[0].author if videos.size > 0
+
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("id") { xml.text "yt:channel:#{channel.ucid}" }
- xml.element("yt:channelId") { xml.text channel.ucid }
- xml.element("icon") { xml.text channel.author_thumbnail }
- xml.element("title") { xml.text channel.author }
- xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}")
+ xml.element("id") { xml.text "yt:channel:#{ucid}" }
+ xml.element("yt:channelId") { xml.text ucid }
+ xml.element("title") { author }
+ xml.element("link", rel: "alternate", href: "#{HOST_URL}/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("name") { xml.text author }
+ xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" }
end
xml.element("image") do
- xml.element("url") { xml.text channel.author_thumbnail }
- xml.element("title") { xml.text channel.author }
+ xml.element("url") { xml.text "" }
+ xml.element("title") { xml.text author }
xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}")
end
videos.each do |video|
- video.to_xml(channel.auto_generated, params, xml)
+ video.to_xml(false, params, xml)
end
end
end
@@ -309,8 +305,9 @@ module Invidious::Routes::Feeds
end
response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}")
- document = XML.parse(response.body)
+ return error_atom(404, NotFoundException.new("Playlist does not exist.")) if response.status_code == 404
+ document = XML.parse(response.body)
document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node|
node.attributes.each do |attribute|
case attribute.name
@@ -423,16 +420,6 @@ module Invidious::Routes::Feeds
next # skip this video since it raised an exception (e.g. it is a scheduled live event)
end
- if CONFIG.enable_user_notifications
- # Deliver notifications to `/api/v1/auth/notifications`
- payload = {
- "topic" => video.ucid,
- "videoId" => video.id,
- "published" => published.to_unix,
- }.to_json
- PG_DB.exec("NOTIFY notifications, E'#{payload}'")
- end
-
video = ChannelVideo.new({
id: id,
title: video.title,
@@ -448,11 +435,7 @@ module Invidious::Routes::Feeds
was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true)
if was_insert
- if CONFIG.enable_user_notifications
- Invidious::Database::Users.add_notification(video)
- else
- Invidious::Database::Users.feed_needs_update(video)
- end
+ NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video))
end
end
end
diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr
index 9c6843e9..f2213da4 100644
--- a/src/invidious/routes/playlists.cr
+++ b/src/invidious/routes/playlists.cr
@@ -304,23 +304,6 @@ module Invidious::Routes::Playlists
end
end
- if env.params.query["action_create_playlist"]?
- action = "action_create_playlist"
- elsif env.params.query["action_delete_playlist"]?
- action = "action_delete_playlist"
- elsif env.params.query["action_edit_playlist"]?
- action = "action_edit_playlist"
- elsif env.params.query["action_add_video"]?
- action = "action_add_video"
- video_id = env.params.query["video_id"]
- elsif env.params.query["action_remove_video"]?
- action = "action_remove_video"
- elsif env.params.query["action_move_video_before"]?
- action = "action_move_video_before"
- else
- return env.redirect referer
- end
-
begin
playlist_id = env.params.query["playlist_id"]
playlist = get_playlist(playlist_id).as(InvidiousPlaylist)
@@ -335,12 +318,8 @@ module Invidious::Routes::Playlists
end
end
- email = user.email
-
- case action
- when "action_edit_playlist"
- # TODO: Playlist stub
- when "action_add_video"
+ case action = env.params.query["action"]?
+ when "add_video"
if playlist.index.size >= CONFIG.playlist_length_limit
if redirect
return error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
@@ -377,12 +356,14 @@ module Invidious::Routes::Playlists
Invidious::Database::PlaylistVideos.insert(playlist_video)
Invidious::Database::Playlists.update_video_added(playlist_id, playlist_video.index)
- when "action_remove_video"
+ when "remove_video"
index = env.params.query["set_video_id"]
Invidious::Database::PlaylistVideos.delete(index)
Invidious::Database::Playlists.update_video_removed(playlist_id, index)
- when "action_move_video_before"
+ when "move_video_before"
# TODO: Playlist stub
+ when nil
+ return error_json(400, "Missing action")
else
return error_json(400, "Unsupported action #{action}")
end
diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr
index 7f9ec592..1de655d2 100644
--- a/src/invidious/routes/subscriptions.cr
+++ b/src/invidious/routes/subscriptions.cr
@@ -32,24 +32,16 @@ module Invidious::Routes::Subscriptions
end
end
- if env.params.query["action_create_subscription_to_channel"]?.try &.to_i?.try &.== 1
- action = "action_create_subscription_to_channel"
- elsif env.params.query["action_remove_subscriptions"]?.try &.to_i?.try &.== 1
- action = "action_remove_subscriptions"
- else
- return env.redirect referer
- end
-
channel_id = env.params.query["c"]?
channel_id ||= ""
- case action
- when "action_create_subscription_to_channel"
+ case action = env.params.query["action"]?
+ when "create_subscription_to_channel"
if !user.subscriptions.includes? channel_id
get_channel(channel_id)
Invidious::Database::Users.subscribe_channel(user, channel_id)
end
- when "action_remove_subscriptions"
+ when "remove_subscriptions"
Invidious::Database::Users.unsubscribe_channel(user, channel_id)
else
return error_json(400, "Unsupported action #{action}")
diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr
index 26852d06..a8f9f665 100644
--- a/src/invidious/routes/video_playback.cr
+++ b/src/invidious/routes/video_playback.cr
@@ -164,10 +164,13 @@ module Invidious::Routes::VideoPlayback
env.response.headers["Access-Control-Allow-Origin"] = "*"
if location = resp.headers["Location"]?
- location = URI.parse(location)
- location = "#{location.request_target}&host=#{location.host}#{region ? "&region=#{region}" : ""}"
+ url = Invidious::HttpServer::Utils.proxy_video_url(location, region: region)
- env.redirect location
+ if title = query_params["title"]?
+ url = "#{url}&title=#{URI.encode_www_form(title)}"
+ end
+
+ env.redirect url
break
end
diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr
index aabe8dfc..1f384546 100644
--- a/src/invidious/routes/watch.cr
+++ b/src/invidious/routes/watch.cr
@@ -121,10 +121,12 @@ module Invidious::Routes::Watch
adaptive_fmts = video.adaptive_fmts
if params.local
- fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
- adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
+ fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
end
+ # Always proxy DASH streams, otherwise youtube CORS headers will prevent playback
+ adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
+
video_streams = video.video_streams
audio_streams = video.audio_streams
@@ -241,18 +243,10 @@ module Invidious::Routes::Watch
end
end
- if env.params.query["action_mark_watched"]?
- action = "action_mark_watched"
- elsif env.params.query["action_mark_unwatched"]?
- action = "action_mark_unwatched"
- else
- return env.redirect referer
- end
-
- case action
- when "action_mark_watched"
+ case action = env.params.query["action"]?
+ when "mark_watched"
Invidious::Database::Users.mark_watched(user, id)
- when "action_mark_unwatched"
+ when "mark_unwatched"
Invidious::Database::Users.mark_unwatched(user, id)
else
return error_json(400, "Unsupported action #{action}")
diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr
index 9009062f..902e0a30 100644
--- a/src/invidious/routing.cr
+++ b/src/invidious/routing.cr
@@ -236,6 +236,7 @@ module Invidious::Routing
get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations
get "/api/v1/comments/:id", {{namespace}}::Videos, :comments
get "/api/v1/clips/:id", {{namespace}}::Videos, :clips
+ get "/api/v1/transcripts/:id", {{namespace}}::Videos, :transcripts
# Feeds
get "/api/v1/trending", {{namespace}}::Feeds, :trending
diff --git a/src/invidious/search/filters.cr b/src/invidious/search/filters.cr
index bf968734..bc2715cf 100644
--- a/src/invidious/search/filters.cr
+++ b/src/invidious/search/filters.cr
@@ -75,7 +75,7 @@ module Invidious::Search
@type : Type = Type::All,
@duration : Duration = Duration::None,
@features : Features = Features::None,
- @sort : Sort = Sort::Relevance
+ @sort : Sort = Sort::Relevance,
)
end
diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr
index c8e8cf7f..94a92e23 100644
--- a/src/invidious/search/query.cr
+++ b/src/invidious/search/query.cr
@@ -47,7 +47,7 @@ module Invidious::Search
def initialize(
params : HTTP::Params,
@type : Type = Type::Regular,
- @region : String? = nil
+ @region : String? = nil,
)
# Get the raw search query string (common to all search types). In
# Regular search mode, also look for the `search_query` URL parameter
diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr
index 533c18d9..007eb666 100644
--- a/src/invidious/user/imports.cr
+++ b/src/invidious/user/imports.cr
@@ -290,42 +290,39 @@ struct Invidious::User
end
def from_newpipe(user : User, body : String) : Bool
- io = IO::Memory.new(body)
+ Compress::Zip::File.open(IO::Memory.new(body), true) do |file|
+ entry = file.entries.find { |file_entry| file_entry.filename == "newpipe.db" }
+ return false if entry.nil?
+ entry.open do |file_io|
+ # Ensure max size of 4MB
+ io_sized = IO::Sized.new(file_io, 0x400000)
- Compress::Zip::File.open(io) do |file|
- file.entries.each do |entry|
- entry.open do |file_io|
- # Ensure max size of 4MB
- io_sized = IO::Sized.new(file_io, 0x400000)
-
- next if entry.filename != "newpipe.db"
-
- tempfile = File.tempfile(".db")
-
- begin
- File.write(tempfile.path, io_sized.gets_to_end)
- rescue
- return false
- end
-
- db = DB.open("sqlite3://" + tempfile.path)
-
- user.watched += db.query_all("SELECT url FROM streams", as: String)
- .map(&.lchop("https://www.youtube.com/watch?v="))
+ begin
+ temp = File.tempfile(".db") do |tempfile|
+ begin
+ File.write(tempfile.path, io_sized.gets_to_end)
+ rescue
+ return false
+ end
- user.watched.uniq!
- Invidious::Database::Users.update_watch_history(user)
+ DB.open("sqlite3://" + tempfile.path) do |db|
+ user.watched += db.query_all("SELECT url FROM streams", as: String)
+ .map(&.lchop("https://www.youtube.com/watch?v="))
- user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String)
- .map(&.lchop("https://www.youtube.com/channel/"))
+ user.watched.uniq!
+ Invidious::Database::Users.update_watch_history(user)
- user.subscriptions.uniq!
- user.subscriptions = get_batch_channels(user.subscriptions)
+ user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String)
+ .map(&.lchop("https://www.youtube.com/channel/"))
- Invidious::Database::Users.update_subscriptions(user)
+ user.subscriptions.uniq!
+ user.subscriptions = get_batch_channels(user.subscriptions)
- db.close
- tempfile.delete
+ Invidious::Database::Users.update_subscriptions(user)
+ end
+ end
+ ensure
+ temp.delete if !temp.nil?
end
end
end
diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr
index ae09e736..962f87bd 100644
--- a/src/invidious/videos.cr
+++ b/src/invidious/videos.cr
@@ -106,7 +106,7 @@ struct Video
if formats = info.dig?("streamingData", "adaptiveFormats")
return formats
.as_a.map(&.as_h)
- .sort_by! { |f| f["width"]?.try &.as_i || 0 }
+ .sort_by! { |f| f["width"]?.try &.as_i || f["audioTrack"]?.try { |a| a["audioIsDefault"]?.try { |v| v.as_bool ? -1 : 0 } } || 0 }
else
return [] of Hash(String, JSON::Any)
end
diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr
index 915c9baf..5ca4bdb2 100644
--- a/src/invidious/videos/parser.cr
+++ b/src/invidious/videos/parser.cr
@@ -36,6 +36,13 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container")
+ if published_time_text = related["publishedTimeText"]?
+ decoded_time = decode_date(published_time_text["simpleText"].to_s)
+ published = decoded_time.to_rfc3339.to_s
+ else
+ published = nil
+ end
+
# TODO: when refactoring video types, make a struct for related videos
# or reuse an existing type, if that fits.
return {
@@ -47,6 +54,7 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
"view_count" => JSON::Any.new(view_count || "0"),
"short_view_count" => JSON::Any.new(short_view_count || "0"),
"author_verified" => JSON::Any.new(author_verified),
+ "published" => JSON::Any.new(published || ""),
}
end
diff --git a/src/invidious/videos/storyboard.cr b/src/invidious/videos/storyboard.cr
index a72c2f55..bd0eef59 100644
--- a/src/invidious/videos/storyboard.cr
+++ b/src/invidious/videos/storyboard.cr
@@ -20,7 +20,7 @@ module Invidious::Videos
def initialize(
*, @url, @width, @height, @count, @interval,
- @rows, @columns, @images_count
+ @rows, @columns, @images_count,
)
authority = /(i\d?).ytimg.com/.match!(@url.host.not_nil!)[1]?
diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr
index 4bd9f820..ee1272d1 100644
--- a/src/invidious/videos/transcript.cr
+++ b/src/invidious/videos/transcript.cr
@@ -122,5 +122,40 @@ module Invidious::Videos
return vtt
end
+
+ def to_json(json : JSON::Builder)
+ json.field "languageCode", @language_code
+ json.field "autoGenerated", @auto_generated
+ json.field "label", @label
+ json.field "body" do
+ json.array do
+ @lines.each do |line|
+ json.object do
+ if line.is_a? HeadingLine
+ json.field "type", "heading"
+ else
+ json.field "type", "regular"
+ end
+
+ json.field "startMs", line.start_ms.total_milliseconds
+ json.field "endMs", line.end_ms.total_milliseconds
+ json.field "line", line.line
+ end
+ end
+ end
+ end
+ end
+
+ def to_json
+ JSON.build do |json|
+ json.object do
+ json.field "transcript" do
+ json.object do
+ to_json(json)
+ end
+ end
+ end
+ end
+ end
end
end
diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr
index a84e44bc..1fe8ab7e 100644
--- a/src/invidious/views/channel.ecr
+++ b/src/invidious/views/channel.ecr
@@ -20,7 +20,9 @@
page_nav_html = IV::Frontend::Pagination.nav_ctoken(locale,
base_url: relative_url,
- ctoken: next_continuation
+ ctoken: next_continuation,
+ first_page: continuation.nil?,
+ params: env.params.query,
)
%>
@@ -40,6 +42,8 @@
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= ucid %>" />
<%- end -%>
+<script src="/js/pagination.js?v=<%= ASSET_COMMIT %>"></script>
+
<link rel="alternate" href="<%= youtube_url %>">
<title><%= author %> - Invidious</title>
<% end %>
diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr
index 6d227cfc..c966a926 100644
--- a/src/invidious/views/components/item.ecr
+++ b/src/invidious/views/components/item.ecr
@@ -128,7 +128,7 @@
<div class="top-left-overlay">
<%- if env.get? "show_watched" -%>
- <form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
+ <form data-onsubmit="return_false" action="/watch_ajax?action=mark_watched&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile"
data-onclick="mark_watched" data-id="<%= item.id %>">
@@ -138,14 +138,14 @@
<%- end -%>
<%- if plid_form = env.get?("add_playlist_items") -%>
- <%- form_parameters = "action_add_video=1&video_id=#{item.id}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
+ <%- form_parameters = "action=add_video&video_id=#{item.id}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
<form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile"
data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>"><i class="icon ion-md-add"></i></button>
</form>
<%- elsif item.is_a?(PlaylistVideo) && (plid_form = env.get?("remove_playlist_items")) -%>
- <%- form_parameters = "action_remove_video=1&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
+ <%- form_parameters = "action=remove_video&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
<form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile"
diff --git a/src/invidious/views/components/items_paginated.ecr b/src/invidious/views/components/items_paginated.ecr
index 4534a0a3..f69df3fe 100644
--- a/src/invidious/views/components/items_paginated.ecr
+++ b/src/invidious/views/components/items_paginated.ecr
@@ -8,4 +8,14 @@
<%= page_nav_html %>
+<script id="pagination-data" type="application/json">
+<%=
+{
+ "next_page" => translate(locale, "Next page"),
+ "prev_page" => translate(locale, "Previous page"),
+ "is_rtl" => locale_is_rtl?(locale)
+}.to_pretty_json
+%>
+</script>
+
<script src="/js/watched_indicator.js"></script>
diff --git a/src/invidious/views/components/subscribe_widget.ecr b/src/invidious/views/components/subscribe_widget.ecr
index 05e4e253..3cfcb0eb 100644
--- a/src/invidious/views/components/subscribe_widget.ecr
+++ b/src/invidious/views/components/subscribe_widget.ecr
@@ -1,13 +1,13 @@
<% if user %>
<% if subscriptions.includes? ucid %>
- <form action="/subscription_ajax?action_remove_subscriptions=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
+ <form action="/subscription_ajax?action=remove_subscriptions&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button data-type="unsubscribe" id="subscribe" class="pure-button pure-button-primary">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b>
</button>
</form>
<% else %>
- <form action="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
+ <form action="/subscription_ajax?action=create_subscription_to_channel&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button data-type="subscribe" id="subscribe" class="pure-button pure-button-primary">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b>
diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr
index bda4e1f3..13fe4147 100644
--- a/src/invidious/views/feeds/history.ecr
+++ b/src/invidious/views/feeds/history.ecr
@@ -37,7 +37,7 @@
</a>
<div class="top-left-overlay"><div class="watched">
- <form data-onsubmit="return_false" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post">
+ <form data-onsubmit="return_false" action="/watch_ajax?action=mark_unwatched&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile"
data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button>
diff --git a/src/invidious/views/user/subscription_manager.ecr b/src/invidious/views/user/subscription_manager.ecr
index c9801f09..d566e228 100644
--- a/src/invidious/views/user/subscription_manager.ecr
+++ b/src/invidious/views/user/subscription_manager.ecr
@@ -37,7 +37,7 @@
<div class="pure-u-2-5"></div>
<div class="pure-u-1-5" style="text-align:right">
<h3 style="padding-right:0.5em">
- <form data-onsubmit="return_false" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post">
+ <form data-onsubmit="return_false" action="/subscription_ajax?action=remove_subscriptions&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<input style="all:unset" type="submit" data-onclick="remove_subscription" data-ucid="<%= channel.id %>" value="<%= translate(locale, "unsubscribe") %>">
</form>
diff --git a/src/invidious/views/user/token_manager.ecr b/src/invidious/views/user/token_manager.ecr
index a73fa048..8431deb0 100644
--- a/src/invidious/views/user/token_manager.ecr
+++ b/src/invidious/views/user/token_manager.ecr
@@ -29,7 +29,7 @@
</div>
<div class="pure-u-1-5" style="text-align:right">
<h3 style="padding-right:0.5em">
- <form data-onsubmit="return_false" action="/token_ajax?action_revoke_token=1&session=<%= token[:session] %>&referer=<%= env.get("current_page") %>" method="post">
+ <form data-onsubmit="return_false" action="/token_ajax?action=revoke_token&session=<%= token[:session] %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<input style="all:unset" type="submit" data-onclick="revoke_token" data-session="<%= token[:session] %>" value="<%= translate(locale, "revoke") %>">
</form>
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr
index 45c58a16..6f9ced6f 100644
--- a/src/invidious/views/watch.ecr
+++ b/src/invidious/views/watch.ecr
@@ -158,7 +158,7 @@ we're going to need to do it here in order to allow for translations.
<% if user %>
<% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %>
<% if !playlists.empty? %>
- <form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax" method="post" target="_blank">
+ <form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax?action=add_video" method="post" target="_blank">
<div class="pure-control-group">
<label for="playlist_id"><%= translate(locale, "Add to playlist: ") %></label>
<select style="width:100%" name="playlist_id" id="playlist_id">
@@ -169,7 +169,6 @@ we're going to need to do it here in order to allow for translations.
</div>
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
- <input type="hidden" name="action_add_video" value="1">
<input type="hidden" name="video_id" value="<%= video.id %>">
<button data-onclick="add_playlist_video" data-id="<%= video.id %>" type="submit" class="pure-button pure-button-primary">
<b><%= translate(locale, "Add to playlist") %></b>
diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr
index 2631b62a..edd7bf1b 100644
--- a/src/invidious/yt_backend/extractors.cr
+++ b/src/invidious/yt_backend/extractors.cr
@@ -67,6 +67,8 @@ private module Parsers
author_id = author_fallback.id
end
+ author_thumbnail = item_contents.dig?("channelThumbnailSupportedRenderers", "channelThumbnailWithLinkRenderer", "thumbnail", "thumbnails", 0, "url").try &.as_s
+
author_verified = has_verified_badge?(item_contents["ownerBadges"]?)
# For live videos (and possibly recently premiered videos) there is no published information.
@@ -148,6 +150,7 @@ private module Parsers
length_seconds: length_seconds,
premiere_timestamp: premiere_timestamp,
author_verified: author_verified,
+ author_thumbnail: author_thumbnail,
badges: badges,
})
end
@@ -579,6 +582,7 @@ private module Parsers
length_seconds: duration,
premiere_timestamp: Time.unix(0),
author_verified: false,
+ author_thumbnail: nil,
badges: VideoBadges::None,
})
end
@@ -708,6 +712,7 @@ private module Parsers
length_seconds: duration,
premiere_timestamp: Time.unix(0),
author_verified: false,
+ author_thumbnail: nil,
badges: VideoBadges::None,
})
end
@@ -1024,7 +1029,7 @@ end
def extract_items(
initial_data : InitialData,
author_fallback : String? = nil,
- author_id_fallback : String? = nil
+ author_id_fallback : String? = nil,
) : {Array(SearchItem), String?}
items = [] of SearchItem
continuation = nil
diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr
index 8f5aa61d..ec080d8c 100644
--- a/src/invidious/yt_backend/youtube_api.cr
+++ b/src/invidious/yt_backend/youtube_api.cr
@@ -211,7 +211,7 @@ module YoutubeAPI
def initialize(
*,
@client_type = ClientType::Web,
- @region = "US"
+ @region = "US",
)
end
@@ -370,7 +370,7 @@ module YoutubeAPI
browse_id : String,
*, # Force the following parameters to be passed by name
params : String,
- client_config : ClientConfig | Nil = nil
+ client_config : ClientConfig | Nil = nil,
)
# JSON Request data, required by the API
data = {
@@ -464,7 +464,7 @@ module YoutubeAPI
video_id : String,
*, # Force the following parameters to be passed by name
params : String,
- client_config : ClientConfig | Nil = nil
+ client_config : ClientConfig | Nil = nil,
)
# Playback context, separate because it can be different between clients
playback_ctx = {
@@ -557,7 +557,7 @@ module YoutubeAPI
def search(
search_query : String,
params : String,
- client_config : ClientConfig | Nil = nil
+ client_config : ClientConfig | Nil = nil,
)
# JSON Request data, required by the API
data = {
@@ -583,7 +583,7 @@ module YoutubeAPI
def get_transcript(
params : String,
- client_config : ClientConfig | Nil = nil
+ client_config : ClientConfig | Nil = nil,
) : Hash(String, JSON::Any)
data = {
"context" => self.make_context(client_config),
@@ -605,7 +605,7 @@ module YoutubeAPI
def _post_json(
endpoint : String,
data : Hash,
- client_config : ClientConfig | Nil
+ client_config : ClientConfig | Nil,
) : Hash(String, JSON::Any)
# Use the default client config if nil is passed
client_config ||= DEFAULT_CLIENT_CONFIG