Merge branch 'master' into master

This commit is contained in:
choelzl 2022-08-13 22:28:41 +02:00 committed by GitHub
commit 861399de72
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
73 changed files with 1458 additions and 695 deletions

View file

@ -41,6 +41,7 @@ jobs:
- 1.2.2 - 1.2.2
- 1.3.2 - 1.3.2
- 1.4.0 - 1.4.0
- 1.5.0
include: include:
- crystal: nightly - crystal: nightly
stable: false stable: false

View file

@ -27,7 +27,7 @@ jobs:
- name: Install Crystal - name: Install Crystal
uses: crystal-lang/install-crystal@v1.6.0 uses: crystal-lang/install-crystal@v1.6.0
with: with:
crystal: 1.2.2 crystal: 1.5.0
- name: Run lint - name: Run lint
run: | run: |

View file

@ -153,6 +153,7 @@ Weblate also allows you to log-in with major SSO providers like Github, Gitlab,
- [WatchTube](https://github.com/WatchTubeTeam/WatchTube): Powerful YouTube client for Apple Watch. - [WatchTube](https://github.com/WatchTubeTeam/WatchTube): Powerful YouTube client for Apple Watch.
- [Yattee](https://github.com/yattee/yattee): Alternative YouTube frontend for iPhone, iPad, Mac and Apple TV. - [Yattee](https://github.com/yattee/yattee): Alternative YouTube frontend for iPhone, iPad, Mac and Apple TV.
- [TubiTui](https://codeberg.org/777/TubiTui): A lightweight, libre, TUI-based YouTube client. - [TubiTui](https://codeberg.org/777/TubiTui): A lightweight, libre, TUI-based YouTube client.
- [Ytfzf](https://github.com/pystardust/ytfzf): A posix script to find and watch youtube videos from the terminal. (Without API)
## Liability ## Liability

View file

@ -204,7 +204,8 @@ img.thumbnail {
margin: 1px; margin: 1px;
border: 1px solid; border: 1px solid;
border-color: #0000 #0000 #CCC #0000; border-color: rgba(0,0,0,0);
border-bottom-color: #CCC;
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
@ -214,7 +215,8 @@ img.thumbnail {
.searchbar input[type="search"]:focus { .searchbar input[type="search"]:focus {
margin: 0 0 0.5px 0; margin: 0 0 0.5px 0;
border: 2px solid; border: 2px solid;
border-color: #0000 #0000 #FED #0000; border-color: rgba(0,0,0,0);
border-bottom-color: #FED;
} }
/* https://stackoverflow.com/a/55170420 */ /* https://stackoverflow.com/a/55170420 */
@ -234,7 +236,7 @@ input[type="search"]::-webkit-search-cancel-button {
} }
.user-field div { .user-field div {
width: initial; width: auto;
} }
.user-field div:not(:last-child) { .user-field div:not(:last-child) {
@ -527,3 +529,9 @@ p,
/* Center the "invidious" logo on the search page */ /* Center the "invidious" logo on the search page */
#logo > h1 { text-align: center; } #logo > h1 { text-align: center; }
/* IE11 fixes */
:-ms-input-placeholder { color: #888; }
/* Wider settings name to less word wrap */
.pure-form-aligned .pure-control-group label { width: 19em; }

View file

@ -101,23 +101,27 @@ ul.vjs-menu-content::-webkit-scrollbar {
order: 2; order: 2;
} }
.vjs-quality-selector, .vjs-audio-button {
.video-js .vjs-http-source-selector {
order: 3; order: 3;
} }
.vjs-playback-rate { .vjs-quality-selector,
.video-js .vjs-http-source-selector {
order: 4; order: 4;
} }
.vjs-share-control { .vjs-playback-rate {
order: 5; order: 5;
} }
.vjs-fullscreen-control { .vjs-share-control {
order: 6; order: 6;
} }
.vjs-fullscreen-control {
order: 7;
}
.vjs-playback-rate > .vjs-menu { .vjs-playback-rate > .vjs-menu {
width: 50px; width: 50px;
} }

View file

@ -68,7 +68,10 @@ fieldset, legend {
.filter-options label { margin: 0 10px; } .filter-options label { margin: 0 10px; }
#filters-apply { text-align: end; } #filters-apply {
text-align: right; /* IE11 only */
text-align: end; /* Override for compatible browsers */
}
/* Error message */ /* Error message */

View file

@ -17,6 +17,7 @@ var options = {
'remainingTimeDisplay', 'remainingTimeDisplay',
'Spacer', 'Spacer',
'captionsButton', 'captionsButton',
'audioTrackButton',
'qualitySelector', 'qualitySelector',
'playbackRateMenuButton', 'playbackRateMenuButton',
'fullscreenToggle' 'fullscreenToggle'
@ -67,6 +68,7 @@ player.on('error', function () {
// add local=true to all current sources // add local=true to all current sources
player.src(player.currentSources().map(function (source) { player.src(player.currentSources().map(function (source) {
source.src += '&local=true'; source.src += '&local=true';
return source;
})); }));
} else if (reloadMakesSense) { } else if (reloadMakesSense) {
setTimeout(function () { setTimeout(function () {
@ -145,11 +147,12 @@ function isMobile() {
} }
if (isMobile()) { if (isMobile()) {
player.mobileUi(); player.mobileUi({ touchControls: { seekSeconds: 5 * player.playbackRate() } });
var buttons = ['playToggle', 'volumePanel', 'captionsButton']; var buttons = ['playToggle', 'volumePanel', 'captionsButton'];
if (video_data.params.quality !== 'dash') buttons.push('qualitySelector'); if (!video_data.params.listen && video_data.params.quality === 'dash') buttons.push('audioTrackButton');
if (video_data.params.listen || video_data.params.quality !== 'dash') buttons.push('qualitySelector');
// Create new control bar object for operation buttons // Create new control bar object for operation buttons
const ControlBar = videojs.getComponent('controlBar'); const ControlBar = videojs.getComponent('controlBar');
@ -176,7 +179,7 @@ if (isMobile()) {
var share_element = document.getElementsByClassName('vjs-share-control')[0]; var share_element = document.getElementsByClassName('vjs-share-control')[0];
operations_bar_element.append(share_element); operations_bar_element.append(share_element);
if (video_data.params.quality === 'dash') { if (!video_data.params.listen && video_data.params.quality === 'dash') {
var http_source_selector = document.getElementsByClassName('vjs-http-source-selector vjs-menu-button')[0]; var http_source_selector = document.getElementsByClassName('vjs-http-source-selector vjs-menu-button')[0];
operations_bar_element.append(http_source_selector); operations_bar_element.append(http_source_selector);
} }
@ -274,6 +277,9 @@ function updateCookie(newVolume, newSpeed) {
player.on('ratechange', function () { player.on('ratechange', function () {
updateCookie(null, player.playbackRate()); updateCookie(null, player.playbackRate());
if (isMobile()) {
player.mobileUi({ touchControls: { seekSeconds: 5 * player.playbackRate() } });
}
}); });
player.on('volumechange', function () { player.on('volumechange', function () {
@ -673,7 +679,12 @@ if (player.share) player.share(shareOptions);
// show the preferred caption by default // show the preferred caption by default
if (player_data.preferred_caption_found) { if (player_data.preferred_caption_found) {
player.ready(function () { player.ready(function () {
if (!video_data.params.listen && video_data.params.quality === 'dash') {
// play.textTracks()[0] on DASH mode is showing some debug messages
player.textTracks()[1].mode = 'showing'; player.textTracks()[1].mode = 'showing';
} else {
player.textTracks()[0].mode = 'showing';
}
}); });
} }

View file

@ -382,13 +382,16 @@ feed_threads: 1
## Enable/Disable the polling job that keeps the decryption ## Enable/Disable the polling job that keeps the decryption
## function (for "secured" videos) up to date. ## function (for "secured" videos) up to date.
## ##
## Note: This part of the code is currently broken, so changing ## Note: This part of the code generate a small amount of data every minute.
## This may not be desired if you have bandwidth limits set by your ISP.
##
## Note 2: This part of the code is currently broken, so changing
## this setting has no impact. ## this setting has no impact.
## ##
## Accepted values: true, false ## Accepted values: true, false
## Default: true ## Default: false
## ##
#decrypt_polling: true #decrypt_polling: false
# ----------------------------- # -----------------------------

View file

@ -2,7 +2,7 @@
# Using it will build an image from the locally cloned repository. # Using it will build an image from the locally cloned repository.
# #
# If you want to use Invidious in production, see the docker-compose.yml file provided # If you want to use Invidious in production, see the docker-compose.yml file provided
# in the installation documentation: https://docs.invidious.io/Installation.md # in the installation documentation: https://docs.invidious.io/installation/
version: "3" version: "3"
services: services:

View file

@ -1,5 +1,5 @@
FROM alpine:edge AS builder FROM alpine:3.16 AS builder
RUN apk add --no-cache 'crystal=1.4.1-r1' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev RUN apk add --no-cache 'crystal=1.4.1-r0' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev
ARG release ARG release
@ -34,7 +34,7 @@ RUN if [ ${release} == 1 ] ; then \
--link-flags "-lxml2 -llzma"; \ --link-flags "-lxml2 -llzma"; \
fi fi
FROM alpine:edge FROM alpine:3.16
RUN apk add --no-cache librsvg ttf-opensans RUN apk add --no-cache librsvg ttf-opensans
WORKDIR /invidious WORKDIR /invidious
RUN addgroup -g 1000 -S invidious && \ RUN addgroup -g 1000 -S invidious && \

View file

@ -368,7 +368,7 @@
"footer_donate_page": "تبرّع", "footer_donate_page": "تبرّع",
"preferences_region_label": "بلد المحتوى: ", "preferences_region_label": "بلد المحتوى: ",
"preferences_quality_dash_label": "جودة فيديو DASH المفضلة: ", "preferences_quality_dash_label": "جودة فيديو DASH المفضلة: ",
"preferences_quality_option_dash": "DASH (جودة تكييفية)", "preferences_quality_option_dash": "DASH (الجودة التلقائية)",
"preferences_quality_option_hd720": "HD720", "preferences_quality_option_hd720": "HD720",
"preferences_quality_option_medium": "متوسطة", "preferences_quality_option_medium": "متوسطة",
"preferences_quality_option_small": "صغيرة", "preferences_quality_option_small": "صغيرة",
@ -459,5 +459,81 @@
"Spanish (Spain)": "الإسبانية (إسبانيا)", "Spanish (Spain)": "الإسبانية (إسبانيا)",
"crash_page_search_issue": "بحثت عن <a href=\"`x`\"> المشكلات الموجودة على GitHub </a>", "crash_page_search_issue": "بحثت عن <a href=\"`x`\"> المشكلات الموجودة على GitHub </a>",
"search_filters_title": "معامل الفرز", "search_filters_title": "معامل الفرز",
"search_message_no_results": "لا توجد نتائج." "search_message_no_results": "لا توجد نتائج.",
"search_message_change_filters_or_query": "حاول توسيع استعلام البحث و / أو تغيير عوامل التصفية.",
"search_filters_date_label": "تاريخ الرفع",
"generic_count_weeks_0": "{{count}} أسبوع",
"generic_count_weeks_1": "{{count}} أسبوع",
"generic_count_weeks_2": "{{count}} أسبوع",
"generic_count_weeks_3": "{{count}} أسبوع",
"generic_count_weeks_4": "{{count}} أسابيع",
"generic_count_weeks_5": "{{count}} أسبوع",
"Popular enabled: ": "تم تمكين الشعبية: ",
"search_filters_duration_option_medium": "متوسط (4-20 دقيقة)",
"search_filters_date_option_none": "أي تاريخ",
"search_filters_type_option_all": "أي نوع",
"search_filters_features_option_vr180": "VR180",
"generic_count_minutes_0": "{{count}} دقيقة",
"generic_count_minutes_1": "{{count}} دقيقة",
"generic_count_minutes_2": "{{count}} دقيقة",
"generic_count_minutes_3": "{{count}} دقيقة",
"generic_count_minutes_4": "{{count}} دقائق",
"generic_count_minutes_5": "{{count}} دقيقة",
"generic_count_hours_0": "{{count}} ساعة",
"generic_count_hours_1": "{{count}} ساعة",
"generic_count_hours_2": "{{count}} ساعة",
"generic_count_hours_3": "{{count}} ساعة",
"generic_count_hours_4": "{{count}} ساعات",
"generic_count_hours_5": "{{count}} ساعة",
"comments_view_x_replies_0": "عرض رد {{count}}",
"comments_view_x_replies_1": "عرض رد {{count}}",
"comments_view_x_replies_2": "عرض رد {{count}}",
"comments_view_x_replies_3": "عرض رد {{count}}",
"comments_view_x_replies_4": "عرض الردود {{count}}",
"comments_view_x_replies_5": "عرض رد {{count}}",
"search_message_use_another_instance": " يمكنك أيضًا البحث عن <a href=\"`x`\"> في مثيل آخر </a>.",
"comments_points_count_0": "{{count}} نقطة",
"comments_points_count_1": "{{count}} نقطة",
"comments_points_count_2": "{{count}} نقطة",
"comments_points_count_3": "{{count}} نقطة",
"comments_points_count_4": "{{count}} نقاط",
"comments_points_count_5": "{{count}} نقطة",
"generic_count_years_0": "{{count}} السنة",
"generic_count_years_1": "{{count}} السنة",
"generic_count_years_2": "{{count}} السنة",
"generic_count_years_3": "{{count}} السنة",
"generic_count_years_4": "{{count}} سنوات",
"generic_count_years_5": "{{count}} السنة",
"tokens_count_0": "الرمز المميز {{count}}",
"tokens_count_1": "الرمز المميز {{count}}",
"tokens_count_2": "الرمز المميز {{count}}",
"tokens_count_3": "الرمز المميز {{count}}",
"tokens_count_4": "الرموز المميزة {{count}}",
"tokens_count_5": "الرمز المميز {{count}}",
"search_filters_apply_button": "تطبيق الفلاتر المحددة",
"search_filters_duration_option_none": "أي مدة",
"subscriptions_unseen_notifs_count_0": "{{count}} إشعار غير مرئي",
"subscriptions_unseen_notifs_count_1": "{{count}} إشعار غير مرئي",
"subscriptions_unseen_notifs_count_2": "{{count}} إشعار غير مرئي",
"subscriptions_unseen_notifs_count_3": "{{count}} إشعار غير مرئي",
"subscriptions_unseen_notifs_count_4": "{{count}} إشعارات غير مرئية",
"subscriptions_unseen_notifs_count_5": "{{count}} إشعار غير مرئي",
"generic_count_days_0": "{{count}} يوم",
"generic_count_days_1": "{{count}} يوم",
"generic_count_days_2": "{{count}} يوم",
"generic_count_days_3": "{{count}} يوم",
"generic_count_days_4": "{{count}} أيام",
"generic_count_days_5": "{{count}} يوم",
"generic_count_months_0": "{{count}} شهر",
"generic_count_months_1": "{{count}} شهر",
"generic_count_months_2": "{{count}} شهر",
"generic_count_months_3": "{{count}} شهر",
"generic_count_months_4": "{{count}} شهور",
"generic_count_months_5": "{{count}} شهر",
"generic_count_seconds_0": "{{count}} ثانية",
"generic_count_seconds_1": "{{count}} ثانية",
"generic_count_seconds_2": "{{count}} ثانية",
"generic_count_seconds_3": "{{count}} ثانية",
"generic_count_seconds_4": "{{count}} ثوانٍ",
"generic_count_seconds_5": "{{count}} ثانية"
} }

View file

@ -88,7 +88,7 @@
"Only show latest unwatched video from channel: ": "Zobrazit jen nejnovější nezhlédnuté video z daného kanálu: ", "Only show latest unwatched video from channel: ": "Zobrazit jen nejnovější nezhlédnuté video z daného kanálu: ",
"preferences_unseen_only_label": "Zobrazit jen již nezhlédnuté: ", "preferences_unseen_only_label": "Zobrazit jen již nezhlédnuté: ",
"preferences_notifications_only_label": "Zobrazit pouze upozornění (pokud nějaká jsou): ", "preferences_notifications_only_label": "Zobrazit pouze upozornění (pokud nějaká jsou): ",
"Enable web notifications": "Povolit webové upozornění", "Enable web notifications": "Povolit webová upozornění",
"`x` uploaded a video": "`x` nahrál(a) video", "`x` uploaded a video": "`x` nahrál(a) video",
"`x` is live": "`x` je živě", "`x` is live": "`x` je živě",
"preferences_category_data": "Nastavení dat", "preferences_category_data": "Nastavení dat",
@ -486,5 +486,6 @@
"search_filters_features_option_purchased": "Zakoupeno", "search_filters_features_option_purchased": "Zakoupeno",
"search_filters_sort_label": "Řadit dle", "search_filters_sort_label": "Řadit dle",
"search_filters_sort_option_relevance": "Relevantnost", "search_filters_sort_option_relevance": "Relevantnost",
"search_filters_apply_button": "Použít vybrané filtry" "search_filters_apply_button": "Použít vybrané filtry",
"Popular enabled: ": "Populární povoleno: "
} }

View file

@ -367,7 +367,7 @@
"adminprefs_modified_source_code_url_label": "URL zum Repositorie des modifizierten Quellcodes", "adminprefs_modified_source_code_url_label": "URL zum Repositorie des modifizierten Quellcodes",
"search_filters_duration_option_short": "Kurz (< 4 Minuten)", "search_filters_duration_option_short": "Kurz (< 4 Minuten)",
"preferences_region_label": "Land der Inhalte: ", "preferences_region_label": "Land der Inhalte: ",
"preferences_quality_option_dash": "DASH (automatische Qualität)", "preferences_quality_option_dash": "DASH (adaptive Qualität)",
"preferences_quality_option_hd720": "HD720", "preferences_quality_option_hd720": "HD720",
"preferences_quality_option_medium": "Mittel", "preferences_quality_option_medium": "Mittel",
"preferences_quality_option_small": "Niedrig", "preferences_quality_option_small": "Niedrig",
@ -460,5 +460,16 @@
"Chinese (Taiwan)": "Chinesisch (Taiwan)", "Chinese (Taiwan)": "Chinesisch (Taiwan)",
"Korean (auto-generated)": "Koreanisch (automatisch generiert)", "Korean (auto-generated)": "Koreanisch (automatisch generiert)",
"Portuguese (auto-generated)": "Portugiesisch (automatisch generiert)", "Portuguese (auto-generated)": "Portugiesisch (automatisch generiert)",
"search_filters_title": "Filtern" "search_filters_title": "Filtern",
"search_message_change_filters_or_query": "Versuchen Sie, Ihre Suchanfrage zu erweitern und/oder die Filter zu ändern.",
"search_message_use_another_instance": " Sie können auch <a href=\"`x`\">auf einer anderen Instanz suchen</a>.",
"Popular enabled: ": "„Beliebt“-Seite aktiviert: ",
"search_message_no_results": "Keine Ergebnisse gefunden.",
"search_filters_duration_option_medium": "Mittel (4 - 20 Minuten)",
"search_filters_features_option_vr180": "VR180",
"search_filters_type_option_all": "Beliebiger Typ",
"search_filters_apply_button": "Ausgewählte Filter anwenden",
"search_filters_duration_option_none": "Beliebige Länge",
"search_filters_date_label": "Upload-Datum",
"search_filters_date_option_none": "Beliebiges Datum"
} }

View file

@ -449,5 +449,6 @@
"videoinfo_invidious_embed_link": "Σύνδεσμος Ενσωμάτωσης", "videoinfo_invidious_embed_link": "Σύνδεσμος Ενσωμάτωσης",
"search_filters_type_option_show": "Μπάρα προόδου διαβάσματος", "search_filters_type_option_show": "Μπάρα προόδου διαβάσματος",
"preferences_watch_history_label": "Ενεργοποίηση ιστορικού παρακολούθησης: ", "preferences_watch_history_label": "Ενεργοποίηση ιστορικού παρακολούθησης: ",
"search_filters_title": "Φίλτρο" "search_filters_title": "Φίλτρο",
"search_message_no_results": "Δεν"
} }

View file

@ -470,5 +470,6 @@
"search_filters_duration_option_medium": "Keskipituinen (4 - 20 minuuttia)", "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_date_option_none": "Milloin tahansa",
"search_filters_type_option_all": "Mikä tahansa tyyppi" "search_filters_type_option_all": "Mikä tahansa tyyppi",
"Popular enabled: ": "Suosittu käytössä: "
} }

View file

@ -107,7 +107,7 @@
"preferences_feed_menu_label": "Izbornik za feedove: ", "preferences_feed_menu_label": "Izbornik za feedove: ",
"preferences_show_nick_label": "Prikaži nadimak na vrhu: ", "preferences_show_nick_label": "Prikaži nadimak na vrhu: ",
"Top enabled: ": "Najbolji aktivirani: ", "Top enabled: ": "Najbolji aktivirani: ",
"CAPTCHA enabled: ": "Aktivirani CAPTCHA: ", "CAPTCHA enabled: ": "CAPTCHA aktiviran: ",
"Login enabled: ": "Prijava aktivirana: ", "Login enabled: ": "Prijava aktivirana: ",
"Registration enabled: ": "Registracija aktivirana: ", "Registration enabled: ": "Registracija aktivirana: ",
"Report statistics: ": "Izvještaj o statistici: ", "Report statistics: ": "Izvještaj o statistici: ",
@ -486,5 +486,6 @@
"search_filters_duration_option_none": "Bilo koje duljine", "search_filters_duration_option_none": "Bilo koje duljine",
"search_filters_duration_option_medium": "Srednje (4 20 minuta)", "search_filters_duration_option_medium": "Srednje (4 20 minuta)",
"search_filters_apply_button": "Primijeni odabrane filtre", "search_filters_apply_button": "Primijeni odabrane filtre",
"search_filters_type_option_all": "Bilo koja vrsta" "search_filters_type_option_all": "Bilo koja vrsta",
"Popular enabled: ": "Popularni aktivirani: "
} }

View file

@ -126,7 +126,7 @@
"revoke": "cabut", "revoke": "cabut",
"Subscriptions": "Langganan", "Subscriptions": "Langganan",
"subscriptions_unseen_notifs_count_0": "{{count}} pemberitahuan belum dilihat", "subscriptions_unseen_notifs_count_0": "{{count}} pemberitahuan belum dilihat",
"search": "cari", "search": "Telusuri",
"Log out": "Keluar", "Log out": "Keluar",
"Released under the AGPLv3 on Github.": "Dirilis di bawah AGPLv3 di GitHub.", "Released under the AGPLv3 on Github.": "Dirilis di bawah AGPLv3 di GitHub.",
"Source available here.": "Sumber tersedia di sini.", "Source available here.": "Sumber tersedia di sini.",
@ -346,7 +346,7 @@
"Community": "Komunitas", "Community": "Komunitas",
"search_filters_sort_option_relevance": "Relevansi", "search_filters_sort_option_relevance": "Relevansi",
"search_filters_sort_option_rating": "Penilaian", "search_filters_sort_option_rating": "Penilaian",
"search_filters_sort_option_date": "Tanggal unggah", "search_filters_sort_option_date": "Tanggal Unggah",
"search_filters_sort_option_views": "Jumlah ditonton", "search_filters_sort_option_views": "Jumlah ditonton",
"search_filters_type_label": "Tipe", "search_filters_type_label": "Tipe",
"search_filters_duration_label": "Durasi", "search_filters_duration_label": "Durasi",
@ -421,5 +421,32 @@
"search_filters_title": "Saring", "search_filters_title": "Saring",
"search_message_no_results": "Tidak ada hasil yang ditemukan.", "search_message_no_results": "Tidak ada hasil yang ditemukan.",
"search_message_change_filters_or_query": "Coba perbanyak kueri pencarian dan/atau ubah filter Anda.", "search_message_change_filters_or_query": "Coba perbanyak kueri pencarian dan/atau ubah filter Anda.",
"search_message_use_another_instance": " Anda juga bisa <a href=\"`x`\">mencari di peladen lain</a>." "search_message_use_another_instance": " Anda juga bisa <a href=\"`x`\">mencari di peladen lain</a>.",
"Indonesian (auto-generated)": "Indonesia (dibuat secara otomatis)",
"Japanese (auto-generated)": "Jepang (dibuat secara otomatis)",
"Korean (auto-generated)": "Korea (dibuat secara otomatis)",
"Portuguese (Brazil)": "Portugis (Brasil)",
"Russian (auto-generated)": "Rusia (dibuat secara otomatis)",
"Spanish (Mexico)": "Spanyol (Meksiko)",
"Spanish (Spain)": "Spanyol (Spanyol)",
"Vietnamese (auto-generated)": "Vietnam (dibuat secara otomatis)",
"search_filters_features_option_vr180": "VR180",
"Spanish (auto-generated)": "Spanyol (dibuat secara otomatis)",
"Chinese": "Bahasa Cina",
"Chinese (Taiwan)": "Bahasa Cina (Taiwan)",
"Chinese (Hong Kong)": "Bahasa Cina (Hong Kong)",
"Chinese (China)": "Bahasa Cina (China)",
"French (auto-generated)": "Perancis (dibuat secara otomatis)",
"German (auto-generated)": "Jerman (dibuat secara otomatis)",
"Italian (auto-generated)": "Italia (dibuat secara otomatis)",
"Portuguese (auto-generated)": "Portugis (dibuat secara otomatis)",
"Turkish (auto-generated)": "Turki (dibuat secara otomatis)",
"search_filters_date_label": "Tanggal unggah",
"search_filters_type_option_all": "Segala jenis",
"search_filters_apply_button": "Terapkan saringan yang dipilih",
"Dutch (auto-generated)": "Belanda (dihasilkan secara otomatis)",
"search_filters_date_option_none": "Tanggal berapa pun",
"search_filters_duration_option_none": "Durasi berapa pun",
"search_filters_duration_option_medium": "Sedang (4 - 20 menit)",
"Cantonese (Hong Kong)": "Bahasa Kanton (Hong Kong)"
} }

View file

@ -14,7 +14,7 @@
"newest": "più recente", "newest": "più recente",
"oldest": "più vecchio", "oldest": "più vecchio",
"popular": "Tendenze", "popular": "Tendenze",
"last": "durare", "last": "ultimo",
"Next page": "Pagina successiva", "Next page": "Pagina successiva",
"Previous page": "Pagina precedente", "Previous page": "Pagina precedente",
"Clear watch history?": "Eliminare la cronologia dei video guardati?", "Clear watch history?": "Eliminare la cronologia dei video guardati?",
@ -28,7 +28,7 @@
"Import and Export Data": "Importazione ed esportazione dati", "Import and Export Data": "Importazione ed esportazione dati",
"Import": "Importa", "Import": "Importa",
"Import Invidious data": "Importa dati Invidious in formato JSON", "Import Invidious data": "Importa dati Invidious in formato JSON",
"Import YouTube subscriptions": "Importa le iscrizioni da YouTube", "Import YouTube subscriptions": "Importa le iscrizioni da YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Importa le iscrizioni da FreeTube (.db)", "Import FreeTube subscriptions (.db)": "Importa le iscrizioni da FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importa le iscrizioni da NewPipe (.json)", "Import NewPipe subscriptions (.json)": "Importa le iscrizioni da NewPipe (.json)",
"Import NewPipe data (.zip)": "Importa i dati di NewPipe (.zip)", "Import NewPipe data (.zip)": "Importa i dati di NewPipe (.zip)",
@ -158,7 +158,7 @@
"generic_views_count_plural": "{{count}} visualizzazioni", "generic_views_count_plural": "{{count}} visualizzazioni",
"Premieres in `x`": "In anteprima in `x`", "Premieres in `x`": "In anteprima in `x`",
"Premieres `x`": "In anteprima `x`", "Premieres `x`": "In anteprima `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Ciao! Sembra che tu abbia disattivato JavaScript. Clicca qui per visualizzare i commenti. Considera che potrebbe volerci più tempo.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Ciao, Sembra che tu abbia disattivato JavaScript. Clicca qui per visualizzare i commenti, ma considera che il caricamento potrebbe richiedere più tempo.",
"View YouTube comments": "Visualizza i commenti da YouTube", "View YouTube comments": "Visualizza i commenti da YouTube",
"View more comments on Reddit": "Visualizza più commenti su Reddit", "View more comments on Reddit": "Visualizza più commenti su Reddit",
"View `x` comments": { "View `x` comments": {
@ -212,7 +212,7 @@
"Azerbaijani": "Azero", "Azerbaijani": "Azero",
"Bangla": "Bengalese", "Bangla": "Bengalese",
"Basque": "Basco", "Basque": "Basco",
"Belarusian": "Biellorusso", "Belarusian": "Bielorusso",
"Bosnian": "Bosniaco", "Bosnian": "Bosniaco",
"Bulgarian": "Bulgaro", "Bulgarian": "Bulgaro",
"Burmese": "Birmano", "Burmese": "Birmano",
@ -238,10 +238,10 @@
"Haitian Creole": "Creolo haitiano", "Haitian Creole": "Creolo haitiano",
"Hausa": "Lingua hausa", "Hausa": "Lingua hausa",
"Hawaiian": "Hawaiano", "Hawaiian": "Hawaiano",
"Hebrew": "Ebreo", "Hebrew": "Ebraico",
"Hindi": "Hindi", "Hindi": "Hindi",
"Hmong": "Hmong", "Hmong": "Hmong",
"Hungarian": "Ungarese", "Hungarian": "Ungherese",
"Icelandic": "Islandese", "Icelandic": "Islandese",
"Igbo": "Igbo", "Igbo": "Igbo",
"Indonesian": "Indonesiano", "Indonesian": "Indonesiano",
@ -254,7 +254,7 @@
"Khmer": "Khmer", "Khmer": "Khmer",
"Korean": "Coreano", "Korean": "Coreano",
"Kurdish": "Curdo", "Kurdish": "Curdo",
"Kyrgyz": "Kirghize", "Kyrgyz": "Kirghiso",
"Lao": "Lao", "Lao": "Lao",
"Latin": "Latino", "Latin": "Latino",
"Latvian": "Lettone", "Latvian": "Lettone",
@ -269,7 +269,7 @@
"Marathi": "Marathi", "Marathi": "Marathi",
"Mongolian": "Mongolo", "Mongolian": "Mongolo",
"Nepali": "Nepalese", "Nepali": "Nepalese",
"Norwegian Bokmål": "Norvegese", "Norwegian Bokmål": "Norvegese bokmål",
"Nyanja": "Nyanja", "Nyanja": "Nyanja",
"Pashto": "Pashtu", "Pashto": "Pashtu",
"Persian": "Persiano", "Persian": "Persiano",
@ -278,7 +278,7 @@
"Punjabi": "Punjabi", "Punjabi": "Punjabi",
"Romanian": "Rumeno", "Romanian": "Rumeno",
"Russian": "Russo", "Russian": "Russo",
"Samoan": "Samoan", "Samoan": "Samoano",
"Scottish Gaelic": "Gaelico scozzese", "Scottish Gaelic": "Gaelico scozzese",
"Serbian": "Serbo", "Serbian": "Serbo",
"Shona": "Shona", "Shona": "Shona",
@ -293,15 +293,15 @@
"Sundanese": "Sudanese", "Sundanese": "Sudanese",
"Swahili": "Swahili", "Swahili": "Swahili",
"Swedish": "Svedese", "Swedish": "Svedese",
"Tajik": "Tajik", "Tajik": "Tagico",
"Tamil": "Tamil", "Tamil": "Tamil",
"Telugu": "Telugu", "Telugu": "Telugu",
"Thai": "Thaï", "Thai": "Thailandese",
"Turkish": "Turco", "Turkish": "Turco",
"Ukrainian": "Ucraino", "Ukrainian": "Ucraino",
"Urdu": "Urdu", "Urdu": "Urdu",
"Uzbek": "Uzbeco", "Uzbek": "Uzbeco",
"Vietnamese": "Vietnamese", "Vietnamese": "Vietnamita",
"Welsh": "Gallese", "Welsh": "Gallese",
"Western Frisian": "Frisone occidentale", "Western Frisian": "Frisone occidentale",
"Xhosa": "Xhosa", "Xhosa": "Xhosa",
@ -340,7 +340,7 @@
"%A %B %-d, %Y": "%A %-d %B %Y", "%A %B %-d, %Y": "%A %-d %B %Y",
"(edited)": "(modificato)", "(edited)": "(modificato)",
"YouTube comment permalink": "Link permanente al commento di YouTube", "YouTube comment permalink": "Link permanente al commento di YouTube",
"permalink": "permalink", "permalink": "perma-collegamento",
"`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤", "`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤",
"Audio mode": "Modalità audio", "Audio mode": "Modalità audio",
"Video mode": "Modalità video", "Video mode": "Modalità video",
@ -364,7 +364,7 @@
"search_filters_type_option_channel": "Canale", "search_filters_type_option_channel": "Canale",
"search_filters_type_option_playlist": "Playlist", "search_filters_type_option_playlist": "Playlist",
"search_filters_type_option_movie": "Film", "search_filters_type_option_movie": "Film",
"search_filters_features_option_hd": "AD", "search_filters_features_option_hd": "HD",
"search_filters_features_option_subtitles": "Sottotitoli / CC", "search_filters_features_option_subtitles": "Sottotitoli / CC",
"search_filters_features_option_c_commons": "Creative Commons", "search_filters_features_option_c_commons": "Creative Commons",
"search_filters_features_option_three_d": "3D", "search_filters_features_option_three_d": "3D",
@ -383,9 +383,9 @@
"preferences_quality_dash_option_4320p": "4320p", "preferences_quality_dash_option_4320p": "4320p",
"search_filters_features_option_three_sixty": "360°", "search_filters_features_option_three_sixty": "360°",
"preferences_quality_dash_option_144p": "144p", "preferences_quality_dash_option_144p": "144p",
"Released under the AGPLv3 on Github.": "Rilasciato su GitHub con licenza AGPLv3.", "Released under the AGPLv3 on Github.": "Pubblicato su GitHub con licenza AGPLv3.",
"preferences_quality_option_medium": "Media", "preferences_quality_option_medium": "Media",
"preferences_quality_option_small": "Piccola", "preferences_quality_option_small": "Limitata",
"preferences_quality_dash_option_best": "Migliore", "preferences_quality_dash_option_best": "Migliore",
"preferences_quality_dash_option_worst": "Peggiore", "preferences_quality_dash_option_worst": "Peggiore",
"invidious": "Invidious", "invidious": "Invidious",
@ -393,7 +393,7 @@
"preferences_quality_option_hd720": "HD720", "preferences_quality_option_hd720": "HD720",
"preferences_quality_dash_option_auto": "Automatica", "preferences_quality_dash_option_auto": "Automatica",
"videoinfo_watch_on_youTube": "Guarda su YouTube", "videoinfo_watch_on_youTube": "Guarda su YouTube",
"preferences_extend_desc_label": "Espandi automaticamente la descrizione del video: ", "preferences_extend_desc_label": "Estendi automaticamente la descrizione del video: ",
"preferences_vr_mode_label": "Video interattivi a 360 gradi: ", "preferences_vr_mode_label": "Video interattivi a 360 gradi: ",
"Show less": "Mostra di meno", "Show less": "Mostra di meno",
"Switch Invidious Instance": "Cambia istanza Invidious", "Switch Invidious Instance": "Cambia istanza Invidious",
@ -425,5 +425,51 @@
"search_filters_type_option_show": "Serie", "search_filters_type_option_show": "Serie",
"search_filters_duration_option_short": "Corto (< 4 minuti)", "search_filters_duration_option_short": "Corto (< 4 minuti)",
"search_filters_duration_option_long": "Lungo (> 20 minuti)", "search_filters_duration_option_long": "Lungo (> 20 minuti)",
"search_filters_features_option_purchased": "Acquistato" "search_filters_features_option_purchased": "Acquistato",
"comments_view_x_replies": "Vedi {{count}} risposta",
"comments_view_x_replies_plural": "Vedi {{count}} risposte",
"comments_points_count": "{{count}} punto",
"comments_points_count_plural": "{{count}} punti",
"Portuguese (auto-generated)": "Portoghese (generati automaticamente)",
"crash_page_you_found_a_bug": "Sembra che tu abbia trovato un bug in Invidious!",
"crash_page_switch_instance": "provato a <a href=\"`x`\">usare un'altra istanza</a>",
"crash_page_before_reporting": "Prima di segnalare un bug, assicurati di aver:",
"crash_page_read_the_faq": "letto le <a href=\"`x`\">domande più frequenti (FAQ)</a>",
"crash_page_search_issue": "cercato tra <a href=\"`x`\"> i problemi esistenti su GitHub</a>",
"crash_page_report_issue": "Se niente di tutto ciò ha aiutato, per favore <a href=\"`x`\">apri un nuovo problema su GitHub</a> (preferibilmente in inglese) e includi il seguente testo nel tuo messaggio (NON tradurre il testo):",
"Popular enabled: ": "Popolare attivato: ",
"English (United Kingdom)": "Inglese (Regno Unito)",
"Portuguese (Brazil)": "Portoghese (Brasile)",
"preferences_watch_history_label": "Attiva cronologia di riproduzione: ",
"French (auto-generated)": "Francese (generati automaticamente)",
"search_message_use_another_instance": " Puoi anche <a href=\"`x`\">cercare in un'altra istanza</a>.",
"search_message_no_results": "Nessun risultato trovato.",
"search_message_change_filters_or_query": "Prova ad ampliare la ricerca e/o modificare i filtri.",
"English (United States)": "Inglese (Stati Uniti)",
"Cantonese (Hong Kong)": "Cantonese (Hong Kong)",
"Chinese": "Cinese",
"Chinese (China)": "Cinese (Cina)",
"Chinese (Hong Kong)": "Cinese (Hong Kong)",
"Chinese (Taiwan)": "Cinese (Taiwan)",
"Dutch (auto-generated)": "Olandese (generati automaticamente)",
"German (auto-generated)": "Tedesco (generati automaticamente)",
"Indonesian (auto-generated)": "Indonesiano (generati automaticamente)",
"Interlingue": "Interlingua",
"Italian (auto-generated)": "Italiano (generati automaticamente)",
"Japanese (auto-generated)": "Giapponese (generati automaticamente)",
"Korean (auto-generated)": "Coreano (generati automaticamente)",
"Russian (auto-generated)": "Russo (generati automaticamente)",
"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)",
"search_filters_date_label": "Data caricamento",
"search_filters_date_option_none": "Qualunque data",
"search_filters_type_option_all": "Qualunque tipo",
"search_filters_duration_option_none": "Qualunque durata",
"search_filters_duration_option_medium": "Media (4 - 20 minuti)",
"search_filters_features_option_vr180": "VR180",
"search_filters_apply_button": "Applica filtri selezionati",
"crash_page_refresh": "provato a <a href=\"`x`\">ricaricare la pagina</a>"
} }

View file

@ -433,5 +433,10 @@
"Spanish (Spain)": "スペイン語 (スペイン)", "Spanish (Spain)": "スペイン語 (スペイン)",
"Vietnamese (auto-generated)": "ベトナム語 (自動生成)", "Vietnamese (auto-generated)": "ベトナム語 (自動生成)",
"search_filters_title": "フィルタ", "search_filters_title": "フィルタ",
"search_filters_features_option_three_sixty": "360°" "search_filters_features_option_three_sixty": "360°",
"search_message_change_filters_or_query": "別のキーワードを試してみるか、検索フィルタを削除してください",
"search_message_no_results": "一致する検索結果はありませんでした",
"English (United States)": "英語 (アメリカ)",
"search_filters_date_label": "アップロード日",
"search_filters_features_option_vr180": "VR180"
} }

View file

@ -460,5 +460,16 @@
"Russian (auto-generated)": "Russisk (laget automatisk)", "Russian (auto-generated)": "Russisk (laget automatisk)",
"Dutch (auto-generated)": "Nederlandsk (laget automatisk)", "Dutch (auto-generated)": "Nederlandsk (laget automatisk)",
"Turkish (auto-generated)": "Tyrkisk (laget automatisk)", "Turkish (auto-generated)": "Tyrkisk (laget automatisk)",
"search_filters_title": "Filtrer" "search_filters_title": "Filtrer",
"Popular enabled: ": "Populære aktiv: ",
"search_message_change_filters_or_query": "Prøv ett mindre snevert søk og/eller endre filterne.",
"search_filters_duration_option_medium": "Middels (420 minutter)",
"search_message_no_results": "Resultatløst.",
"search_filters_type_option_all": "Alle typer",
"search_filters_duration_option_none": "Enhver varighet",
"search_message_use_another_instance": " Du kan også <a href=\"`x`\">søke på en annen instans</a>.",
"search_filters_date_label": "Opplastningsdato",
"search_filters_apply_button": "Bruk valgte filtre",
"search_filters_date_option_none": "Siden begynnelsen",
"search_filters_features_option_vr180": "VR180"
} }

View file

@ -470,5 +470,6 @@
"Spanish (Spain)": "Espanhol (Espanha)", "Spanish (Spain)": "Espanhol (Espanha)",
"Turkish (auto-generated)": "Turco (gerado automaticamente)", "Turkish (auto-generated)": "Turco (gerado automaticamente)",
"search_filters_duration_option_medium": "Médio (4 - 20 minutos)", "search_filters_duration_option_medium": "Médio (4 - 20 minutos)",
"search_filters_features_option_vr180": "VR180" "search_filters_features_option_vr180": "VR180",
"Popular enabled: ": "Popular habilitado: "
} }

View file

@ -21,15 +21,15 @@
"No": "Não", "No": "Não",
"Import and Export Data": "Importar e exportar dados", "Import and Export Data": "Importar e exportar dados",
"Import": "Importar", "Import": "Importar",
"Import Invidious data": "Importar dados do Invidious", "Import Invidious data": "Importar dados JSON do Invidious",
"Import YouTube subscriptions": "Importar subscrições do YouTube", "Import YouTube subscriptions": "Importar subscrições OPML ou do YouTube",
"Import FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)", "Import FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)", "Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)",
"Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)", "Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)",
"Export": "Exportar", "Export": "Exportar",
"Export subscriptions as OPML": "Exportar subscrições como OPML", "Export subscriptions as OPML": "Exportar subscrições como OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar subscrições como OPML (para NewPipe e FreeTube)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar subscrições como OPML (para NewPipe e FreeTube)",
"Export data as JSON": "Exportar dados como JSON", "Export data as JSON": "Exportar dados do Invidious como JSON",
"Delete account?": "Eliminar conta?", "Delete account?": "Eliminar conta?",
"History": "Histórico", "History": "Histórico",
"An alternative front-end to YouTube": "Uma interface alternativa ao YouTube", "An alternative front-end to YouTube": "Uma interface alternativa ao YouTube",
@ -60,13 +60,13 @@
"preferences_volume_label": "Volume da reprodução: ", "preferences_volume_label": "Volume da reprodução: ",
"preferences_comments_label": "Preferência dos comentários: ", "preferences_comments_label": "Preferência dos comentários: ",
"youtube": "YouTube", "youtube": "YouTube",
"reddit": "reddit", "reddit": "Reddit",
"preferences_captions_label": "Legendas predefinidas: ", "preferences_captions_label": "Legendas predefinidas: ",
"Fallback captions: ": "Legendas alternativas: ", "Fallback captions: ": "Legendas alternativas: ",
"preferences_related_videos_label": "Mostrar vídeos relacionados: ", "preferences_related_videos_label": "Mostrar vídeos relacionados: ",
"preferences_annotations_label": "Mostrar anotações sempre: ", "preferences_annotations_label": "Mostrar anotações sempre: ",
"preferences_extend_desc_label": "Estender automaticamente a descrição do vídeo: ", "preferences_extend_desc_label": "Estender automaticamente a descrição do vídeo: ",
"preferences_vr_mode_label": "Vídeos interativos de 360 graus: ", "preferences_vr_mode_label": "Vídeos interativos de 360 graus (necessita de WebGL): ",
"preferences_category_visual": "Preferências visuais", "preferences_category_visual": "Preferências visuais",
"preferences_player_style_label": "Estilo do reprodutor: ", "preferences_player_style_label": "Estilo do reprodutor: ",
"Dark mode: ": "Modo escuro: ", "Dark mode: ": "Modo escuro: ",
@ -374,5 +374,93 @@
"next_steps_error_message": "Pode tentar as seguintes opções: ", "next_steps_error_message": "Pode tentar as seguintes opções: ",
"next_steps_error_message_refresh": "Atualizar", "next_steps_error_message_refresh": "Atualizar",
"next_steps_error_message_go_to_youtube": "Ir ao YouTube", "next_steps_error_message_go_to_youtube": "Ir ao YouTube",
"search_filters_title": "Filtro" "search_filters_title": "Filtro",
"generic_videos_count": "{{count}} vídeo",
"generic_videos_count_plural": "{{count}} vídeos",
"generic_playlists_count": "{{count}} lista de reprodução",
"generic_playlists_count_plural": "{{count}} listas de reprodução",
"generic_subscriptions_count": "{{count}} subscrição",
"generic_subscriptions_count_plural": "{{count}} subscrições",
"generic_views_count": "{{count}} visualização",
"generic_views_count_plural": "{{count}} visualizações",
"generic_subscribers_count": "{{count}} subscritor",
"generic_subscribers_count_plural": "{{count}} subscritores",
"preferences_quality_dash_option_4320p": "4320p",
"preferences_quality_dash_label": "Qualidade de vídeo DASH preferencial ",
"preferences_quality_dash_option_2160p": "2160p",
"subscriptions_unseen_notifs_count": "{{count}} notificação por ver",
"subscriptions_unseen_notifs_count_plural": "{{count}} notificações por ver",
"Popular enabled: ": "Página \"Popular\" ativada: ",
"search_message_no_results": "Nenhum resultado encontrado.",
"preferences_quality_dash_option_auto": "Automática",
"preferences_region_label": "País para o conteúdo: ",
"preferences_quality_dash_option_1440p": "1440p",
"preferences_quality_dash_option_720p": "720p",
"preferences_watch_history_label": "Ativar histórico de visualizações ",
"preferences_quality_dash_option_best": "Melhor",
"preferences_quality_dash_option_worst": "Pior",
"preferences_quality_dash_option_144p": "144p",
"invidious": "Invidious",
"preferences_quality_option_hd720": "HD720",
"preferences_quality_option_dash": "DASH (qualidade adaptativa)",
"preferences_quality_option_medium": "Média",
"preferences_quality_option_small": "Pequena",
"preferences_quality_dash_option_1080p": "1080p",
"preferences_quality_dash_option_480p": "480p",
"preferences_quality_dash_option_360p": "360p",
"preferences_quality_dash_option_240p": "240p",
"Video unavailable": "Vídeo indisponível",
"Russian (auto-generated)": "Russo (geradas automaticamente)",
"comments_view_x_replies": "Ver {{count}} resposta",
"comments_view_x_replies_plural": "Ver {{count}} respostas",
"comments_points_count": "{{count}} ponto",
"comments_points_count_plural": "{{count}} pontos",
"English (United Kingdom)": "Inglês (Reino Unido)",
"Chinese (Hong Kong)": "Chinês (Hong Kong)",
"Chinese (Taiwan)": "Chinês (Taiwan)",
"Dutch (auto-generated)": "Holandês (geradas automaticamente)",
"French (auto-generated)": "Francês (geradas automaticamente)",
"German (auto-generated)": "Alemão (geradas automaticamente)",
"Indonesian (auto-generated)": "Indonésio (geradas automaticamente)",
"Interlingue": "Interlingue",
"Italian (auto-generated)": "Italiano (geradas automaticamente)",
"Japanese (auto-generated)": "Japonês (geradas automaticamente)",
"Korean (auto-generated)": "Coreano (geradas automaticamente)",
"Portuguese (auto-generated)": "Português (geradas automaticamente)",
"Portuguese (Brazil)": "Português (Brasil)",
"Spanish (Spain)": "Espanhol (Espanha)",
"Vietnamese (auto-generated)": "Vietnamita (geradas automaticamente)",
"search_filters_type_option_all": "Qualquer tipo",
"search_filters_duration_option_none": "Qualquer duração",
"search_filters_duration_option_short": "Curto (< 4 minutos)",
"search_filters_duration_option_medium": "Médio (4 - 20 minutos)",
"search_filters_duration_option_long": "Longo (> 20 minutos)",
"search_filters_features_option_purchased": "Comprado",
"search_filters_apply_button": "Aplicar filtros selecionados",
"videoinfo_watch_on_youTube": "Ver no YouTube",
"videoinfo_youTube_embed_link": "Embutir",
"adminprefs_modified_source_code_url_label": "URL do repositório do código-fonte modificado",
"videoinfo_invidious_embed_link": "Ligação embutida",
"none": "nenhum",
"videoinfo_started_streaming_x_ago": "Entrou em direto há `x`",
"download_subtitles": "Legendas - `x` (.vtt)",
"user_created_playlists": "`x` listas de reprodução criadas",
"user_saved_playlists": "`x` listas de reprodução guardadas",
"preferences_save_player_pos_label": "Guardar posição de reprodução: ",
"Turkish (auto-generated)": "Turco (geradas automaticamente)",
"Cantonese (Hong Kong)": "Cantonês (Hong Kong)",
"Chinese (China)": "Chinês (China)",
"Spanish (auto-generated)": "Espanhol (geradas automaticamente)",
"Spanish (Mexico)": "Espanhol (México)",
"English (United States)": "Inglês (Estados Unidos)",
"footer_donate_page": "Doar",
"footer_documentation": "Documentação",
"footer_source_code": "Código-fonte",
"footer_original_source_code": "Código-fonte original",
"footer_modfied_source_code": "Código-fonte modificado",
"Chinese": "Chinês",
"search_filters_date_label": "Data de carregamento",
"search_filters_date_option_none": "Qualquer data",
"search_filters_features_option_three_sixty": "360°",
"search_filters_features_option_vr180": "VR180"
} }

View file

@ -470,5 +470,6 @@
"search_filters_date_label": "Data de publicação", "search_filters_date_label": "Data de publicação",
"search_filters_date_option_none": "Qualquer data", "search_filters_date_option_none": "Qualquer data",
"search_filters_type_option_all": "Qualquer tipo", "search_filters_type_option_all": "Qualquer tipo",
"search_filters_duration_option_none": "Qualquer duração" "search_filters_duration_option_none": "Qualquer duração",
"Popular enabled: ": "Página \"popular\" ativada: "
} }

View file

@ -75,11 +75,11 @@
"light": "светлая", "light": "светлая",
"preferences_thin_mode_label": "Облегчённое оформление: ", "preferences_thin_mode_label": "Облегчённое оформление: ",
"preferences_category_misc": "Прочие настройки", "preferences_category_misc": "Прочие настройки",
"preferences_automatic_instance_redirect_label": "Автоматическое перенаправление на зеркало сайта (переход на redirect.invidious.io): ", "preferences_automatic_instance_redirect_label": "Автоматическая смена зеркала (переход на redirect.invidious.io): ",
"preferences_category_subscription": "Настройки подписок", "preferences_category_subscription": "Настройки подписок",
"preferences_annotations_subscribed_label": "Всегда показывать аннотации на каналах из ваших подписок? ", "preferences_annotations_subscribed_label": "Всегда показывать аннотации на каналах из ваших подписок? ",
"Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ", "Redirect homepage to feed: ": "Показывать подписки на главной странице: ",
"preferences_max_results_label": "Число видео, на которые вы подписаны, в ленте: ", "preferences_max_results_label": "Число видео в ленте: ",
"preferences_sort_label": "Сортировать видео: ", "preferences_sort_label": "Сортировать видео: ",
"published": "по дате публикации", "published": "по дате публикации",
"published - reverse": "по дате публикации в обратном порядке", "published - reverse": "по дате публикации в обратном порядке",
@ -102,13 +102,13 @@
"Manage tokens": "Управление токенами", "Manage tokens": "Управление токенами",
"Watch history": "История просмотров", "Watch history": "История просмотров",
"Delete account": "Удалить аккаунт", "Delete account": "Удалить аккаунт",
"preferences_category_admin": "Администраторские настройки", "preferences_category_admin": "Настройки администратора",
"preferences_default_home_label": "Главная страница по умолчанию: ", "preferences_default_home_label": "Главная страница по умолчанию: ",
"preferences_feed_menu_label": "Меню ленты видео: ", "preferences_feed_menu_label": "Меню ленты видео: ",
"preferences_show_nick_label": "Показать ник вверху: ", "preferences_show_nick_label": "Показать ник вверху: ",
"Top enabled: ": "Включить топ видео? ", "Top enabled: ": "Включить топ видео? ",
"CAPTCHA enabled: ": "Включить капчу? ", "CAPTCHA enabled: ": "Включить капчу? ",
"Login enabled: ": "Включить авторизацию? ", "Login enabled: ": "Включить авторизацию: ",
"Registration enabled: ": "Включить регистрацию? ", "Registration enabled: ": "Включить регистрацию? ",
"Report statistics: ": "Сообщать статистику? ", "Report statistics: ": "Сообщать статистику? ",
"Save preferences": "Сохранить настройки", "Save preferences": "Сохранить настройки",
@ -186,7 +186,7 @@
"Could not fetch comments": "Не удаётся загрузить комментарии", "Could not fetch comments": "Не удаётся загрузить комментарии",
"`x` ago": "`x` назад", "`x` ago": "`x` назад",
"Load more": "Загрузить ещё", "Load more": "Загрузить ещё",
"Could not create mix.": "Не удаётся создать микс.", "Could not create mix.": "Не удалось создать микс.",
"Empty playlist": "Плейлист пуст", "Empty playlist": "Плейлист пуст",
"Not a playlist.": "Некорректный плейлист.", "Not a playlist.": "Некорректный плейлист.",
"Playlist does not exist.": "Плейлист не существует.", "Playlist does not exist.": "Плейлист не существует.",
@ -195,7 +195,7 @@
"Hidden field \"token\" is a required field": "Необходимо заполнить скрытое поле «токен»", "Hidden field \"token\" is a required field": "Необходимо заполнить скрытое поле «токен»",
"Erroneous challenge": "Неправильный ответ в «challenge»", "Erroneous challenge": "Неправильный ответ в «challenge»",
"Erroneous token": "Неправильный токен", "Erroneous token": "Неправильный токен",
"No such user": "Недопустимое имя пользователя", "No such user": "Пользователь не найден",
"Token is expired, please try again": "Срок действия токена истёк, попробуйте позже", "Token is expired, please try again": "Срок действия токена истёк, попробуйте позже",
"English": "Английский", "English": "Английский",
"English (auto-generated)": "Английский (созданы автоматически)", "English (auto-generated)": "Английский (созданы автоматически)",
@ -486,5 +486,6 @@
"search_filters_features_option_vr180": "VR180", "search_filters_features_option_vr180": "VR180",
"search_message_change_filters_or_query": "Попробуйте расширить поисковый запрос или изменить фильтры.", "search_message_change_filters_or_query": "Попробуйте расширить поисковый запрос или изменить фильтры.",
"search_filters_duration_option_medium": "Средние (4 - 20 минут)", "search_filters_duration_option_medium": "Средние (4 - 20 минут)",
"search_filters_apply_button": "Применить фильтры" "search_filters_apply_button": "Применить фильтры",
"Popular enabled: ": "Популярное включено: "
} }

126
locales/si.json Normal file
View file

@ -0,0 +1,126 @@
{
"generic_views_count": "බැලීම් {{count}}",
"generic_views_count_plural": "බැලීම් {{count}}",
"generic_videos_count": "{{count}} වීඩියෝව",
"generic_videos_count_plural": "වීඩියෝ {{count}}",
"generic_subscribers_count": "ග්‍රාහකයන් {{count}}",
"generic_subscribers_count_plural": "ග්‍රාහකයන් {{count}}",
"generic_subscriptions_count": "දායකත්ව {{count}}",
"generic_subscriptions_count_plural": "දායකත්ව {{count}}",
"Shared `x` ago": "`x` පෙර බෙදා ගන්නා ලදී",
"Unsubscribe": "දායක නොවන්න",
"View playlist on YouTube": "YouTube හි ධාවන ලැයිස්තුව බලන්න",
"newest": "අලුත්ම",
"oldest": "පැරණිතම",
"popular": "ජනප්‍රිය",
"last": "අවසන්",
"Cannot change password for Google accounts": "Google ගිණුම් සඳහා මුරපදය වෙනස් කළ නොහැක",
"Authorize token?": "ටෝකනය අනුමත කරනවා ද?",
"Authorize token for `x`?": "`x` සඳහා ටෝකනය අනුමත කරනවා ද?",
"Yes": "ඔව්",
"Import and Export Data": "දත්ත ආනයනය සහ අපනයනය කිරීම",
"Import": "ආනයන",
"Import Invidious data": "Invidious JSON දත්ත ආයාත කරන්න",
"Import FreeTube subscriptions (.db)": "FreeTube දායකත්වයන් (.db) ආයාත කරන්න",
"Import NewPipe subscriptions (.json)": "NewPipe දායකත්වයන් (.json) ආයාත කරන්න",
"Import NewPipe data (.zip)": "NewPipe දත්ත (.zip) ආයාත කරන්න",
"Export": "අපනයන",
"Export data as JSON": "Invidious දත්ත JSON ලෙස අපනයනය කරන්න",
"Delete account?": "ගිණුම මකාදමනවා ද?",
"History": "ඉතිහාසය",
"An alternative front-end to YouTube": "YouTube සඳහා විකල්ප ඉදිරිපස අන්තයක්",
"source": "මූලාශ්‍රය",
"Log in/register": "පුරන්න/ලියාපදිංචිවන්න",
"Log in with Google": "Google සමඟ පුරන්න",
"Password": "මුරපදය",
"Time (h:mm:ss):": "වේලාව (h:mm:ss):",
"Sign In": "පුරන්න",
"Preferences": "මනාපයන්",
"preferences_category_player": "වීඩියෝ ධාවක මනාපයන්",
"preferences_video_loop_label": "නැවත නැවතත්: ",
"preferences_autoplay_label": "ස්වයංක්‍රීය වාදනය: ",
"preferences_continue_label": "මීලඟට වාදනය කරන්න: ",
"preferences_continue_autoplay_label": "මීළඟ වීඩියෝව ස්වයංක්‍රීයව ධාවනය කරන්න: ",
"preferences_local_label": "Proxy වීඩියෝ: ",
"preferences_watch_history_label": "නැරඹුම් ඉතිහාසය සබල කරන්න: ",
"preferences_speed_label": "පෙරනිමි වේගය: ",
"preferences_quality_option_dash": "DASH (අනුවර්තිත ගුණත්වය)",
"preferences_quality_option_medium": "මධ්‍යස්ථ",
"preferences_quality_dash_label": "කැමති DASH වීඩියෝ ගුණත්වය: ",
"preferences_quality_dash_option_4320p": "4320p",
"preferences_quality_dash_option_1080p": "1080p",
"preferences_quality_dash_option_480p": "480p",
"preferences_quality_dash_option_360p": "360p",
"preferences_quality_dash_option_144p": "144p",
"preferences_volume_label": "ධාවකයේ හඬ: ",
"preferences_comments_label": "පෙරනිමි අදහස්: ",
"youtube": "YouTube",
"reddit": "Reddit",
"invidious": "Invidious",
"preferences_captions_label": "පෙරනිමි උපසිරැසි: ",
"preferences_related_videos_label": "අදාළ වීඩියෝ පෙන්වන්න: ",
"preferences_annotations_label": "අනුසටහන් පෙන්වන්න: ",
"preferences_vr_mode_label": "අන්තර්ක්‍රියාකාරී අංශක 360 වීඩියෝ (WebGL අවශ්‍යයි): ",
"preferences_region_label": "අන්තර්ගත රට: ",
"preferences_player_style_label": "වීඩියෝ ධාවක විලාසය: ",
"Dark mode: ": "අඳුරු මාදිලිය: ",
"preferences_dark_mode_label": "තේමාව: ",
"light": "ආලෝකමත්",
"generic_playlists_count": "{{count}} ධාවන ලැයිස්තුව",
"generic_playlists_count_plural": "ධාවන ලැයිස්තු {{count}}",
"LIVE": "සජීව",
"Subscribe": "දායක වන්න",
"View channel on YouTube": "YouTube හි නාලිකාව බලන්න",
"Next page": "ඊළඟ පිටුව",
"Previous page": "පෙර පිටුව",
"Clear watch history?": "නැරඹුම් ඉතිහාසය මකාදමනවා ද?",
"No": "නැත",
"Log in": "පුරන්න",
"New password": "නව මුරපදය",
"Import YouTube subscriptions": "YouTube/OPML දායකත්වයන් ආයාත කරන්න",
"Register": "ලියාපදිංචිවන්න",
"New passwords must match": "නව මුරපද ගැලපිය යුතුය",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML ලෙස දායකත්වයන් අපනයනය කරන්න (NewPipe සහ FreeTube සඳහා)",
"Export subscriptions as OPML": "දායකත්වයන් OPML ලෙස අපනයනය කරන්න",
"JavaScript license information": "JavaScript බලපත්‍ර තොරතුරු",
"User ID": "පරිශීලක කේතය",
"Text CAPTCHA": "CAPTCHA පෙල",
"Image CAPTCHA": "CAPTCHA රූපය",
"Google verification code": "Google සත්‍යාපන කේතය",
"E-mail": "විද්‍යුත් තැපෑල",
"preferences_quality_label": "කැමති වීඩියෝ ගුණත්වය: ",
"preferences_quality_option_hd720": "HD720",
"preferences_quality_dash_option_auto": "ස්වයංක්‍රීය",
"preferences_quality_option_small": "කුඩා",
"preferences_quality_dash_option_best": "උසස්",
"preferences_quality_dash_option_2160p": "2160p",
"preferences_quality_dash_option_1440p": "1440p",
"preferences_quality_dash_option_720p": "720p",
"preferences_quality_dash_option_240p": "240p",
"preferences_extend_desc_label": "වීඩියෝ විස්තරය ස්වයංක්‍රීයව දිගහරින්න: ",
"preferences_category_visual": "දෘශ්‍ය මනාපයන්",
"dark": "අඳුරු",
"preferences_category_misc": "විවිධ මනාප",
"preferences_category_subscription": "දායකත්ව මනාප",
"Redirect homepage to feed: ": "මුල් පිටුව පෝෂණය වෙත හරවා යවන්න: ",
"preferences_max_results_label": "සංග්‍රහයේ පෙන්වන වීඩියෝ ගණන: ",
"preferences_sort_label": "වීඩියෝ වර්ග කරන්න: ",
"alphabetically": "අකාරාදී ලෙස",
"alphabetically - reverse": "අකාරාදී - ආපසු",
"channel name": "නාලිකාවේ නම",
"Only show latest video from channel: ": "නාලිකාවේ නවතම වීඩියෝව පමණක් පෙන්වන්න: ",
"preferences_unseen_only_label": "නොබැලූ පමණක් පෙන්වන්න: ",
"Enable web notifications": "වෙබ් දැනුම්දීම් සබල කරන්න",
"Import/export data": "දත්ත ආනයනය / අපනයනය",
"Change password": "මුරපදය වෙනස් කරන්න",
"Manage subscriptions": "දායකත්ව කළමනාකරණය",
"Manage tokens": "ටෝකන කළමනාකරණය",
"Watch history": "නැරඹුම් ඉතිහාසය",
"Save preferences": "මනාප සුරකින්න",
"Token": "ටෝකනය",
"View privacy policy.": "රහස්‍යතා ප්‍රතිපත්තිය බලන්න.",
"Only show latest unwatched video from channel: ": "නාලිකාවේ නවතම නැරඹන නොලද වීඩියෝව පමණක් පෙන්වන්න: ",
"preferences_category_data": "දත්ත මනාප",
"Clear watch history": "නැරඹුම් ඉතිහාසය මකාදැමීම",
"Subscriptions": "දායකත්ව"
}

View file

@ -502,5 +502,6 @@
"crash_page_refresh": "poskušal/a <a href=\"`x`\">osvežiti stran</a>", "crash_page_refresh": "poskušal/a <a href=\"`x`\">osvežiti stran</a>",
"crash_page_before_reporting": "Preden prijaviš napako, se prepričaj, da si:", "crash_page_before_reporting": "Preden prijaviš napako, se prepričaj, da si:",
"crash_page_search_issue": "preiskal/a <a href=\"`x`\">obstoječe težave na GitHubu</a>", "crash_page_search_issue": "preiskal/a <a href=\"`x`\">obstoječe težave na GitHubu</a>",
"crash_page_report_issue": "Če nič od navedenega ni pomagalo, prosim <a href=\"`x`\">odpri novo težavo v GitHubu</a> (po možnosti v angleščini) in v svoje sporočilo vključi naslednje besedilo (tega besedila NE prevajaj):" "crash_page_report_issue": "Če nič od navedenega ni pomagalo, prosim <a href=\"`x`\">odpri novo težavo v GitHubu</a> (po možnosti v angleščini) in v svoje sporočilo vključi naslednje besedilo (tega besedila NE prevajaj):",
"Popular enabled: ": "Priljubljeni omogočeni: "
} }

View file

@ -470,5 +470,6 @@
"search_filters_duration_option_medium": "Orta (4 - 20 dakika)", "search_filters_duration_option_medium": "Orta (4 - 20 dakika)",
"search_filters_features_option_vr180": "VR180", "search_filters_features_option_vr180": "VR180",
"search_filters_title": "Filtreler", "search_filters_title": "Filtreler",
"search_message_change_filters_or_query": "Arama sorgunuzu genişletmeyi ve/veya filtreleri değiştirmeyi deneyin." "search_message_change_filters_or_query": "Arama sorgunuzu genişletmeyi ve/veya filtreleri değiştirmeyi deneyin.",
"Popular enabled: ": "Popüler etkin: "
} }

View file

@ -486,5 +486,6 @@
"search_filters_features_option_purchased": "Придбано", "search_filters_features_option_purchased": "Придбано",
"search_filters_sort_option_relevance": "Відповідні", "search_filters_sort_option_relevance": "Відповідні",
"search_filters_sort_option_rating": "Рейтингові", "search_filters_sort_option_rating": "Рейтингові",
"search_filters_sort_option_views": "Популярні" "search_filters_sort_option_views": "Популярні",
"Popular enabled: ": "Популярне ввімкнено: "
} }

View file

@ -454,5 +454,6 @@
"search_message_change_filters_or_query": "尝试扩大你的搜索查询和/或更改过滤器。", "search_message_change_filters_or_query": "尝试扩大你的搜索查询和/或更改过滤器。",
"search_filters_duration_option_none": "任意时长", "search_filters_duration_option_none": "任意时长",
"search_filters_type_option_all": "任意类型", "search_filters_type_option_all": "任意类型",
"search_filters_features_option_vr180": "VR180" "search_filters_features_option_vr180": "VR180",
"Popular enabled: ": "已启用流行度: "
} }

View file

@ -454,5 +454,6 @@
"search_filters_title": "過濾條件", "search_filters_title": "過濾條件",
"search_filters_date_label": "上傳日期", "search_filters_date_label": "上傳日期",
"search_filters_type_option_all": "任何類型", "search_filters_type_option_all": "任何類型",
"search_filters_date_option_none": "任何日期" "search_filters_date_option_none": "任何日期",
"Popular enabled: ": "已啟用人氣: "
} }

2
mocks

@ -1 +1 @@
Subproject commit 020337194dd482c47ee2d53cd111d0ebf2831e52 Subproject commit c401dd9203434b561022242c24b0c200d72284c0

View file

@ -6,7 +6,7 @@
interactive=true interactive=true
if [ "$1" == "--no-interactive" ]; then if [ "$1" = "--no-interactive" ]; then
interactive=false interactive=false
fi fi
@ -21,7 +21,7 @@ sudo systemctl enable postgresql.service
# Create databse and user # Create databse and user
# #
if [ "$interactive" == "true" ]; then if [ "$interactive" = "true" ]; then
sudo -u postgres -- createuser -P kemal sudo -u postgres -- createuser -P kemal
sudo -u postgres -- createdb -O kemal invidious sudo -u postgres -- createdb -O kemal invidious
else else

View file

@ -74,7 +74,7 @@ install_apt() {
sudo apt-get install --yes --no-install-recommends \ sudo apt-get install --yes --no-install-recommends \
libssl-dev libxml2-dev libyaml-dev libgmp-dev libevent-dev \ libssl-dev libxml2-dev libyaml-dev libgmp-dev libevent-dev \
libpcre3-dev libreadline-dev libsqlite3-dev zlib1g-dev \ libpcre3-dev libreadline-dev libsqlite3-dev zlib1g-dev \
crystal postgres git librsvg2-bin make crystal postgresql-13 git librsvg2-bin make
} }
install_yum() { install_yum() {

View file

@ -197,4 +197,46 @@ Spectator.describe Invidious::Search::Query do
) )
end end
end end
describe "#to_http_params" do
it "formats regular search" do
query = described_class.new(
HTTP::Params.parse("q=The+Simpsons+hiding+in+bush&duration=short"),
Invidious::Search::Query::Type::Regular, nil
)
params = query.to_http_params
expect(params).to have_key("duration")
expect(params["duration"]?).to eq("short")
expect(params).to have_key("q")
expect(params["q"]?).to eq("The Simpsons hiding in bush")
# Check if there aren't other parameters
params.delete("duration")
params.delete("q")
expect(params).to be_empty
end
it "formats channel search" do
query = described_class.new(
HTTP::Params.parse("q=channel:UC2DjFE7Xf11URZqWBigcVOQ%20multimeter"),
Invidious::Search::Query::Type::Regular, nil
)
params = query.to_http_params
expect(params).to have_key("channel")
expect(params["channel"]?).to eq("UC2DjFE7Xf11URZqWBigcVOQ")
expect(params).to have_key("q")
expect(params["q"]?).to eq("multimeter")
# Check if there aren't other parameters
params.delete("channel")
params.delete("q")
expect(params).to be_empty
end
end
end end

View file

@ -0,0 +1,113 @@
require "../../parsers_helper.cr"
Spectator.describe Invidious::Hashtag do
it "parses scheduled livestreams data (test 1)" do
# Enable mock
_player = load_mock("video/scheduled_live_nintendo.player")
_next = load_mock("video/scheduled_live_nintendo.next")
raw_data = _player.merge!(_next)
info = parse_video_info("QMGibBzTu0g", raw_data)
# Some basic verifications
expect(typeof(info)).to eq(Hash(String, JSON::Any))
expect(info["shortDescription"].as_s).to eq(
"Tune in on 6/22 at 7 a.m. PT for a livestreamed Xenoblade Chronicles 3 Direct presentation featuring roughly 20 minutes of information about the upcoming RPG adventure for Nintendo Switch."
)
expect(info["descriptionHtml"].as_s).to eq(
"Tune in on 6/22 at 7 a.m. PT for a livestreamed Xenoblade Chronicles 3 Direct presentation featuring roughly 20 minutes of information about the upcoming RPG adventure for Nintendo Switch."
)
expect(info["likes"].as_i).to eq(2_283)
expect(info["genre"].as_s).to eq("Gaming")
expect(info["genreUrl"].raw).to be_nil
expect(info["genreUcid"].as_s).to be_empty
expect(info["license"].as_s).to be_empty
expect(info["authorThumbnail"].as_s).to eq(
"https://yt3.ggpht.com/ytc/AKedOLTt4vtjREUUNdHlyu9c4gtJjG90M9jQheRlLKy44A=s48-c-k-c0x00ffffff-no-rj"
)
expect(info["authorVerified"].as_bool).to be_true
expect(info["subCountText"].as_s).to eq("8.5M")
expect(info["relatedVideos"].as_a.size).to eq(20)
# related video #1
expect(info["relatedVideos"][3]["id"].as_s).to eq("a-SN3lLIUEo")
expect(info["relatedVideos"][3]["author"].as_s).to eq("Nintendo")
expect(info["relatedVideos"][3]["ucid"].as_s).to eq("UCGIY_O-8vW4rfX98KlMkvRg")
expect(info["relatedVideos"][3]["view_count"].as_s).to eq("147796")
expect(info["relatedVideos"][3]["short_view_count"].as_s).to eq("147K")
expect(info["relatedVideos"][3]["author_verified"].as_s).to eq("true")
# Related video #2
expect(info["relatedVideos"][16]["id"].as_s).to eq("l_uC1jFK0lo")
expect(info["relatedVideos"][16]["author"].as_s).to eq("Nintendo")
expect(info["relatedVideos"][16]["ucid"].as_s).to eq("UCGIY_O-8vW4rfX98KlMkvRg")
expect(info["relatedVideos"][16]["view_count"].as_s).to eq("53510")
expect(info["relatedVideos"][16]["short_view_count"].as_s).to eq("53K")
expect(info["relatedVideos"][16]["author_verified"].as_s).to eq("true")
end
it "parses scheduled livestreams data (test 2)" do
# Enable mock
_player = load_mock("video/scheduled_live_PBD-Podcast.player")
_next = load_mock("video/scheduled_live_PBD-Podcast.next")
raw_data = _player.merge!(_next)
info = parse_video_info("RG0cjYbXxME", raw_data)
# Some basic verifications
expect(typeof(info)).to eq(Hash(String, JSON::Any))
expect(info["shortDescription"].as_s).to start_with(
<<-TXT
PBD Podcast Episode 171. In this episode, Patrick Bet-David is joined by Dr. Patrick Moore and Adam Sosnick.
Join the channel to get exclusive access to perks: https://bit.ly/3Q9rSQL
TXT
)
expect(info["descriptionHtml"].as_s).to start_with(
<<-TXT
PBD Podcast Episode 171. In this episode, Patrick Bet-David is joined by Dr. Patrick Moore and Adam Sosnick.
Join the channel to get exclusive access to perks: <a href="https://bit.ly/3Q9rSQL">bit.ly/3Q9rSQL</a>
TXT
)
expect(info["likes"].as_i).to eq(22)
expect(info["genre"].as_s).to eq("Entertainment")
expect(info["genreUrl"].raw).to be_nil
expect(info["genreUcid"].as_s).to be_empty
expect(info["license"].as_s).to be_empty
expect(info["authorThumbnail"].as_s).to eq(
"https://yt3.ggpht.com/61ArDiQshJrvSXcGLhpFfIO3hlMabe2fksitcf6oGob0Mdr5gztdkXxRljICUodL4iuTSrtxW4A=s48-c-k-c0x00ffffff-no-rj"
)
expect(info["authorVerified"].as_bool).to be_false
expect(info["subCountText"].as_s).to eq("227K")
expect(info["relatedVideos"].as_a.size).to eq(20)
# related video #1
expect(info["relatedVideos"][2]["id"]).to eq("La9oLLoI5Rc")
expect(info["relatedVideos"][2]["author"]).to eq("Tom Bilyeu")
expect(info["relatedVideos"][2]["ucid"]).to eq("UCnYMOamNKLGVlJgRUbamveA")
expect(info["relatedVideos"][2]["view_count"]).to eq("13329149")
expect(info["relatedVideos"][2]["short_view_count"]).to eq("13M")
expect(info["relatedVideos"][2]["author_verified"]).to eq("true")
# Related video #2
expect(info["relatedVideos"][9]["id"]).to eq("IQ_4fvpzYuA")
expect(info["relatedVideos"][9]["author"]).to eq("Business Today")
expect(info["relatedVideos"][9]["ucid"]).to eq("UCaPHWiExfUWaKsUtENLCv5w")
expect(info["relatedVideos"][9]["view_count"]).to eq("26432")
expect(info["relatedVideos"][9]["short_view_count"]).to eq("26K")
expect(info["relatedVideos"][9]["author_verified"]).to eq("true")
end
end

View file

@ -6,6 +6,7 @@ require "protodec/utils"
require "spectator" require "spectator"
require "../src/invidious/exceptions"
require "../src/invidious/helpers/macros" require "../src/invidious/helpers/macros"
require "../src/invidious/helpers/logger" require "../src/invidious/helpers/logger"
require "../src/invidious/helpers/utils" require "../src/invidious/helpers/utils"

View file

@ -136,12 +136,13 @@ Invidious::Database.check_integrity(CONFIG)
# Running the script by itself would show some colorful feedback while this doesn't. # Running the script by itself would show some colorful feedback while this doesn't.
# Perhaps we should just move the script to runtime in order to get that feedback? # Perhaps we should just move the script to runtime in order to get that feedback?
{% puts "\nChecking player dependencies...\n" %} {% puts "\nChecking player dependencies, this may take more than 20 minutes... If it is stuck, check your internet connection.\n" %}
{% if flag?(:minified_player_dependencies) %} {% if flag?(:minified_player_dependencies) %}
{% puts run("../scripts/fetch-player-dependencies.cr", "--minified").stringify %} {% puts run("../scripts/fetch-player-dependencies.cr", "--minified").stringify %}
{% else %} {% else %}
{% puts run("../scripts/fetch-player-dependencies.cr").stringify %} {% puts run("../scripts/fetch-player-dependencies.cr").stringify %}
{% end %} {% end %}
{% puts "\nDone checking player dependencies, now compiling Invidious...\n" %}
{% end %} {% end %}
# Start jobs # Start jobs
@ -180,305 +181,19 @@ def popular_videos
Invidious::Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get Invidious::Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get
end end
# Routing
before_all do |env| before_all do |env|
preferences = Preferences.from_json("{}") Invidious::Routes::BeforeAll.handle(env)
begin
if prefs_cookie = env.request.cookies["PREFS"]?
preferences = Preferences.from_json(URI.decode_www_form(prefs_cookie.value))
else
if language_header = env.request.headers["Accept-Language"]?
if language = ANG.language_negotiator.best(language_header, LOCALES.keys)
preferences.locale = language.header
end
end
end
rescue
preferences = Preferences.from_json("{}")
end end
env.set "preferences", preferences Invidious::Routing.register_all
env.response.headers["X-XSS-Protection"] = "1; mode=block"
env.response.headers["X-Content-Type-Options"] = "nosniff"
# Allow media resources to be loaded from google servers
# TODO: check if *.youtube.com can be removed
if CONFIG.disabled?("local") || !preferences.local
extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443"
else
extra_media_csp = ""
end
# Only allow the pages at /embed/* to be embedded
if env.request.resource.starts_with?("/embed")
frame_ancestors = "'self' http: https:"
else
frame_ancestors = "'none'"
end
# TODO: Remove style-src's 'unsafe-inline', requires to remove all
# inline styles (<style> [..] </style>, style=" [..] ")
env.response.headers["Content-Security-Policy"] = {
"default-src 'none'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'self'",
"manifest-src 'self'",
"media-src 'self' blob:" + extra_media_csp,
"child-src 'self' blob:",
"frame-src 'self'",
"frame-ancestors " + frame_ancestors,
}.join("; ")
env.response.headers["Referrer-Policy"] = "same-origin"
# Ask the chrom*-based browsers to disable FLoC
# See: https://blog.runcloud.io/google-floc/
env.response.headers["Permissions-Policy"] = "interest-cohort=()"
if (Kemal.config.ssl || CONFIG.https_only) && CONFIG.hsts
env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
end
next if {
"/sb/",
"/vi/",
"/s_p/",
"/yts/",
"/ggpht/",
"/api/manifest/",
"/videoplayback",
"/latest_version",
"/download",
}.any? { |r| env.request.resource.starts_with? r }
if env.request.cookies.has_key? "SID"
sid = env.request.cookies["SID"].value
if sid.starts_with? "v1:"
raise "Cannot use token as SID"
end
# Invidious users only have SID
if !env.request.cookies.has_key? "SSID"
if email = Invidious::Database::SessionIDs.select_email(sid)
user = Invidious::Database::Users.select!(email: email)
csrf_token = generate_response(sid, {
":authorize_token",
":playlist_ajax",
":signout",
":subscription_ajax",
":token_ajax",
":watch_ajax",
}, HMAC_KEY, 1.week)
preferences = user.preferences
env.set "preferences", preferences
env.set "sid", sid
env.set "csrf_token", csrf_token
env.set "user", user
end
else
headers = HTTP::Headers.new
headers["Cookie"] = env.request.headers["Cookie"]
begin
user, sid = get_user(sid, headers, false)
csrf_token = generate_response(sid, {
":authorize_token",
":playlist_ajax",
":signout",
":subscription_ajax",
":token_ajax",
":watch_ajax",
}, HMAC_KEY, 1.week)
preferences = user.preferences
env.set "preferences", preferences
env.set "sid", sid
env.set "csrf_token", csrf_token
env.set "user", user
rescue ex
end
end
end
dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s
thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode.to_s
thin_mode = thin_mode == "true"
locale = env.params.query["hl"]? || preferences.locale
preferences.dark_mode = dark_mode
preferences.thin_mode = thin_mode
preferences.locale = locale
env.set "preferences", preferences
current_page = env.request.path
if env.request.query
query = HTTP::Params.parse(env.request.query.not_nil!)
if query["referer"]?
query["referer"] = get_referer(env, "/")
end
current_page += "?#{query}"
end
env.set "current_page", URI.encode_www_form(current_page)
end
{% unless flag?(:api_only) %}
Invidious::Routing.get "/", Invidious::Routes::Misc, :home
Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy
Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses
Invidious::Routing.get "/channel/:ucid", Invidious::Routes::Channels, :home
Invidious::Routing.get "/channel/:ucid/home", Invidious::Routes::Channels, :home
Invidious::Routing.get "/channel/:ucid/videos", Invidious::Routes::Channels, :videos
Invidious::Routing.get "/channel/:ucid/playlists", Invidious::Routes::Channels, :playlists
Invidious::Routing.get "/channel/:ucid/community", Invidious::Routes::Channels, :community
Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about
Invidious::Routing.get "/channel/:ucid/live", Invidious::Routes::Channels, :live
Invidious::Routing.get "/user/:user/live", Invidious::Routes::Channels, :live
Invidious::Routing.get "/c/:user/live", Invidious::Routes::Channels, :live
["", "/videos", "/playlists", "/community", "/about"].each do |path|
# /c/LinusTechTips
Invidious::Routing.get "/c/:user#{path}", Invidious::Routes::Channels, :brand_redirect
# /user/linustechtips | Not always the same as /c/
Invidious::Routing.get "/user/:user#{path}", Invidious::Routes::Channels, :brand_redirect
# /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow
Invidious::Routing.get "/attribution_link#{path}", Invidious::Routes::Channels, :brand_redirect
# /profile?user=linustechtips
Invidious::Routing.get "/profile/#{path}", Invidious::Routes::Channels, :profile
end
Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle
Invidious::Routing.post "/watch_ajax", Invidious::Routes::Watch, :mark_watched
Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect
Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect
Invidious::Routing.get "/clip/:clip", Invidious::Routes::Watch, :clip
Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect
Invidious::Routing.get "/v/:id", Invidious::Routes::Watch, :redirect
Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect
Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_redirect
Invidious::Routing.post "/download", Invidious::Routes::Watch, :download
Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect
Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show
Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new
Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create
Invidious::Routing.get "/subscribe_playlist", Invidious::Routes::Playlists, :subscribe
Invidious::Routing.get "/delete_playlist", Invidious::Routes::Playlists, :delete_page
Invidious::Routing.post "/delete_playlist", Invidious::Routes::Playlists, :delete
Invidious::Routing.get "/edit_playlist", Invidious::Routes::Playlists, :edit
Invidious::Routing.post "/edit_playlist", Invidious::Routes::Playlists, :update
Invidious::Routing.get "/add_playlist_items", Invidious::Routes::Playlists, :add_playlist_items_page
Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax
Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show
Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix
Invidious::Routing.get "/watch_videos", Invidious::Routes::Playlists, :watch_videos
Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch
Invidious::Routing.get "/results", Invidious::Routes::Search, :results
Invidious::Routing.get "/search", Invidious::Routes::Search, :search
Invidious::Routing.get "/hashtag/:hashtag", Invidious::Routes::Search, :hashtag
# User routes
define_user_routes()
# Feeds
Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect
Invidious::Routing.get "/feed/playlists", Invidious::Routes::Feeds, :playlists
Invidious::Routing.get "/feed/popular", Invidious::Routes::Feeds, :popular
Invidious::Routing.get "/feed/trending", Invidious::Routes::Feeds, :trending
Invidious::Routing.get "/feed/subscriptions", Invidious::Routes::Feeds, :subscriptions
Invidious::Routing.get "/feed/history", Invidious::Routes::Feeds, :history
# RSS Feeds
Invidious::Routing.get "/feed/channel/:ucid", Invidious::Routes::Feeds, :rss_channel
Invidious::Routing.get "/feed/private", Invidious::Routes::Feeds, :rss_private
Invidious::Routing.get "/feed/playlist/:plid", Invidious::Routes::Feeds, :rss_playlist
Invidious::Routing.get "/feeds/videos.xml", Invidious::Routes::Feeds, :rss_videos
# Support push notifications via PubSubHubbub
Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get
Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post
Invidious::Routing.get "/modify_notifications", Invidious::Routes::Notifications, :modify
Invidious::Routing.post "/subscription_ajax", Invidious::Routes::Subscriptions, :toggle_subscription
Invidious::Routing.get "/subscription_manager", Invidious::Routes::Subscriptions, :subscription_manager
{% end %}
Invidious::Routing.get "/ggpht/*", Invidious::Routes::Images, :ggpht
Invidious::Routing.options "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :options_storyboard
Invidious::Routing.get "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :get_storyboard
Invidious::Routing.get "/s_p/:id/:name", Invidious::Routes::Images, :s_p_image
Invidious::Routing.get "/yts/img/:name", Invidious::Routes::Images, :yts_image
Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails
# API routes (macro)
define_v1_api_routes()
# Video playback (macros)
define_api_manifest_routes()
define_video_playback_routes()
error 404 do |env| error 404 do |env|
if md = env.request.path.match(/^\/(?<id>([a-zA-Z0-9_-]{11})|(\w+))$/) Invidious::Routes::ErrorRoutes.error_404(env)
item = md["id"]
# Check if item is branding URL e.g. https://youtube.com/gaming
response = YT_POOL.client &.get("/#{item}")
if response.status_code == 301
response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target)
end
if response.body.empty?
env.response.headers["Location"] = "/"
halt env, status_code: 302
end
html = XML.parse_html(response.body)
ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
if ucid
env.response.headers["Location"] = "/channel/#{ucid}"
halt env, status_code: 302
end
params = [] of String
env.params.query.each do |k, v|
params << "#{k}=#{v}"
end
params = params.join("&")
url = "/watch?v=#{item}"
if !params.empty?
url += "&#{params}"
end
# Check if item is video ID
if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404
env.response.headers["Location"] = url
halt env, status_code: 302
end
end
env.response.headers["Location"] = "/"
halt env, status_code: 302
end end
error 500 do |env, ex| error 500 do |env, ex|
locale = env.get("preferences").as(Preferences).locale
error_template(500, ex) error_template(500, ex)
end end
@ -486,6 +201,8 @@ static_headers do |response|
response.headers.add("Cache-Control", "max-age=2629800") response.headers.add("Cache-Control", "max-age=2629800")
end end
# Init Kemal
public_folder "assets" public_folder "assets"
Kemal.config.powered_by_header = false Kemal.config.powered_by_header = false

View file

@ -31,7 +31,12 @@ def get_about_info(ucid, locale) : AboutChannel
end end
if initdata.dig?("alerts", 0, "alertRenderer", "type") == "ERROR" if initdata.dig?("alerts", 0, "alertRenderer", "type") == "ERROR"
raise InfoException.new(initdata["alerts"][0]["alertRenderer"]["text"]["simpleText"].as_s) error_message = initdata["alerts"][0]["alertRenderer"]["text"]["simpleText"].as_s
if error_message == "This channel does not exist."
raise NotFoundException.new(error_message)
else
raise InfoException.new(error_message)
end
end end
if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]? if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]?
@ -54,9 +59,6 @@ def get_about_info(ucid, locale) : AboutChannel
banner = banners.try &.[-1]?.try &.["url"].as_s? banner = banners.try &.[-1]?.try &.["url"].as_s?
description_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"] description_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]
is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s)
else else
author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
@ -74,13 +76,17 @@ def get_about_info(ucid, locale) : AboutChannel
# end # end
description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]? description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?
end
is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s)
end allowed_regions = initdata
.dig?("microformat", "microformatDataRenderer", "availableCountries")
.try &.as_a.map(&.as_s) || [] of String
description = !description_node.nil? ? description_node.as_s : "" description = !description_node.nil? ? description_node.as_s : ""
description_html = HTML.escape(description) description_html = HTML.escape(description)
if !description_node.nil? if !description_node.nil?
if description_node.as_h?.nil? if description_node.as_h?.nil?
description_node = text_to_parsed_content(description_node.as_s) description_node = text_to_parsed_content(description_node.as_s)

View file

@ -6,20 +6,18 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
end end
if response.status_code != 200 if response.status_code != 200
raise InfoException.new("This channel does not exist.") raise NotFoundException.new("This channel does not exist.")
end end
ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"] ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"]
if !continuation || continuation.empty? if !continuation || continuation.empty?
initial_data = extract_initial_data(response.body) initial_data = extract_initial_data(response.body)
body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]? body = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"])["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]
if !body if !body
raise InfoException.new("Could not extract community tab.") raise InfoException.new("Could not extract community tab.")
end end
body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]
else else
continuation = produce_channel_community_continuation(ucid, continuation) continuation = produce_channel_community_continuation(ucid, continuation)
@ -49,8 +47,12 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
error_message = (message["text"]["simpleText"]? || error_message = (message["text"]["simpleText"]? ||
message["text"]["runs"]?.try &.[0]?.try &.["text"]?) message["text"]["runs"]?.try &.[0]?.try &.["text"]?)
.try &.as_s || "" .try &.as_s || ""
if error_message == "This channel does not exist."
raise NotFoundException.new(error_message)
else
raise InfoException.new(error_message) raise InfoException.new(error_message)
end end
end
response = JSON.build do |json| response = JSON.build do |json|
json.object do json.object do

View file

@ -95,7 +95,7 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b
contents = body["contents"]? contents = body["contents"]?
header = body["header"]? header = body["header"]?
else else
raise InfoException.new("Could not fetch comments") raise NotFoundException.new("Comments not found.")
end end
if !contents if !contents
@ -290,7 +290,7 @@ def fetch_reddit_comments(id, sort_by = "confidence")
thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink) thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink)
else else
raise InfoException.new("Could not fetch comments") raise NotFoundException.new("Comments not found.")
end end
client.close client.close

View file

@ -86,7 +86,7 @@ class Config
@[YAML::Field(converter: Preferences::URIConverter)] @[YAML::Field(converter: Preferences::URIConverter)]
property database_url : URI = URI.parse("") property database_url : URI = URI.parse("")
# Use polling to keep decryption function up to date # Use polling to keep decryption function up to date
property decrypt_polling : Bool = true property decrypt_polling : Bool = false
# Used for crawling channels: threads should check all videos uploaded by a channel # Used for crawling channels: threads should check all videos uploaded by a channel
property full_refresh : Bool = false property full_refresh : Bool = false
# Used to tell Invidious it is behind a proxy, so links to resources should be https:// # Used to tell Invidious it is behind a proxy, so links to resources should be https://

View file

@ -1,3 +1,11 @@
# InfoExceptions are for displaying information to the user.
#
# An InfoException might or might not indicate that something went wrong.
# Historically Invidious didn't differentiate between these two options, so to
# maintain previous functionality InfoExceptions do not print backtraces.
class InfoException < Exception
end
# Exception used to hold the bogus UCID during a channel search. # Exception used to hold the bogus UCID during a channel search.
class ChannelSearchException < InfoException class ChannelSearchException < InfoException
getter channel : String getter channel : String
@ -18,3 +26,7 @@ class BrokenTubeException < Exception
return "Missing JSON element \"#{@element}\"" return "Missing JSON element \"#{@element}\""
end end
end end
# Exception threw when an element is not found.
class NotFoundException < InfoException
end

View file

@ -1,11 +1,3 @@
# InfoExceptions are for displaying information to the user.
#
# An InfoException might or might not indicate that something went wrong.
# Historically Invidious didn't differentiate between these two options, so to
# maintain previous functionality InfoExceptions do not print backtraces.
class InfoException < Exception
end
# ------------------- # -------------------
# Issue template # Issue template
# ------------------- # -------------------

View file

@ -317,7 +317,7 @@ def get_playlist(plid : String)
if playlist = Invidious::Database::Playlists.select(id: plid) if playlist = Invidious::Database::Playlists.select(id: plid)
return playlist return playlist
else else
raise InfoException.new("Playlist does not exist.") raise NotFoundException.new("Playlist does not exist.")
end end
else else
return fetch_playlist(plid) return fetch_playlist(plid)

View file

@ -16,6 +16,8 @@ module Invidious::Routes::API::Manifest
video = get_video(id, region: region) video = get_video(id, region: region)
rescue ex : VideoRedirect rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id) return env.redirect env.request.resource.gsub(id, ex.video_id)
rescue ex : NotFoundException
haltf env, status_code: 404
rescue ex rescue ex
haltf env, status_code: 403 haltf env, status_code: 403
end end
@ -46,7 +48,7 @@ module Invidious::Routes::API::Manifest
end end
end end
audio_streams = video.audio_streams audio_streams = video.audio_streams.sort_by { |stream| {stream["bitrate"].as_i} }.reverse!
video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse! video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse!
manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml| manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml|
@ -60,16 +62,22 @@ module Invidious::Routes::API::Manifest
mime_streams = audio_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } mime_streams = audio_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type }
next if mime_streams.empty? next if mime_streams.empty?
xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do
mime_streams.each do |fmt| mime_streams.each do |fmt|
# OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415)
next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange"))
# 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
codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"')
bandwidth = fmt["bitrate"].as_i bandwidth = fmt["bitrate"].as_i
itag = fmt["itag"].as_i itag = fmt["itag"].as_i
url = fmt["url"].as_s url = fmt["url"].as_s
xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: i == 0 ? "main" : "alternate")
xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do
xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011", xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011",
value: "2") value: "2")
@ -79,10 +87,9 @@ module Invidious::Routes::API::Manifest
end end
end end
end end
end
i += 1 i += 1
end end
end
potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144} potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144}

View file

@ -237,6 +237,8 @@ module Invidious::Routes::API::V1::Authenticated
begin begin
video = get_video(video_id) video = get_video(video_id)
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex rescue ex
return error_json(500, ex) return error_json(500, ex)
end end

View file

@ -13,6 +13,8 @@ module Invidious::Routes::API::V1::Channels
rescue ex : ChannelRedirect rescue ex : ChannelRedirect
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex rescue ex
return error_json(500, ex) return error_json(500, ex)
end end
@ -170,6 +172,8 @@ module Invidious::Routes::API::V1::Channels
rescue ex : ChannelRedirect rescue ex : ChannelRedirect
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex rescue ex
return error_json(500, ex) return error_json(500, ex)
end end
@ -205,6 +209,8 @@ module Invidious::Routes::API::V1::Channels
rescue ex : ChannelRedirect rescue ex : ChannelRedirect
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex rescue ex
return error_json(500, ex) return error_json(500, ex)
end end

View file

@ -12,6 +12,8 @@ module Invidious::Routes::API::V1::Videos
rescue ex : VideoRedirect rescue ex : VideoRedirect
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex rescue ex
return error_json(500, ex) return error_json(500, ex)
end end
@ -42,6 +44,8 @@ module Invidious::Routes::API::V1::Videos
rescue ex : VideoRedirect rescue ex : VideoRedirect
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
rescue ex : NotFoundException
haltf env, 404
rescue ex rescue ex
haltf env, 500 haltf env, 500
end end
@ -167,6 +171,8 @@ module Invidious::Routes::API::V1::Videos
rescue ex : VideoRedirect rescue ex : VideoRedirect
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
rescue ex : NotFoundException
haltf env, 404
rescue ex rescue ex
haltf env, 500 haltf env, 500
end end
@ -324,6 +330,8 @@ module Invidious::Routes::API::V1::Videos
begin begin
comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by) comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by)
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex rescue ex
return error_json(500, ex) return error_json(500, ex)
end end

View file

@ -0,0 +1,152 @@
module Invidious::Routes::BeforeAll
def self.handle(env)
preferences = Preferences.from_json("{}")
begin
if prefs_cookie = env.request.cookies["PREFS"]?
preferences = Preferences.from_json(URI.decode_www_form(prefs_cookie.value))
else
if language_header = env.request.headers["Accept-Language"]?
if language = ANG.language_negotiator.best(language_header, LOCALES.keys)
preferences.locale = language.header
end
end
end
rescue
preferences = Preferences.from_json("{}")
end
env.set "preferences", preferences
env.response.headers["X-XSS-Protection"] = "1; mode=block"
env.response.headers["X-Content-Type-Options"] = "nosniff"
# Allow media resources to be loaded from google servers
# TODO: check if *.youtube.com can be removed
if CONFIG.disabled?("local") || !preferences.local
extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443"
else
extra_media_csp = ""
end
# Only allow the pages at /embed/* to be embedded
if env.request.resource.starts_with?("/embed")
frame_ancestors = "'self' http: https:"
else
frame_ancestors = "'none'"
end
# TODO: Remove style-src's 'unsafe-inline', requires to remove all
# inline styles (<style> [..] </style>, style=" [..] ")
env.response.headers["Content-Security-Policy"] = {
"default-src 'none'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'self'",
"manifest-src 'self'",
"media-src 'self' blob:" + extra_media_csp,
"child-src 'self' blob:",
"frame-src 'self'",
"frame-ancestors " + frame_ancestors,
}.join("; ")
env.response.headers["Referrer-Policy"] = "same-origin"
# Ask the chrom*-based browsers to disable FLoC
# See: https://blog.runcloud.io/google-floc/
env.response.headers["Permissions-Policy"] = "interest-cohort=()"
if (Kemal.config.ssl || CONFIG.https_only) && CONFIG.hsts
env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
end
return if {
"/sb/",
"/vi/",
"/s_p/",
"/yts/",
"/ggpht/",
"/api/manifest/",
"/videoplayback",
"/latest_version",
"/download",
}.any? { |r| env.request.resource.starts_with? r }
if env.request.cookies.has_key? "SID"
sid = env.request.cookies["SID"].value
if sid.starts_with? "v1:"
raise "Cannot use token as SID"
end
# Invidious users only have SID
if !env.request.cookies.has_key? "SSID"
if email = Invidious::Database::SessionIDs.select_email(sid)
user = Invidious::Database::Users.select!(email: email)
csrf_token = generate_response(sid, {
":authorize_token",
":playlist_ajax",
":signout",
":subscription_ajax",
":token_ajax",
":watch_ajax",
}, HMAC_KEY, 1.week)
preferences = user.preferences
env.set "preferences", preferences
env.set "sid", sid
env.set "csrf_token", csrf_token
env.set "user", user
end
else
headers = HTTP::Headers.new
headers["Cookie"] = env.request.headers["Cookie"]
begin
user, sid = get_user(sid, headers, false)
csrf_token = generate_response(sid, {
":authorize_token",
":playlist_ajax",
":signout",
":subscription_ajax",
":token_ajax",
":watch_ajax",
}, HMAC_KEY, 1.week)
preferences = user.preferences
env.set "preferences", preferences
env.set "sid", sid
env.set "csrf_token", csrf_token
env.set "user", user
rescue ex
end
end
end
dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s
thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode.to_s
thin_mode = thin_mode == "true"
locale = env.params.query["hl"]? || preferences.locale
preferences.dark_mode = dark_mode
preferences.thin_mode = thin_mode
preferences.locale = locale
env.set "preferences", preferences
current_page = env.request.path
if env.request.query
query = HTTP::Params.parse(env.request.query.not_nil!)
if query["referer"]?
query["referer"] = get_referer(env, "/")
end
current_page += "?#{query}"
end
env.set "current_page", URI.encode_www_form(current_page)
end
end

View file

@ -85,6 +85,9 @@ module Invidious::Routes::Channels
rescue ex : InfoException rescue ex : InfoException
env.response.status_code = 500 env.response.status_code = 500
error_message = ex.message error_message = ex.message
rescue ex : NotFoundException
env.response.status_code = 404
error_message = ex.message
rescue ex rescue ex
return error_template(500, ex) return error_template(500, ex)
end end
@ -118,7 +121,7 @@ module Invidious::Routes::Channels
resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}") resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}")
ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"] ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"]
rescue ex : InfoException | KeyError rescue ex : InfoException | KeyError
raise InfoException.new(translate(locale, "This channel does not exist.")) return error_template(404, translate(locale, "This channel does not exist."))
end end
selected_tab = env.request.path.split("/")[-1] selected_tab = env.request.path.split("/")[-1]
@ -141,7 +144,7 @@ module Invidious::Routes::Channels
user = env.params.query["user"]? user = env.params.query["user"]?
if !user if !user
raise InfoException.new("This channel does not exist.") return error_template(404, "This channel does not exist.")
else else
env.redirect "/user/#{user}#{uri_params}" env.redirect "/user/#{user}#{uri_params}"
end end
@ -197,6 +200,8 @@ module Invidious::Routes::Channels
channel = get_about_info(ucid, locale) channel = get_about_info(ucid, locale)
rescue ex : ChannelRedirect rescue ex : ChannelRedirect
return env.redirect env.request.resource.gsub(ucid, ex.channel_id) return env.redirect env.request.resource.gsub(ucid, ex.channel_id)
rescue ex : NotFoundException
return error_template(404, ex)
rescue ex rescue ex
return error_template(500, ex) return error_template(500, ex)
end end

View file

@ -7,6 +7,8 @@ module Invidious::Routes::Embed
playlist = get_playlist(plid) playlist = get_playlist(plid)
offset = env.params.query["index"]?.try &.to_i? || 0 offset = env.params.query["index"]?.try &.to_i? || 0
videos = get_playlist_videos(playlist, offset: offset) videos = get_playlist_videos(playlist, offset: offset)
rescue ex : NotFoundException
return error_template(404, ex)
rescue ex rescue ex
return error_template(500, ex) return error_template(500, ex)
end end
@ -60,6 +62,8 @@ module Invidious::Routes::Embed
playlist = get_playlist(plid) playlist = get_playlist(plid)
offset = env.params.query["index"]?.try &.to_i? || 0 offset = env.params.query["index"]?.try &.to_i? || 0
videos = get_playlist_videos(playlist, offset: offset) videos = get_playlist_videos(playlist, offset: offset)
rescue ex : NotFoundException
return error_template(404, ex)
rescue ex rescue ex
return error_template(500, ex) return error_template(500, ex)
end end
@ -119,6 +123,8 @@ module Invidious::Routes::Embed
video = get_video(id, region: params.region) video = get_video(id, region: params.region)
rescue ex : VideoRedirect rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id) return env.redirect env.request.resource.gsub(id, ex.video_id)
rescue ex : NotFoundException
return error_template(404, ex)
rescue ex rescue ex
return error_template(500, ex) return error_template(500, ex)
end end

View file

@ -0,0 +1,47 @@
module Invidious::Routes::ErrorRoutes
def self.error_404(env)
if md = env.request.path.match(/^\/(?<id>([a-zA-Z0-9_-]{11})|(\w+))$/)
item = md["id"]
# Check if item is branding URL e.g. https://youtube.com/gaming
response = YT_POOL.client &.get("/#{item}")
if response.status_code == 301
response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target)
end
if response.body.empty?
env.response.headers["Location"] = "/"
haltf env, status_code: 302
end
html = XML.parse_html(response.body)
ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
if ucid
env.response.headers["Location"] = "/channel/#{ucid}"
haltf env, status_code: 302
end
params = [] of String
env.params.query.each do |k, v|
params << "#{k}=#{v}"
end
params = params.join("&")
url = "/watch?v=#{item}"
if !params.empty?
url += "&#{params}"
end
# Check if item is video ID
if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404
env.response.headers["Location"] = url
haltf env, status_code: 302
end
end
env.response.headers["Location"] = "/"
haltf env, status_code: 302
end
end

View file

@ -150,6 +150,8 @@ module Invidious::Routes::Feeds
channel = get_about_info(ucid, locale) channel = get_about_info(ucid, locale)
rescue ex : ChannelRedirect rescue ex : ChannelRedirect
return env.redirect env.request.resource.gsub(ucid, ex.channel_id) return env.redirect env.request.resource.gsub(ucid, ex.channel_id)
rescue ex : NotFoundException
return error_atom(404, ex)
rescue ex rescue ex
return error_atom(500, ex) return error_atom(500, ex)
end end
@ -202,6 +204,12 @@ module Invidious::Routes::Feeds
xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" } xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" }
end end
xml.element("image") do
xml.element("url") { xml.text channel.author_thumbnail }
xml.element("title") { xml.text channel.author }
xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}")
end
videos.each do |video| videos.each do |video|
video.to_xml(channel.auto_generated, params, xml) video.to_xml(channel.auto_generated, params, xml)
end end

View file

@ -66,7 +66,13 @@ module Invidious::Routes::Playlists
user = user.as(User) user = user.as(User)
playlist_id = env.params.query["list"] playlist_id = env.params.query["list"]
begin
playlist = get_playlist(playlist_id) playlist = get_playlist(playlist_id)
rescue ex : NotFoundException
return error_template(404, ex)
rescue ex
return error_template(500, ex)
end
subscribe_playlist(user, playlist) subscribe_playlist(user, playlist)
env.redirect "/playlist?list=#{playlist.id}" env.redirect "/playlist?list=#{playlist.id}"
@ -304,6 +310,8 @@ module Invidious::Routes::Playlists
playlist_id = env.params.query["playlist_id"] playlist_id = env.params.query["playlist_id"]
playlist = get_playlist(playlist_id).as(InvidiousPlaylist) playlist = get_playlist(playlist_id).as(InvidiousPlaylist)
raise "Invalid user" if playlist.author != user.email raise "Invalid user" if playlist.author != user.email
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex rescue ex
if redirect if redirect
return error_template(400, ex) return error_template(400, ex)
@ -334,6 +342,8 @@ module Invidious::Routes::Playlists
begin begin
video = get_video(video_id) video = get_video(video_id)
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex rescue ex
if redirect if redirect
return error_template(500, ex) return error_template(500, ex)
@ -394,6 +404,8 @@ module Invidious::Routes::Playlists
begin begin
playlist = get_playlist(plid) playlist = get_playlist(plid)
rescue ex : NotFoundException
return error_template(404, ex)
rescue ex rescue ex
return error_template(500, ex) return error_template(500, ex)
end end

View file

@ -59,6 +59,12 @@ module Invidious::Routes::Search
return error_template(500, ex) return error_template(500, ex)
end end
params = query.to_http_params
url_prev_page = "/search?#{params}&page=#{query.page - 1}"
url_next_page = "/search?#{params}&page=#{query.page + 1}"
redirect_url = Invidious::Frontend::Misc.redirect_url(env)
env.set "search", query.text env.set "search", query.text
templated "search" templated "search"
end end

View file

@ -265,7 +265,13 @@ module Invidious::Routes::VideoPlayback
return error_template(403, "Administrator has disabled this endpoint.") return error_template(403, "Administrator has disabled this endpoint.")
end end
begin
video = get_video(id, region: region) video = get_video(id, region: region)
rescue ex : NotFoundException
return error_template(404, ex)
rescue ex
return error_template(500, ex)
end
fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag } fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag }
url = fmt.try &.["url"]?.try &.as_s url = fmt.try &.["url"]?.try &.as_s

View file

@ -63,6 +63,9 @@ module Invidious::Routes::Watch
video = get_video(id, region: params.region) video = get_video(id, region: params.region)
rescue ex : VideoRedirect rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id) return env.redirect env.request.resource.gsub(id, ex.video_id)
rescue ex : NotFoundException
LOGGER.error("get_video not found: #{id} : #{ex.message}")
return error_template(404, ex)
rescue ex rescue ex
LOGGER.error("get_video: #{id} : #{ex.message}") LOGGER.error("get_video: #{id} : #{ex.message}")
return error_template(500, ex) return error_template(500, ex)

View file

@ -1,71 +1,238 @@
module Invidious::Routing module Invidious::Routing
{% for http_method in {"get", "post", "delete", "options", "patch", "put", "head"} %} extend self
{% for http_method in {"get", "post", "delete", "options", "patch", "put"} %}
macro {{http_method.id}}(path, controller, method = :handle) macro {{http_method.id}}(path, controller, method = :handle)
{{http_method.id}} \{{ path }} do |env| unless Kemal::Utils.path_starts_with_slash?(\{{path}})
raise Kemal::Exceptions::InvalidPathStartException.new({{http_method}}, \{{path}})
end
Kemal::RouteHandler::INSTANCE.add_route({{http_method.upcase}}, \{{path}}) do |env|
\{{ controller }}.\{{ method.id }}(env) \{{ controller }}.\{{ method.id }}(env)
end end
end end
{% end %} {% end %}
def register_all
{% unless flag?(:api_only) %}
get "/", Routes::Misc, :home
get "/privacy", Routes::Misc, :privacy
get "/licenses", Routes::Misc, :licenses
get "/redirect", Routes::Misc, :cross_instance_redirect
self.register_channel_routes
self.register_watch_routes
self.register_iv_playlist_routes
self.register_yt_playlist_routes
self.register_search_routes
self.register_user_routes
self.register_feed_routes
# Support push notifications via PubSubHubbub
get "/feed/webhook/:token", Routes::Feeds, :push_notifications_get
post "/feed/webhook/:token", Routes::Feeds, :push_notifications_post
get "/modify_notifications", Routes::Notifications, :modify
{% end %}
self.register_image_routes
self.register_api_v1_routes
self.register_api_manifest_routes
self.register_video_playback_routes
end end
macro define_user_routes # -------------------
# Invidious routes
# -------------------
def register_user_routes
# User login/out # User login/out
Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page get "/login", Routes::Login, :login_page
Invidious::Routing.get "/login/oauth/:provider", Invidious::Routes::Login, :login_oauth get "/login/oauth/:provider", Routes::Login, :login_oauth
Invidious::Routing.post "/login", Invidious::Routes::Login, :login post "/login", Routes::Login, :login
Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout post "/signout", Routes::Login, :signout
Invidious::Routing.get "/Captcha", Invidious::Routes::Login, :captcha get "/Captcha", Routes::Login, :captcha
# User preferences # User preferences
Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show get "/preferences", Routes::PreferencesRoute, :show
Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update post "/preferences", Routes::PreferencesRoute, :update
Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme get "/toggle_theme", Routes::PreferencesRoute, :toggle_theme
Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control get "/data_control", Routes::PreferencesRoute, :data_control
Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control post "/data_control", Routes::PreferencesRoute, :update_data_control
# User account management # User account management
Invidious::Routing.get "/change_password", Invidious::Routes::Account, :get_change_password get "/change_password", Routes::Account, :get_change_password
Invidious::Routing.post "/change_password", Invidious::Routes::Account, :post_change_password post "/change_password", Routes::Account, :post_change_password
Invidious::Routing.get "/delete_account", Invidious::Routes::Account, :get_delete get "/delete_account", Routes::Account, :get_delete
Invidious::Routing.post "/delete_account", Invidious::Routes::Account, :post_delete post "/delete_account", Routes::Account, :post_delete
Invidious::Routing.get "/clear_watch_history", Invidious::Routes::Account, :get_clear_history get "/clear_watch_history", Routes::Account, :get_clear_history
Invidious::Routing.post "/clear_watch_history", Invidious::Routes::Account, :post_clear_history post "/clear_watch_history", Routes::Account, :post_clear_history
Invidious::Routing.get "/authorize_token", Invidious::Routes::Account, :get_authorize_token get "/authorize_token", Routes::Account, :get_authorize_token
Invidious::Routing.post "/authorize_token", Invidious::Routes::Account, :post_authorize_token post "/authorize_token", Routes::Account, :post_authorize_token
Invidious::Routing.get "/token_manager", Invidious::Routes::Account, :token_manager get "/token_manager", Routes::Account, :token_manager
Invidious::Routing.post "/token_ajax", Invidious::Routes::Account, :token_ajax post "/token_ajax", Routes::Account, :token_ajax
post "/subscription_ajax", Routes::Subscriptions, :toggle_subscription
get "/subscription_manager", Routes::Subscriptions, :subscription_manager
end end
macro define_v1_api_routes def register_iv_playlist_routes
{{namespace = Invidious::Routes::API::V1}} get "/create_playlist", Routes::Playlists, :new
post "/create_playlist", Routes::Playlists, :create
get "/subscribe_playlist", Routes::Playlists, :subscribe
get "/delete_playlist", Routes::Playlists, :delete_page
post "/delete_playlist", Routes::Playlists, :delete
get "/edit_playlist", Routes::Playlists, :edit
post "/edit_playlist", Routes::Playlists, :update
get "/add_playlist_items", Routes::Playlists, :add_playlist_items_page
post "/playlist_ajax", Routes::Playlists, :playlist_ajax
end
def register_feed_routes
# Feeds
get "/view_all_playlists", Routes::Feeds, :view_all_playlists_redirect
get "/feed/playlists", Routes::Feeds, :playlists
get "/feed/popular", Routes::Feeds, :popular
get "/feed/trending", Routes::Feeds, :trending
get "/feed/subscriptions", Routes::Feeds, :subscriptions
get "/feed/history", Routes::Feeds, :history
# RSS Feeds
get "/feed/channel/:ucid", Routes::Feeds, :rss_channel
get "/feed/private", Routes::Feeds, :rss_private
get "/feed/playlist/:plid", Routes::Feeds, :rss_playlist
get "/feeds/videos.xml", Routes::Feeds, :rss_videos
end
# -------------------
# Youtube routes
# -------------------
def register_channel_routes
get "/channel/:ucid", Routes::Channels, :home
get "/channel/:ucid/home", Routes::Channels, :home
get "/channel/:ucid/videos", Routes::Channels, :videos
get "/channel/:ucid/playlists", Routes::Channels, :playlists
get "/channel/:ucid/community", Routes::Channels, :community
get "/channel/:ucid/about", Routes::Channels, :about
get "/channel/:ucid/live", Routes::Channels, :live
get "/user/:user/live", Routes::Channels, :live
get "/c/:user/live", Routes::Channels, :live
["", "/videos", "/playlists", "/community", "/about"].each do |path|
# /c/LinusTechTips
get "/c/:user#{path}", Routes::Channels, :brand_redirect
# /user/linustechtips | Not always the same as /c/
get "/user/:user#{path}", Routes::Channels, :brand_redirect
# /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow
get "/attribution_link#{path}", Routes::Channels, :brand_redirect
# /profile?user=linustechtips
get "/profile/#{path}", Routes::Channels, :profile
end
end
def register_watch_routes
get "/watch", Routes::Watch, :handle
post "/watch_ajax", Routes::Watch, :mark_watched
get "/watch/:id", Routes::Watch, :redirect
get "/shorts/:id", Routes::Watch, :redirect
get "/clip/:clip", Routes::Watch, :clip
get "/w/:id", Routes::Watch, :redirect
get "/v/:id", Routes::Watch, :redirect
get "/e/:id", Routes::Watch, :redirect
post "/download", Routes::Watch, :download
get "/embed/", Routes::Embed, :redirect
get "/embed/:id", Routes::Embed, :show
end
def register_yt_playlist_routes
get "/playlist", Routes::Playlists, :show
get "/mix", Routes::Playlists, :mix
get "/watch_videos", Routes::Playlists, :watch_videos
end
def register_search_routes
get "/opensearch.xml", Routes::Search, :opensearch
get "/results", Routes::Search, :results
get "/search", Routes::Search, :search
get "/hashtag/:hashtag", Routes::Search, :hashtag
end
# -------------------
# Media proxy routes
# -------------------
def register_api_manifest_routes
get "/api/manifest/dash/id/:id", Routes::API::Manifest, :get_dash_video_id
get "/api/manifest/dash/id/videoplayback", Routes::API::Manifest, :get_dash_video_playback
get "/api/manifest/dash/id/videoplayback/*", Routes::API::Manifest, :get_dash_video_playback_greedy
options "/api/manifest/dash/id/videoplayback", Routes::API::Manifest, :options_dash_video_playback
options "/api/manifest/dash/id/videoplayback/*", Routes::API::Manifest, :options_dash_video_playback
get "/api/manifest/hls_playlist/*", Routes::API::Manifest, :get_hls_playlist
get "/api/manifest/hls_variant/*", Routes::API::Manifest, :get_hls_variant
end
def register_video_playback_routes
get "/videoplayback", Routes::VideoPlayback, :get_video_playback
get "/videoplayback/*", Routes::VideoPlayback, :get_video_playback_greedy
options "/videoplayback", Routes::VideoPlayback, :options_video_playback
options "/videoplayback/*", Routes::VideoPlayback, :options_video_playback
get "/latest_version", Routes::VideoPlayback, :latest_version
end
def register_image_routes
get "/ggpht/*", Routes::Images, :ggpht
options "/sb/:authority/:id/:storyboard/:index", Routes::Images, :options_storyboard
get "/sb/:authority/:id/:storyboard/:index", Routes::Images, :get_storyboard
get "/s_p/:id/:name", Routes::Images, :s_p_image
get "/yts/img/:name", Routes::Images, :yts_image
get "/vi/:id/:name", Routes::Images, :thumbnails
end
# -------------------
# API routes
# -------------------
def register_api_v1_routes
{% begin %}
{{namespace = Routes::API::V1}}
# Videos # Videos
Invidious::Routing.get "/api/v1/videos/:id", {{namespace}}::Videos, :videos get "/api/v1/videos/:id", {{namespace}}::Videos, :videos
Invidious::Routing.get "/api/v1/storyboards/:id", {{namespace}}::Videos, :storyboards get "/api/v1/storyboards/:id", {{namespace}}::Videos, :storyboards
Invidious::Routing.get "/api/v1/captions/:id", {{namespace}}::Videos, :captions get "/api/v1/captions/:id", {{namespace}}::Videos, :captions
Invidious::Routing.get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations
Invidious::Routing.get "/api/v1/comments/:id", {{namespace}}::Videos, :comments get "/api/v1/comments/:id", {{namespace}}::Videos, :comments
# Feeds # Feeds
Invidious::Routing.get "/api/v1/trending", {{namespace}}::Feeds, :trending get "/api/v1/trending", {{namespace}}::Feeds, :trending
Invidious::Routing.get "/api/v1/popular", {{namespace}}::Feeds, :popular get "/api/v1/popular", {{namespace}}::Feeds, :popular
# Channels # Channels
Invidious::Routing.get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home
{% for route in {"videos", "latest", "playlists", "community", "search"} %} {% for route in {"videos", "latest", "playlists", "community", "search"} %}
Invidious::Routing.get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}} get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}}
Invidious::Routing.get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}} get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}}
{% end %} {% end %}
# 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community # 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community
Invidious::Routing.get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect
Invidious::Routing.get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect
# Search # Search
Invidious::Routing.get "/api/v1/search", {{namespace}}::Search, :search get "/api/v1/search", {{namespace}}::Search, :search
Invidious::Routing.get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions
# Authenticated # Authenticated
@ -74,58 +241,34 @@ macro define_v1_api_routes
# Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications # Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
# Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications # Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
Invidious::Routing.get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences
Invidious::Routing.post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences
Invidious::Routing.get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed
Invidious::Routing.get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions
Invidious::Routing.post "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :subscribe_channel post "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :subscribe_channel
Invidious::Routing.delete "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :unsubscribe_channel delete "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :unsubscribe_channel
get "/api/v1/auth/playlists", {{namespace}}::Authenticated, :list_playlists
post "/api/v1/auth/playlists", {{namespace}}::Authenticated, :create_playlist
patch "/api/v1/auth/playlists/:plid",{{namespace}}:: Authenticated, :update_playlist_attribute
delete "/api/v1/auth/playlists/:plid", {{namespace}}::Authenticated, :delete_playlist
post "/api/v1/auth/playlists/:plid/videos", {{namespace}}::Authenticated, :insert_video_into_playlist
delete "/api/v1/auth/playlists/:plid/videos/:index", {{namespace}}::Authenticated, :delete_video_in_playlist
Invidious::Routing.get "/api/v1/auth/playlists", {{namespace}}::Authenticated, :list_playlists get "/api/v1/auth/tokens", {{namespace}}::Authenticated, :get_tokens
Invidious::Routing.post "/api/v1/auth/playlists", {{namespace}}::Authenticated, :create_playlist post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token
Invidious::Routing.patch "/api/v1/auth/playlists/:plid",{{namespace}}:: Authenticated, :update_playlist_attribute post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token
Invidious::Routing.delete "/api/v1/auth/playlists/:plid", {{namespace}}::Authenticated, :delete_playlist
get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
Invidious::Routing.post "/api/v1/auth/playlists/:plid/videos", {{namespace}}::Authenticated, :insert_video_into_playlist post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
Invidious::Routing.delete "/api/v1/auth/playlists/:plid/videos/:index", {{namespace}}::Authenticated, :delete_video_in_playlist
Invidious::Routing.get "/api/v1/auth/tokens", {{namespace}}::Authenticated, :get_tokens
Invidious::Routing.post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token
Invidious::Routing.post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token
Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
# Misc # Misc
Invidious::Routing.get "/api/v1/stats", {{namespace}}::Misc, :stats get "/api/v1/stats", {{namespace}}::Misc, :stats
Invidious::Routing.get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist
Invidious::Routing.get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist
Invidious::Routing.get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes
{% end %}
end end
macro define_api_manifest_routes
Invidious::Routing.get "/api/manifest/dash/id/:id", Invidious::Routes::API::Manifest, :get_dash_video_id
Invidious::Routing.get "/api/manifest/dash/id/videoplayback", Invidious::Routes::API::Manifest, :get_dash_video_playback
Invidious::Routing.get "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::API::Manifest, :get_dash_video_playback_greedy
Invidious::Routing.options "/api/manifest/dash/id/videoplayback", Invidious::Routes::API::Manifest, :options_dash_video_playback
Invidious::Routing.options "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::API::Manifest, :options_dash_video_playback
Invidious::Routing.get "/api/manifest/hls_playlist/*", Invidious::Routes::API::Manifest, :get_hls_playlist
Invidious::Routing.get "/api/manifest/hls_variant/*", Invidious::Routes::API::Manifest, :get_hls_variant
end
macro define_video_playback_routes
Invidious::Routing.get "/videoplayback", Invidious::Routes::VideoPlayback, :get_video_playback
Invidious::Routing.get "/videoplayback/*", Invidious::Routes::VideoPlayback, :get_video_playback_greedy
Invidious::Routing.options "/videoplayback", Invidious::Routes::VideoPlayback, :options_video_playback
Invidious::Routing.options "/videoplayback/*", Invidious::Routes::VideoPlayback, :options_video_playback
Invidious::Routing.get "/latest_version", Invidious::Routes::VideoPlayback, :latest_version
end end

View file

@ -57,7 +57,7 @@ module Invidious::Search
# Get the page number (also common to all search types) # Get the page number (also common to all search types)
@page = params["page"]?.try &.to_i? || 1 @page = params["page"]?.try &.to_i? || 1
# Stop here is raw query in empty # Stop here if raw query is empty
# NOTE: maybe raise in the future? # NOTE: maybe raise in the future?
return if self.empty_raw_query? return if self.empty_raw_query?
@ -127,6 +127,16 @@ module Invidious::Search
return items return items
end end
# Return the HTTP::Params corresponding to this Query (invidious format)
def to_http_params : HTTP::Params
params = @filters.to_iv_params
params["q"] = @query
params["channel"] = @channel if !@channel.empty?
return params
end
# TODO: clean code # TODO: clean code
private def unnest_items(all_items) : Array(SearchItem) private def unnest_items(all_items) : Array(SearchItem)
items = [] of SearchItem items = [] of SearchItem

View file

@ -19,7 +19,7 @@ struct Invidious::User
expires: Time.utc + 2.years, expires: Time.utc + 2.years,
secure: SECURE, secure: SECURE,
http_only: true, http_only: true,
samesite: HTTP::Cookie::SameSite::Strict samesite: HTTP::Cookie::SameSite::Lax
) )
end end
@ -34,7 +34,7 @@ struct Invidious::User
expires: Time.utc + 2.years, expires: Time.utc + 2.years,
secure: SECURE, secure: SECURE,
http_only: false, http_only: false,
samesite: HTTP::Cookie::SameSite::Strict samesite: HTTP::Cookie::SameSite::Lax
) )
end end
end end

View file

@ -323,7 +323,7 @@ struct Video
json.field "viewCount", self.views json.field "viewCount", self.views
json.field "likeCount", self.likes json.field "likeCount", self.likes
json.field "dislikeCount", self.dislikes json.field "dislikeCount", 0_i64
json.field "paid", self.paid json.field "paid", self.paid
json.field "premium", self.premium json.field "premium", self.premium
@ -354,7 +354,7 @@ struct Video
json.field "lengthSeconds", self.length_seconds json.field "lengthSeconds", self.length_seconds
json.field "allowRatings", self.allow_ratings json.field "allowRatings", self.allow_ratings
json.field "rating", self.average_rating json.field "rating", 0_i64
json.field "isListed", self.is_listed json.field "isListed", self.is_listed
json.field "liveNow", self.live_now json.field "liveNow", self.live_now
json.field "isUpcoming", self.is_upcoming json.field "isUpcoming", self.is_upcoming
@ -556,11 +556,6 @@ struct Video
info["dislikes"]?.try &.as_i64 || 0_i64 info["dislikes"]?.try &.as_i64 || 0_i64
end end
def average_rating : Float64
# (likes / (likes + dislikes) * 4 + 1)
info["videoDetails"]["averageRating"]?.try { |t| t.as_f? || t.as_i64?.try &.to_f64 }.try &.round(4) || 0.0
end
def published : Time def published : Time
info info
.dig?("microformat", "playerMicroformatRenderer", "publishDate") .dig?("microformat", "playerMicroformatRenderer", "publishDate")
@ -813,14 +808,6 @@ struct Video
return info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s return info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s
end end
def wilson_score : Float64
ci_lower_bound(likes, likes + dislikes).round(4)
end
def engagement : Float64
(((likes + dislikes) / views) * 100).round(4)
end
def reason : String? def reason : String?
info["reason"]?.try &.as_s info["reason"]?.try &.as_s
end end
@ -899,36 +886,46 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
end end
def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil) def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil)
params = {} of String => JSON::Any # Init client config for the API
client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region) client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region)
if context_screen == "embed" if context_screen == "embed"
client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed
end end
# Fetch data from the player endpoint
player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
if player_response.dig?("playabilityStatus", "status").try &.as_s != "OK" playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
if playability_status != "OK"
subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason") subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason")
reason = subreason.try &.[]?("simpleText").try &.as_s reason = subreason.try &.[]?("simpleText").try &.as_s
reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("") reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("")
reason ||= player_response.dig("playabilityStatus", "reason").as_s reason ||= player_response.dig("playabilityStatus", "reason").as_s
params["reason"] = JSON::Any.new(reason)
return params # Stop here if video is not a scheduled livestream
if playability_status != "LIVE_STREAM_OFFLINE"
return {
"reason" => JSON::Any.new(reason),
}
end
else
reason = nil
end end
params["shortDescription"] = player_response.dig?("videoDetails", "shortDescription") || JSON::Any.new(nil)
# Don't fetch the next endpoint if the video is unavailable. # Don't fetch the next endpoint if the video is unavailable.
if !params["reason"]? if {"OK", "LIVE_STREAM_OFFLINE"}.any?(playability_status)
next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) next_response = YoutubeAPI.next({"videoId": video_id, "params": ""})
player_response = player_response.merge(next_response) player_response = player_response.merge(next_response)
end end
params = parse_video_info(video_id, player_response)
params["reason"] = JSON::Any.new(reason) if reason
# Fetch the video streams using an Android client in order to get the decrypted URLs and # Fetch the video streams using an Android client in order to get the decrypted URLs and
# maybe fix throttling issues (#2194).See for the explanation about the decrypted URLs: # maybe fix throttling issues (#2194).See for the explanation about the decrypted URLs:
# https://github.com/TeamNewPipe/NewPipeExtractor/issues/562 # https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
if !params["reason"]? if reason.nil?
if context_screen == "embed" if context_screen == "embed"
client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed
else else
@ -946,10 +943,15 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
end end
end end
# TODO: clean that up
{"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f| {"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f|
params[f] = player_response[f] if player_response[f]? params[f] = player_response[f] if player_response[f]?
end end
return params
end
def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any)
# Top level elements # Top level elements
main_results = player_response.dig?("contents", "twoColumnWatchNextResults") main_results = player_response.dig?("contents", "twoColumnWatchNextResults")
@ -1003,9 +1005,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
end end
end end
params["relatedVideos"] = JSON::Any.new(related) # Likes
# Likes/dislikes
toplevel_buttons = video_primary_renderer toplevel_buttons = video_primary_renderer
.try &.dig?("videoActions", "menuRenderer", "topLevelButtons") .try &.dig?("videoActions", "menuRenderer", "topLevelButtons")
@ -1023,64 +1023,38 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"") LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"")
LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes
end end
dislikes_button = toplevel_buttons.as_a
.find(&.dig("toggleButtonRenderer", "defaultIcon", "iconType").as_s.== "DISLIKE")
.try &.["toggleButtonRenderer"]
if dislikes_button
dislikes_txt = (dislikes_button["defaultText"]? || dislikes_button["toggledText"]?)
.try &.dig?("accessibility", "accessibilityData", "label")
dislikes = dislikes_txt.as_s.gsub(/\D/, "").to_i64? if dislikes_txt
LOGGER.trace("extract_video_info: Found \"dislikes\" button. Button text is \"#{dislikes_txt}\"")
LOGGER.debug("extract_video_info: Dislikes count is #{dislikes}") if dislikes
end end
end
if likes && likes != 0_i64 && (!dislikes || dislikes == 0_i64)
if rating = player_response.dig?("videoDetails", "averageRating").try { |x| x.as_i64? || x.as_f? }
dislikes = (likes * ((5 - rating)/(rating - 1))).round.to_i64
LOGGER.debug("extract_video_info: Dislikes count (using fallback method) is #{dislikes}")
end
end
params["likes"] = JSON::Any.new(likes || 0_i64)
params["dislikes"] = JSON::Any.new(dislikes || 0_i64)
# Description # Description
short_description = player_response.dig?("videoDetails", "shortDescription")
description_html = video_secondary_renderer.try &.dig?("description", "runs") description_html = video_secondary_renderer.try &.dig?("description", "runs")
.try &.as_a.try { |t| content_to_comment_html(t, video_id) } .try &.as_a.try { |t| content_to_comment_html(t, video_id) }
params["descriptionHtml"] = JSON::Any.new(description_html || "<p></p>")
# Video metadata # Video metadata
metadata = video_secondary_renderer metadata = video_secondary_renderer
.try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows") .try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows")
.try &.as_a .try &.as_a
params["genre"] = params["microformat"]?.try &.["playerMicroformatRenderer"]?.try &.["category"]? || JSON::Any.new("") genre = player_response.dig?("microformat", "playerMicroformatRenderer", "category")
params["genreUrl"] = JSON::Any.new(nil) genre_ucid = nil
license = nil
metadata.try &.each do |row| metadata.try &.each do |row|
title = row["metadataRowRenderer"]?.try &.["title"]?.try &.["simpleText"]?.try &.as_s metadata_title = row.dig?("metadataRowRenderer", "title", "simpleText").try &.as_s
contents = row.dig?("metadataRowRenderer", "contents", 0) contents = row.dig?("metadataRowRenderer", "contents", 0)
if title.try &.== "Category" if metadata_title == "Category"
contents = contents.try &.dig?("runs", 0) contents = contents.try &.dig?("runs", 0)
params["genre"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "") genre = contents.try &.["text"]?
params["genreUcid"] = JSON::Any.new(contents.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]? genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId")
.try &.["browseId"]?.try &.as_s || "") elsif metadata_title == "License"
elsif title.try &.== "License" license = contents.try &.dig?("runs", 0, "text")
contents = contents.try &.["runs"]? elsif metadata_title == "Licensed to YouTube by"
.try &.as_a[0]? license = contents.try &.["simpleText"]?
params["license"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "")
elsif title.try &.== "Licensed to YouTube by"
params["license"] = JSON::Any.new(contents.try &.["simpleText"]?.try &.as_s || "")
end end
end end
@ -1088,20 +1062,30 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer")
author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url") author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url")
params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "")
author_verified = has_verified_badge?(author_info["badges"]?) author_verified = has_verified_badge?(author_info["badges"]?)
params["authorVerified"] = JSON::Any.new(author_verified)
subs_text = author_info["subscriberCountText"]? subs_text = author_info["subscriberCountText"]?
.try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") } .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }
.try &.as_s.split(" ", 2)[0] .try &.as_s.split(" ", 2)[0]
params["subCountText"] = JSON::Any.new(subs_text || "-")
end end
# Return data # Return data
params = {
"shortDescription" => JSON::Any.new(short_description.try &.as_s || nil),
"relatedVideos" => JSON::Any.new(related),
"likes" => JSON::Any.new(likes || 0_i64),
"dislikes" => JSON::Any.new(0_i64),
"descriptionHtml" => JSON::Any.new(description_html || "<p></p>"),
"genre" => JSON::Any.new(genre.try &.as_s || ""),
"genreUrl" => JSON::Any.new(nil),
"genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""),
"license" => JSON::Any.new(license.try &.as_s || ""),
"authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""),
"authorVerified" => JSON::Any.new(author_verified),
"subCountText" => JSON::Any.new(subs_text || "-"),
}
return params return params
end end
@ -1158,8 +1142,12 @@ def fetch_video(id, region)
end end
if reason = info["reason"]? if reason = info["reason"]?
if reason == "Video unavailable"
raise NotFoundException.new(reason.as_s || "")
else
raise InfoException.new(reason.as_s || "") raise InfoException.new(reason.as_s || "")
end end
end
video = Video.new({ video = Video.new({
id: id, id: id,

View file

@ -5,7 +5,7 @@
<a href="/channel/<%= item.ucid %>"> <a href="/channel/<%= item.ucid %>">
<% if !env.get("preferences").as(Preferences).thin_mode %> <% if !env.get("preferences").as(Preferences).thin_mode %>
<center> <center>
<img loading="lazy" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/> <img loading="lazy" tabindex="-1" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/>
</center> </center>
<% end %> <% end %>
<p dir="auto"><%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></p> <p dir="auto"><%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></p>
@ -23,7 +23,7 @@
<a style="width:100%" href="<%= url %>"> <a style="width:100%" href="<%= url %>">
<% if !env.get("preferences").as(Preferences).thin_mode %> <% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail"> <div class="thumbnail">
<img loading="lazy" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>"/> <img loading="lazy" tabindex="-1" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>"/>
<p class="length"><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p> <p class="length"><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
</div> </div>
<% end %> <% end %>
@ -36,7 +36,7 @@
<a href="/watch?v=<%= item.id %>&list=<%= item.rdid %>"> <a href="/watch?v=<%= item.id %>&list=<%= item.rdid %>">
<% if !env.get("preferences").as(Preferences).thin_mode %> <% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail"> <div class="thumbnail">
<img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% if item.length_seconds != 0 %> <% if item.length_seconds != 0 %>
<p class="length"><%= recode_length_seconds(item.length_seconds) %></p> <p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
<% end %> <% end %>
@ -51,16 +51,13 @@
<a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&index=<%= item.index %>"> <a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&index=<%= item.index %>">
<% if !env.get("preferences").as(Preferences).thin_mode %> <% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail"> <div class="thumbnail">
<img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% if plid_form = env.get?("remove_playlist_items") %> <% if plid_form = env.get?("remove_playlist_items") %>
<form data-onsubmit="return_false" action="/playlist_ajax?action_remove_video=1&set_video_id=<%= item.index %>&playlist_id=<%= plid_form %>&referer=<%= env.get("current_page") %>" method="post"> <form data-onsubmit="return_false" action="/playlist_ajax?action_remove_video=1&set_video_id=<%= item.index %>&playlist_id=<%= plid_form %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<p class="watched"> <p class="watched">
<a data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid_form %>" href="javascript:void(0)"> <button type="submit" style="all:unset" data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid_form %>"><i class="icon ion-md-trash"></i></button>
<button type="submit" style="all:unset">
<i class="icon ion-md-trash"></i>
</button>
</a>
</p> </p>
</form> </form>
<% end %> <% end %>
@ -103,29 +100,21 @@
<a style="width:100%" href="/watch?v=<%= item.id %>"> <a style="width:100%" href="/watch?v=<%= item.id %>">
<% if !env.get("preferences").as(Preferences).thin_mode %> <% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail"> <div class="thumbnail">
<img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% if env.get? "show_watched" %> <% 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=1&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) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<p class="watched"> <p class="watched">
<a data-onclick="mark_watched" data-id="<%= item.id %>" href="javascript:void(0)"> <button type="submit" style="all:unset" data-onclick="mark_watched" data-id="<%= item.id %>">
<button type="submit" style="all:unset"> <i data-mouse="switch_classes" data-switch-classes="ion-ios-eye-off,ion-ios-eye" class="icon ion-ios-eye"></i>
<i data-mouse="switch_classes" data-switch-classes="ion-ios-eye-off,ion-ios-eye"
class="icon ion-ios-eye">
</i>
</button> </button>
</a>
</p> </p>
</form> </form>
<% elsif plid_form = env.get? "add_playlist_items" %> <% elsif plid_form = env.get? "add_playlist_items" %>
<form data-onsubmit="return_false" action="/playlist_ajax?action_add_video=1&video_id=<%= item.id %>&playlist_id=<%= plid_form %>&referer=<%= env.get("current_page") %>" method="post"> <form data-onsubmit="return_false" action="/playlist_ajax?action_add_video=1&video_id=<%= item.id %>&playlist_id=<%= plid_form %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<p class="watched"> <p class="watched">
<a data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>" href="javascript:void(0)"> <button type="submit" style="all:unset" data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>"><i class="icon ion-md-add"></i></button>
<button type="submit" style="all:unset">
<i class="icon ion-md-add"></i>
</button>
</a>
</p> </p>
</form> </form>
<% end %> <% end %>

View file

@ -7,14 +7,25 @@
<source src="<%= URI.parse(hlsvp).request_target %><% if params.local %>?local=true<% end %>" type="application/x-mpegURL" label="livestream"> <source src="<%= URI.parse(hlsvp).request_target %><% if params.local %>?local=true<% end %>" type="application/x-mpegURL" label="livestream">
<% else %> <% else %>
<% if params.listen %> <% if params.listen %>
<% audio_streams.each_with_index do |fmt, i| <% # default to 128k m4a stream
best_m4a_stream_index = 0
best_m4a_stream_bitrate = 0
audio_streams.each_with_index do |fmt, i|
bandwidth = fmt["bitrate"].as_i
if (fmt["mimeType"].as_s.starts_with?("audio/mp4") && bandwidth > best_m4a_stream_bitrate)
best_m4a_stream_bitrate = bandwidth
best_m4a_stream_index = i
end
end
audio_streams.each_with_index do |fmt, i|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}" src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
src_url += "&local=true" if params.local src_url += "&local=true" if params.local
bitrate = fmt["bitrate"] bitrate = fmt["bitrate"]
mimetype = HTML.escape(fmt["mimeType"].as_s) mimetype = HTML.escape(fmt["mimeType"].as_s)
selected = i == 0 ? true : false selected = (i == best_m4a_stream_index)
%> %>
<source src="<%= src_url %>" type='<%= mimetype %>' label="<%= bitrate %>k" selected="<%= selected %>"> <source src="<%= src_url %>" type='<%= mimetype %>' label="<%= bitrate %>k" selected="<%= selected %>">
<% if !params.local && !CONFIG.disabled?("local") %> <% if !params.local && !CONFIG.disabled?("local") %>

View file

@ -11,6 +11,7 @@
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/embed.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/embed.css?v=<%= ASSET_COMMIT %>">
<title><%= HTML.escape(video.title) %> - Invidious</title> <title><%= HTML.escape(video.title) %> - Invidious</title>
<script src="/js/_helpers.js?v=<%= ASSET_COMMIT %>"></script>
</head> </head>
<body class="dark-theme"> <body class="dark-theme">

View file

@ -38,9 +38,7 @@
<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=1&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) || "") %>"> <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
<p class="watched"> <p class="watched">
<a data-onclick="mark_unwatched" data-id="<%= item %>" href="javascript:void(0)"> <button type="submit" style="all:unset" data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button>
<button type="submit" style="all:unset"><i class="icon ion-md-trash"></i></button>
</a>
</p> </p>
</form> </form>
</div> </div>

View file

@ -3,16 +3,6 @@
<link rel="stylesheet" href="/css/search.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/search.css?v=<%= ASSET_COMMIT %>">
<% end %> <% end %>
<%-
search_query_encoded = URI.encode_www_form(query.text, space_to_plus: true)
filter_params = query.filters.to_iv_params
url_prev_page = "/search?q=#{search_query_encoded}&#{filter_params}&page=#{query.page - 1}"
url_next_page = "/search?q=#{search_query_encoded}&#{filter_params}&page=#{query.page + 1}"
redirect_url = Invidious::Frontend::Misc.redirect_url(env)
-%>
<!-- Search redirection and filtering UI --> <!-- Search redirection and filtering UI -->
<%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %> <%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
<hr/> <hr/>

View file

@ -68,7 +68,7 @@
</div> </div>
<% if env.get("preferences").as(Preferences).show_nick %> <% if env.get("preferences").as(Preferences).show_nick %>
<div class="pure-u-1-4"> <div class="pure-u-1-4">
<span id="user_name"><%= env.get("user").as(Invidious::User).email %></span> <span id="user_name"><%= HTML.escape(env.get("user").as(Invidious::User).email) %></span>
</div> </div>
<% end %> <% end %>
<div class="pure-u-1-4"> <div class="pure-u-1-4">

View file

@ -39,9 +39,7 @@
<h3 style="padding-right:0.5em"> <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=1&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 type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<a data-onclick="remove_subscription" data-ucid="<%= channel.id %>" href="#"> <input style="all:unset" type="submit" data-onclick="remove_subscription" data-ucid="<%= channel.id %>" value="<%= translate(locale, "unsubscribe") %>">
<input style="all:unset" type="submit" value="<%= translate(locale, "unsubscribe") %>">
</a>
</form> </form>
</h3> </h3>
</div> </div>

View file

@ -31,9 +31,7 @@
<h3 style="padding-right:0.5em"> <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=1&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 type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<a data-onclick="revoke_token" data-session="<%= token[:session] %>" href="#"> <input style="all:unset" type="submit" data-onclick="revoke_token" data-session="<%= token[:session] %>" value="<%= translate(locale, "revoke") %>">
<input style="all:unset" type="submit" value="<%= translate(locale, "revoke") %>">
</a>
</form> </form>
</h3> </h3>
</div> </div>

View file

@ -173,7 +173,7 @@ we're going to need to do it here in order to allow for translations.
<p id="views"><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p> <p id="views"><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p>
<p id="likes"><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p> <p id="likes"><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p>
<p id="dislikes"></p> <p id="dislikes" style="display: none; visibility: hidden;"></p>
<p id="genre"><%= translate(locale, "Genre: ") %> <p id="genre"><%= translate(locale, "Genre: ") %>
<% if !video.genre_url %> <% if !video.genre_url %>
<%= video.genre %> <%= video.genre %>
@ -185,9 +185,9 @@ we're going to need to do it here in order to allow for translations.
<p id="license"><%= translate(locale, "License: ") %><%= video.license %></p> <p id="license"><%= translate(locale, "License: ") %><%= video.license %></p>
<% end %> <% end %>
<p id="family_friendly"><%= translate(locale, "Family friendly? ") %><%= translate_bool(locale, video.is_family_friendly) %></p> <p id="family_friendly"><%= translate(locale, "Family friendly? ") %><%= translate_bool(locale, video.is_family_friendly) %></p>
<p id="wilson"><%= translate(locale, "Wilson score: ") %><%= video.wilson_score %></p> <p id="wilson" style="display: none; visibility: hidden;"></p>
<p id="rating"></p> <p id="rating" style="display: none; visibility: hidden;"></p>
<p id="engagement"><%= translate(locale, "Engagement: ") %><%= video.engagement %>%</p> <p id="engagement" style="display: none; visibility: hidden;"></p>
<% if video.allowed_regions.size != REGIONS.size %> <% if video.allowed_regions.size != REGIONS.size %>
<p id="allowed_regions"> <p id="allowed_regions">
<% if video.allowed_regions.size < REGIONS.size // 2 %> <% if video.allowed_regions.size < REGIONS.size // 2 %>

View file

@ -417,7 +417,7 @@ private module Extractors
# {"tabRenderer": { # {"tabRenderer": {
# "endpoint": {...} # "endpoint": {...}
# "title": "Playlists", # "title": "Playlists",
# "selected": true, # "selected": true, # Is nil unless tab is selected
# "content": {...}, # "content": {...},
# ... # ...
# }} # }}
@ -435,7 +435,8 @@ private module Extractors
raw_items = [] of JSON::Any raw_items = [] of JSON::Any
content = extract_selected_tab(target["tabs"])["content"] content = extract_selected_tab(target["tabs"])["content"]
content["sectionListRenderer"]["contents"].as_a.each do |renderer_container| if section_list_contents = content.dig?("sectionListRenderer", "contents")
section_list_contents.as_a.each do |renderer_container|
renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0] renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0]
# Category extraction # Category extraction
@ -451,6 +452,7 @@ private module Extractors
raw_items << item raw_items << item
end end
end end
end
return raw_items return raw_items
end end

View file

@ -84,7 +84,7 @@ end
def extract_selected_tab(tabs) def extract_selected_tab(tabs)
# Extract the selected tab from the array of tabs Youtube returns # Extract the selected tab from the array of tabs Youtube returns
return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]["tabRenderer"] return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"]
end end
def fetch_continuation_token(items : Array(JSON::Any)) def fetch_continuation_token(items : Array(JSON::Any))

View file

@ -5,15 +5,28 @@
module YoutubeAPI module YoutubeAPI
extend self extend self
private DEFAULT_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
private ANDROID_APP_VERSION = "17.29.35"
private ANDROID_SDK_VERSION = 30_i64
private IOS_APP_VERSION = "17.30.1"
# Enumerate used to select one of the clients supported by the API # Enumerate used to select one of the clients supported by the API
enum ClientType enum ClientType
Web Web
WebEmbeddedPlayer WebEmbeddedPlayer
WebMobile WebMobile
WebScreenEmbed WebScreenEmbed
Android Android
AndroidEmbeddedPlayer AndroidEmbeddedPlayer
AndroidScreenEmbed AndroidScreenEmbed
IOS
IOSEmbedded
IOSMusic
TvHtml5
TvHtml5ScreenEmbed TvHtml5ScreenEmbed
end end
@ -21,50 +34,78 @@ module YoutubeAPI
HARDCODED_CLIENTS = { HARDCODED_CLIENTS = {
ClientType::Web => { ClientType::Web => {
name: "WEB", name: "WEB",
version: "2.20210721.00.00", version: "2.20220804.07.00",
api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", api_key: DEFAULT_API_KEY,
screen: "WATCH_FULL_SCREEN", screen: "WATCH_FULL_SCREEN",
}, },
ClientType::WebEmbeddedPlayer => { ClientType::WebEmbeddedPlayer => {
name: "WEB_EMBEDDED_PLAYER", # 56 name: "WEB_EMBEDDED_PLAYER", # 56
version: "1.20210721.1.0", version: "1.20220803.01.00",
api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", api_key: DEFAULT_API_KEY,
screen: "EMBED", screen: "EMBED",
}, },
ClientType::WebMobile => { ClientType::WebMobile => {
name: "MWEB", name: "MWEB",
version: "2.20210726.08.00", version: "2.20220805.01.00",
api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", api_key: DEFAULT_API_KEY,
screen: "", # None
}, },
ClientType::WebScreenEmbed => { ClientType::WebScreenEmbed => {
name: "WEB", name: "WEB",
version: "2.20210721.00.00", version: "2.20220804.00.00",
api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", api_key: DEFAULT_API_KEY,
screen: "EMBED", screen: "EMBED",
}, },
# Android
ClientType::Android => { ClientType::Android => {
name: "ANDROID", name: "ANDROID",
version: "16.20", version: ANDROID_APP_VERSION,
api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w",
screen: "", # ?? android_sdk_version: ANDROID_SDK_VERSION,
}, },
ClientType::AndroidEmbeddedPlayer => { ClientType::AndroidEmbeddedPlayer => {
name: "ANDROID_EMBEDDED_PLAYER", # 55 name: "ANDROID_EMBEDDED_PLAYER", # 55
version: "16.20", version: ANDROID_APP_VERSION,
api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", api_key: DEFAULT_API_KEY,
screen: "", # None?
}, },
ClientType::AndroidScreenEmbed => { ClientType::AndroidScreenEmbed => {
name: "ANDROID", # 3 name: "ANDROID", # 3
version: "16.20", version: ANDROID_APP_VERSION,
api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", api_key: DEFAULT_API_KEY,
screen: "EMBED", screen: "EMBED",
android_sdk_version: ANDROID_SDK_VERSION,
},
# IOS
ClientType::IOS => {
name: "IOS", # 5
version: IOS_APP_VERSION,
api_key: "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc",
},
ClientType::IOSEmbedded => {
name: "IOS_MESSAGES_EXTENSION", # 66
version: IOS_APP_VERSION,
api_key: DEFAULT_API_KEY,
},
ClientType::IOSMusic => {
name: "IOS_MUSIC", # 26
version: "4.32",
api_key: "AIzaSyBAETezhkwP0ZWA02RsqT1zu78Fpt0bC_s",
},
# TV app
ClientType::TvHtml5 => {
name: "TVHTML5", # 7
version: "7.20220325",
api_key: DEFAULT_API_KEY,
}, },
ClientType::TvHtml5ScreenEmbed => { ClientType::TvHtml5ScreenEmbed => {
name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", # 85
version: "2.0", version: "2.0",
api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", api_key: DEFAULT_API_KEY,
screen: "EMBED", screen: "EMBED",
}, },
} }
@ -131,7 +172,11 @@ module YoutubeAPI
# :ditto: # :ditto:
def screen : String def screen : String
HARDCODED_CLIENTS[@client_type][:screen] HARDCODED_CLIENTS[@client_type][:screen]? || ""
end
def android_sdk_version : Int64?
HARDCODED_CLIENTS[@client_type][:android_sdk_version]?
end end
# Convert to string, for logging purposes # Convert to string, for logging purposes
@ -163,7 +208,7 @@ module YoutubeAPI
"gl" => client_config.region || "US", # Can't be empty! "gl" => client_config.region || "US", # Can't be empty!
"clientName" => client_config.name, "clientName" => client_config.name,
"clientVersion" => client_config.version, "clientVersion" => client_config.version,
}, } of String => String | Int64,
} }
# Add some more context if it exists in the client definitions # Add some more context if it exists in the client definitions
@ -174,7 +219,11 @@ module YoutubeAPI
if client_config.screen == "EMBED" if client_config.screen == "EMBED"
client_context["thirdParty"] = { client_context["thirdParty"] = {
"embedUrl" => "https://www.youtube.com/embed/dQw4w9WgXcQ", "embedUrl" => "https://www.youtube.com/embed/dQw4w9WgXcQ",
} } of String => String | Int64
end
if android_sdk_version = client_config.android_sdk_version
client_context["client"]["androidSdkVersion"] = android_sdk_version
end end
return client_context return client_context