mirror of
https://gitea.invidious.io/iv-org/invidious-copy-2022-08-14.git
synced 2024-08-15 00:53:20 +00:00
Merge branch 'master' into master
This commit is contained in:
commit
861399de72
73 changed files with 1458 additions and 695 deletions
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
|
@ -41,6 +41,7 @@ jobs:
|
|||
- 1.2.2
|
||||
- 1.3.2
|
||||
- 1.4.0
|
||||
- 1.5.0
|
||||
include:
|
||||
- crystal: nightly
|
||||
stable: false
|
||||
|
|
2
.github/workflows/container-release.yml
vendored
2
.github/workflows/container-release.yml
vendored
|
@ -27,7 +27,7 @@ jobs:
|
|||
- name: Install Crystal
|
||||
uses: crystal-lang/install-crystal@v1.6.0
|
||||
with:
|
||||
crystal: 1.2.2
|
||||
crystal: 1.5.0
|
||||
|
||||
- name: Run lint
|
||||
run: |
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
<a href="https://hosted.weblate.org/engage/invidious/">
|
||||
<img alt="Translation Status" src="https://hosted.weblate.org/widgets/invidious/-/translations/svg-badge.svg">
|
||||
</a>
|
||||
|
||||
|
||||
<a href="https://github.com/humanetech-community/awesome-humane-tech">
|
||||
<img alt="Awesome Humane Tech" src="https://raw.githubusercontent.com/humanetech-community/awesome-humane-tech/main/humane-tech-badge.svg?sanitize=true">
|
||||
</a>
|
||||
|
@ -28,17 +28,17 @@
|
|||
<h3>An open source alternative front-end to YouTube</h3>
|
||||
|
||||
<a href="https://invidious.io/">Website</a>
|
||||
•
|
||||
•
|
||||
<a href="https://instances.invidious.io/">Instances list</a>
|
||||
•
|
||||
<a href="https://docs.invidious.io/faq/">FAQ</a>
|
||||
•
|
||||
•
|
||||
<a href="https://docs.invidious.io/">Documentation</a>
|
||||
•
|
||||
<a href="#contribute">Contribute</a>
|
||||
•
|
||||
<a href="https://invidious.io/donate/">Donate</a>
|
||||
|
||||
|
||||
<h5>Chat with us:</h5>
|
||||
<a href="https://matrix.to/#/#invidious:matrix.org">
|
||||
<img alt="Matrix" src="https://img.shields.io/matrix/invidious:matrix.org?label=Matrix&color=darkgreen">
|
||||
|
@ -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.
|
||||
- [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.
|
||||
- [Ytfzf](https://github.com/pystardust/ytfzf): A posix script to find and watch youtube videos from the terminal. (Without API)
|
||||
|
||||
|
||||
## Liability
|
||||
|
|
|
@ -204,7 +204,8 @@ img.thumbnail {
|
|||
margin: 1px;
|
||||
|
||||
border: 1px solid;
|
||||
border-color: #0000 #0000 #CCC #0000;
|
||||
border-color: rgba(0,0,0,0);
|
||||
border-bottom-color: #CCC;
|
||||
border-radius: 0;
|
||||
|
||||
box-shadow: none;
|
||||
|
@ -214,7 +215,8 @@ img.thumbnail {
|
|||
.searchbar input[type="search"]:focus {
|
||||
margin: 0 0 0.5px 0;
|
||||
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 */
|
||||
|
@ -234,7 +236,7 @@ input[type="search"]::-webkit-search-cancel-button {
|
|||
}
|
||||
|
||||
.user-field div {
|
||||
width: initial;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.user-field div:not(:last-child) {
|
||||
|
@ -527,3 +529,9 @@ p,
|
|||
|
||||
/* Center the "invidious" logo on the search page */
|
||||
#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; }
|
||||
|
|
|
@ -101,23 +101,27 @@ ul.vjs-menu-content::-webkit-scrollbar {
|
|||
order: 2;
|
||||
}
|
||||
|
||||
.vjs-quality-selector,
|
||||
.video-js .vjs-http-source-selector {
|
||||
.vjs-audio-button {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.vjs-playback-rate {
|
||||
.vjs-quality-selector,
|
||||
.video-js .vjs-http-source-selector {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.vjs-share-control {
|
||||
.vjs-playback-rate {
|
||||
order: 5;
|
||||
}
|
||||
|
||||
.vjs-fullscreen-control {
|
||||
.vjs-share-control {
|
||||
order: 6;
|
||||
}
|
||||
|
||||
.vjs-fullscreen-control {
|
||||
order: 7;
|
||||
}
|
||||
|
||||
.vjs-playback-rate > .vjs-menu {
|
||||
width: 50px;
|
||||
}
|
||||
|
|
|
@ -68,7 +68,10 @@ fieldset, legend {
|
|||
.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 */
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ var options = {
|
|||
'remainingTimeDisplay',
|
||||
'Spacer',
|
||||
'captionsButton',
|
||||
'audioTrackButton',
|
||||
'qualitySelector',
|
||||
'playbackRateMenuButton',
|
||||
'fullscreenToggle'
|
||||
|
@ -67,6 +68,7 @@ player.on('error', function () {
|
|||
// add local=true to all current sources
|
||||
player.src(player.currentSources().map(function (source) {
|
||||
source.src += '&local=true';
|
||||
return source;
|
||||
}));
|
||||
} else if (reloadMakesSense) {
|
||||
setTimeout(function () {
|
||||
|
@ -145,11 +147,12 @@ function isMobile() {
|
|||
}
|
||||
|
||||
if (isMobile()) {
|
||||
player.mobileUi();
|
||||
player.mobileUi({ touchControls: { seekSeconds: 5 * player.playbackRate() } });
|
||||
|
||||
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
|
||||
const ControlBar = videojs.getComponent('controlBar');
|
||||
|
@ -176,7 +179,7 @@ if (isMobile()) {
|
|||
var share_element = document.getElementsByClassName('vjs-share-control')[0];
|
||||
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];
|
||||
operations_bar_element.append(http_source_selector);
|
||||
}
|
||||
|
@ -274,6 +277,9 @@ function updateCookie(newVolume, newSpeed) {
|
|||
|
||||
player.on('ratechange', function () {
|
||||
updateCookie(null, player.playbackRate());
|
||||
if (isMobile()) {
|
||||
player.mobileUi({ touchControls: { seekSeconds: 5 * player.playbackRate() } });
|
||||
}
|
||||
});
|
||||
|
||||
player.on('volumechange', function () {
|
||||
|
@ -673,7 +679,12 @@ if (player.share) player.share(shareOptions);
|
|||
// show the preferred caption by default
|
||||
if (player_data.preferred_caption_found) {
|
||||
player.ready(function () {
|
||||
player.textTracks()[1].mode = 'showing';
|
||||
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';
|
||||
} else {
|
||||
player.textTracks()[0].mode = 'showing';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -382,13 +382,16 @@ feed_threads: 1
|
|||
## Enable/Disable the polling job that keeps the decryption
|
||||
## 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.
|
||||
##
|
||||
## Accepted values: true, false
|
||||
## Default: true
|
||||
## Default: false
|
||||
##
|
||||
#decrypt_polling: true
|
||||
#decrypt_polling: false
|
||||
|
||||
|
||||
# -----------------------------
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# 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
|
||||
# in the installation documentation: https://docs.invidious.io/Installation.md
|
||||
# in the installation documentation: https://docs.invidious.io/installation/
|
||||
|
||||
version: "3"
|
||||
services:
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
FROM alpine:edge 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
|
||||
FROM alpine:3.16 AS builder
|
||||
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
|
||||
|
||||
|
@ -34,7 +34,7 @@ RUN if [ ${release} == 1 ] ; then \
|
|||
--link-flags "-lxml2 -llzma"; \
|
||||
fi
|
||||
|
||||
FROM alpine:edge
|
||||
FROM alpine:3.16
|
||||
RUN apk add --no-cache librsvg ttf-opensans
|
||||
WORKDIR /invidious
|
||||
RUN addgroup -g 1000 -S invidious && \
|
||||
|
|
|
@ -368,7 +368,7 @@
|
|||
"footer_donate_page": "تبرّع",
|
||||
"preferences_region_label": "بلد المحتوى: ",
|
||||
"preferences_quality_dash_label": "جودة فيديو DASH المفضلة: ",
|
||||
"preferences_quality_option_dash": "DASH (جودة تكييفية)",
|
||||
"preferences_quality_option_dash": "DASH (الجودة التلقائية)",
|
||||
"preferences_quality_option_hd720": "HD720",
|
||||
"preferences_quality_option_medium": "متوسطة",
|
||||
"preferences_quality_option_small": "صغيرة",
|
||||
|
@ -459,5 +459,81 @@
|
|||
"Spanish (Spain)": "الإسبانية (إسبانيا)",
|
||||
"crash_page_search_issue": "بحثت عن <a href=\"`x`\"> المشكلات الموجودة على GitHub </a>",
|
||||
"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}} ثانية"
|
||||
}
|
||||
|
|
|
@ -88,7 +88,7 @@
|
|||
"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_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` is live": "`x` je živě",
|
||||
"preferences_category_data": "Nastavení dat",
|
||||
|
@ -486,5 +486,6 @@
|
|||
"search_filters_features_option_purchased": "Zakoupeno",
|
||||
"search_filters_sort_label": "Řadit dle",
|
||||
"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: "
|
||||
}
|
||||
|
|
|
@ -367,7 +367,7 @@
|
|||
"adminprefs_modified_source_code_url_label": "URL zum Repositorie des modifizierten Quellcodes",
|
||||
"search_filters_duration_option_short": "Kurz (< 4 Minuten)",
|
||||
"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_medium": "Mittel",
|
||||
"preferences_quality_option_small": "Niedrig",
|
||||
|
@ -460,5 +460,16 @@
|
|||
"Chinese (Taiwan)": "Chinesisch (Taiwan)",
|
||||
"Korean (auto-generated)": "Koreanisch (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"
|
||||
}
|
||||
|
|
|
@ -449,5 +449,6 @@
|
|||
"videoinfo_invidious_embed_link": "Σύνδεσμος Ενσωμάτωσης",
|
||||
"search_filters_type_option_show": "Μπάρα προόδου διαβάσματος",
|
||||
"preferences_watch_history_label": "Ενεργοποίηση ιστορικού παρακολούθησης: ",
|
||||
"search_filters_title": "Φίλτρο"
|
||||
"search_filters_title": "Φίλτρο",
|
||||
"search_message_no_results": "Δεν"
|
||||
}
|
||||
|
|
|
@ -470,5 +470,6 @@
|
|||
"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_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ä: "
|
||||
}
|
||||
|
|
|
@ -107,7 +107,7 @@
|
|||
"preferences_feed_menu_label": "Izbornik za feedove: ",
|
||||
"preferences_show_nick_label": "Prikaži nadimak na vrhu: ",
|
||||
"Top enabled: ": "Najbolji aktivirani: ",
|
||||
"CAPTCHA enabled: ": "Aktivirani CAPTCHA: ",
|
||||
"CAPTCHA enabled: ": "CAPTCHA aktiviran: ",
|
||||
"Login enabled: ": "Prijava aktivirana: ",
|
||||
"Registration enabled: ": "Registracija aktivirana: ",
|
||||
"Report statistics: ": "Izvještaj o statistici: ",
|
||||
|
@ -486,5 +486,6 @@
|
|||
"search_filters_duration_option_none": "Bilo koje duljine",
|
||||
"search_filters_duration_option_medium": "Srednje (4 – 20 minuta)",
|
||||
"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: "
|
||||
}
|
||||
|
|
|
@ -126,7 +126,7 @@
|
|||
"revoke": "cabut",
|
||||
"Subscriptions": "Langganan",
|
||||
"subscriptions_unseen_notifs_count_0": "{{count}} pemberitahuan belum dilihat",
|
||||
"search": "cari",
|
||||
"search": "Telusuri",
|
||||
"Log out": "Keluar",
|
||||
"Released under the AGPLv3 on Github.": "Dirilis di bawah AGPLv3 di GitHub.",
|
||||
"Source available here.": "Sumber tersedia di sini.",
|
||||
|
@ -346,7 +346,7 @@
|
|||
"Community": "Komunitas",
|
||||
"search_filters_sort_option_relevance": "Relevansi",
|
||||
"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_type_label": "Tipe",
|
||||
"search_filters_duration_label": "Durasi",
|
||||
|
@ -421,5 +421,32 @@
|
|||
"search_filters_title": "Saring",
|
||||
"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_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)"
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
"newest": "più recente",
|
||||
"oldest": "più vecchio",
|
||||
"popular": "Tendenze",
|
||||
"last": "durare",
|
||||
"last": "ultimo",
|
||||
"Next page": "Pagina successiva",
|
||||
"Previous page": "Pagina precedente",
|
||||
"Clear watch history?": "Eliminare la cronologia dei video guardati?",
|
||||
|
@ -28,7 +28,7 @@
|
|||
"Import and Export Data": "Importazione ed esportazione dati",
|
||||
"Import": "Importa",
|
||||
"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 NewPipe subscriptions (.json)": "Importa le iscrizioni da NewPipe (.json)",
|
||||
"Import NewPipe data (.zip)": "Importa i dati di NewPipe (.zip)",
|
||||
|
@ -158,7 +158,7 @@
|
|||
"generic_views_count_plural": "{{count}} visualizzazioni",
|
||||
"Premieres in `x`": "In anteprima in `x`",
|
||||
"Premieres `x`": "In anteprima `x`",
|
||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Ciao! Sembra che tu abbia disattivato JavaScript. Clicca qui per visualizzare i commenti. Considera che potrebbe volerci più tempo.",
|
||||
"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 more comments on Reddit": "Visualizza più commenti su Reddit",
|
||||
"View `x` comments": {
|
||||
|
@ -212,7 +212,7 @@
|
|||
"Azerbaijani": "Azero",
|
||||
"Bangla": "Bengalese",
|
||||
"Basque": "Basco",
|
||||
"Belarusian": "Biellorusso",
|
||||
"Belarusian": "Bielorusso",
|
||||
"Bosnian": "Bosniaco",
|
||||
"Bulgarian": "Bulgaro",
|
||||
"Burmese": "Birmano",
|
||||
|
@ -238,10 +238,10 @@
|
|||
"Haitian Creole": "Creolo haitiano",
|
||||
"Hausa": "Lingua hausa",
|
||||
"Hawaiian": "Hawaiano",
|
||||
"Hebrew": "Ebreo",
|
||||
"Hebrew": "Ebraico",
|
||||
"Hindi": "Hindi",
|
||||
"Hmong": "Hmong",
|
||||
"Hungarian": "Ungarese",
|
||||
"Hungarian": "Ungherese",
|
||||
"Icelandic": "Islandese",
|
||||
"Igbo": "Igbo",
|
||||
"Indonesian": "Indonesiano",
|
||||
|
@ -254,7 +254,7 @@
|
|||
"Khmer": "Khmer",
|
||||
"Korean": "Coreano",
|
||||
"Kurdish": "Curdo",
|
||||
"Kyrgyz": "Kirghize",
|
||||
"Kyrgyz": "Kirghiso",
|
||||
"Lao": "Lao",
|
||||
"Latin": "Latino",
|
||||
"Latvian": "Lettone",
|
||||
|
@ -269,7 +269,7 @@
|
|||
"Marathi": "Marathi",
|
||||
"Mongolian": "Mongolo",
|
||||
"Nepali": "Nepalese",
|
||||
"Norwegian Bokmål": "Norvegese",
|
||||
"Norwegian Bokmål": "Norvegese bokmål",
|
||||
"Nyanja": "Nyanja",
|
||||
"Pashto": "Pashtu",
|
||||
"Persian": "Persiano",
|
||||
|
@ -278,7 +278,7 @@
|
|||
"Punjabi": "Punjabi",
|
||||
"Romanian": "Rumeno",
|
||||
"Russian": "Russo",
|
||||
"Samoan": "Samoan",
|
||||
"Samoan": "Samoano",
|
||||
"Scottish Gaelic": "Gaelico scozzese",
|
||||
"Serbian": "Serbo",
|
||||
"Shona": "Shona",
|
||||
|
@ -293,15 +293,15 @@
|
|||
"Sundanese": "Sudanese",
|
||||
"Swahili": "Swahili",
|
||||
"Swedish": "Svedese",
|
||||
"Tajik": "Tajik",
|
||||
"Tajik": "Tagico",
|
||||
"Tamil": "Tamil",
|
||||
"Telugu": "Telugu",
|
||||
"Thai": "Thaï",
|
||||
"Thai": "Thailandese",
|
||||
"Turkish": "Turco",
|
||||
"Ukrainian": "Ucraino",
|
||||
"Urdu": "Urdu",
|
||||
"Uzbek": "Uzbeco",
|
||||
"Vietnamese": "Vietnamese",
|
||||
"Vietnamese": "Vietnamita",
|
||||
"Welsh": "Gallese",
|
||||
"Western Frisian": "Frisone occidentale",
|
||||
"Xhosa": "Xhosa",
|
||||
|
@ -340,7 +340,7 @@
|
|||
"%A %B %-d, %Y": "%A %-d %B %Y",
|
||||
"(edited)": "(modificato)",
|
||||
"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 ❤",
|
||||
"Audio mode": "Modalità audio",
|
||||
"Video mode": "Modalità video",
|
||||
|
@ -364,7 +364,7 @@
|
|||
"search_filters_type_option_channel": "Canale",
|
||||
"search_filters_type_option_playlist": "Playlist",
|
||||
"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_c_commons": "Creative Commons",
|
||||
"search_filters_features_option_three_d": "3D",
|
||||
|
@ -383,9 +383,9 @@
|
|||
"preferences_quality_dash_option_4320p": "4320p",
|
||||
"search_filters_features_option_three_sixty": "360°",
|
||||
"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_small": "Piccola",
|
||||
"preferences_quality_option_small": "Limitata",
|
||||
"preferences_quality_dash_option_best": "Migliore",
|
||||
"preferences_quality_dash_option_worst": "Peggiore",
|
||||
"invidious": "Invidious",
|
||||
|
@ -393,7 +393,7 @@
|
|||
"preferences_quality_option_hd720": "HD720",
|
||||
"preferences_quality_dash_option_auto": "Automatica",
|
||||
"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: ",
|
||||
"Show less": "Mostra di meno",
|
||||
"Switch Invidious Instance": "Cambia istanza Invidious",
|
||||
|
@ -425,5 +425,51 @@
|
|||
"search_filters_type_option_show": "Serie",
|
||||
"search_filters_duration_option_short": "Corto (< 4 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>"
|
||||
}
|
||||
|
|
|
@ -433,5 +433,10 @@
|
|||
"Spanish (Spain)": "スペイン語 (スペイン)",
|
||||
"Vietnamese (auto-generated)": "ベトナム語 (自動生成)",
|
||||
"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"
|
||||
}
|
||||
|
|
|
@ -460,5 +460,16 @@
|
|||
"Russian (auto-generated)": "Russisk (laget automatisk)",
|
||||
"Dutch (auto-generated)": "Nederlandsk (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 (4–20 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"
|
||||
}
|
||||
|
|
|
@ -470,5 +470,6 @@
|
|||
"Spanish (Spain)": "Espanhol (Espanha)",
|
||||
"Turkish (auto-generated)": "Turco (gerado automaticamente)",
|
||||
"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: "
|
||||
}
|
||||
|
|
|
@ -21,15 +21,15 @@
|
|||
"No": "Não",
|
||||
"Import and Export Data": "Importar e exportar dados",
|
||||
"Import": "Importar",
|
||||
"Import Invidious data": "Importar dados do Invidious",
|
||||
"Import YouTube subscriptions": "Importar subscrições do YouTube",
|
||||
"Import Invidious data": "Importar dados JSON do Invidious",
|
||||
"Import YouTube subscriptions": "Importar subscrições OPML ou do YouTube",
|
||||
"Import FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)",
|
||||
"Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)",
|
||||
"Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)",
|
||||
"Export": "Exportar",
|
||||
"Export subscriptions as OPML": "Exportar subscrições como OPML",
|
||||
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar subscrições como OPML (para NewPipe e FreeTube)",
|
||||
"Export data as JSON": "Exportar dados como JSON",
|
||||
"Export data as JSON": "Exportar dados do Invidious como JSON",
|
||||
"Delete account?": "Eliminar conta?",
|
||||
"History": "Histórico",
|
||||
"An alternative front-end to YouTube": "Uma interface alternativa ao YouTube",
|
||||
|
@ -60,13 +60,13 @@
|
|||
"preferences_volume_label": "Volume da reprodução: ",
|
||||
"preferences_comments_label": "Preferência dos comentários: ",
|
||||
"youtube": "YouTube",
|
||||
"reddit": "reddit",
|
||||
"reddit": "Reddit",
|
||||
"preferences_captions_label": "Legendas predefinidas: ",
|
||||
"Fallback captions: ": "Legendas alternativas: ",
|
||||
"preferences_related_videos_label": "Mostrar vídeos relacionados: ",
|
||||
"preferences_annotations_label": "Mostrar anotações sempre: ",
|
||||
"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_player_style_label": "Estilo do reprodutor: ",
|
||||
"Dark mode: ": "Modo escuro: ",
|
||||
|
@ -374,5 +374,93 @@
|
|||
"next_steps_error_message": "Pode tentar as seguintes opções: ",
|
||||
"next_steps_error_message_refresh": "Atualizar",
|
||||
"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"
|
||||
}
|
||||
|
|
|
@ -470,5 +470,6 @@
|
|||
"search_filters_date_label": "Data de publicação",
|
||||
"search_filters_date_option_none": "Qualquer data",
|
||||
"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: "
|
||||
}
|
||||
|
|
|
@ -75,11 +75,11 @@
|
|||
"light": "светлая",
|
||||
"preferences_thin_mode_label": "Облегчённое оформление: ",
|
||||
"preferences_category_misc": "Прочие настройки",
|
||||
"preferences_automatic_instance_redirect_label": "Автоматическое перенаправление на зеркало сайта (переход на redirect.invidious.io): ",
|
||||
"preferences_automatic_instance_redirect_label": "Автоматическая смена зеркала (переход на redirect.invidious.io): ",
|
||||
"preferences_category_subscription": "Настройки подписок",
|
||||
"preferences_annotations_subscribed_label": "Всегда показывать аннотации на каналах из ваших подписок? ",
|
||||
"Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ",
|
||||
"preferences_max_results_label": "Число видео, на которые вы подписаны, в ленте: ",
|
||||
"Redirect homepage to feed: ": "Показывать подписки на главной странице: ",
|
||||
"preferences_max_results_label": "Число видео в ленте: ",
|
||||
"preferences_sort_label": "Сортировать видео: ",
|
||||
"published": "по дате публикации",
|
||||
"published - reverse": "по дате публикации в обратном порядке",
|
||||
|
@ -102,13 +102,13 @@
|
|||
"Manage tokens": "Управление токенами",
|
||||
"Watch history": "История просмотров",
|
||||
"Delete account": "Удалить аккаунт",
|
||||
"preferences_category_admin": "Администраторские настройки",
|
||||
"preferences_category_admin": "Настройки администратора",
|
||||
"preferences_default_home_label": "Главная страница по умолчанию: ",
|
||||
"preferences_feed_menu_label": "Меню ленты видео: ",
|
||||
"preferences_show_nick_label": "Показать ник вверху: ",
|
||||
"Top enabled: ": "Включить топ видео? ",
|
||||
"CAPTCHA enabled: ": "Включить капчу? ",
|
||||
"Login enabled: ": "Включить авторизацию? ",
|
||||
"Login enabled: ": "Включить авторизацию: ",
|
||||
"Registration enabled: ": "Включить регистрацию? ",
|
||||
"Report statistics: ": "Сообщать статистику? ",
|
||||
"Save preferences": "Сохранить настройки",
|
||||
|
@ -158,7 +158,7 @@
|
|||
"View more comments on Reddit": "Посмотреть больше комментариев на Reddit",
|
||||
"View `x` comments": {
|
||||
"([^.,0-9]|^)1([^.,0-9]|$)": "Показано `x` комментариев",
|
||||
"": "Показано`x` комментариев"
|
||||
"": "Показано `x` комментариев"
|
||||
},
|
||||
"View Reddit comments": "Смотреть комментарии с Reddit",
|
||||
"Hide replies": "Скрыть ответы",
|
||||
|
@ -186,7 +186,7 @@
|
|||
"Could not fetch comments": "Не удаётся загрузить комментарии",
|
||||
"`x` ago": "`x` назад",
|
||||
"Load more": "Загрузить ещё",
|
||||
"Could not create mix.": "Не удаётся создать микс.",
|
||||
"Could not create mix.": "Не удалось создать микс.",
|
||||
"Empty playlist": "Плейлист пуст",
|
||||
"Not a playlist.": "Некорректный плейлист.",
|
||||
"Playlist does not exist.": "Плейлист не существует.",
|
||||
|
@ -195,7 +195,7 @@
|
|||
"Hidden field \"token\" is a required field": "Необходимо заполнить скрытое поле «токен»",
|
||||
"Erroneous challenge": "Неправильный ответ в «challenge»",
|
||||
"Erroneous token": "Неправильный токен",
|
||||
"No such user": "Недопустимое имя пользователя",
|
||||
"No such user": "Пользователь не найден",
|
||||
"Token is expired, please try again": "Срок действия токена истёк, попробуйте позже",
|
||||
"English": "Английский",
|
||||
"English (auto-generated)": "Английский (созданы автоматически)",
|
||||
|
@ -486,5 +486,6 @@
|
|||
"search_filters_features_option_vr180": "VR180",
|
||||
"search_message_change_filters_or_query": "Попробуйте расширить поисковый запрос или изменить фильтры.",
|
||||
"search_filters_duration_option_medium": "Средние (4 - 20 минут)",
|
||||
"search_filters_apply_button": "Применить фильтры"
|
||||
"search_filters_apply_button": "Применить фильтры",
|
||||
"Popular enabled: ": "Популярное включено: "
|
||||
}
|
||||
|
|
126
locales/si.json
Normal file
126
locales/si.json
Normal 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": "දායකත්ව"
|
||||
}
|
|
@ -502,5 +502,6 @@
|
|||
"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_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: "
|
||||
}
|
||||
|
|
|
@ -470,5 +470,6 @@
|
|||
"search_filters_duration_option_medium": "Orta (4 - 20 dakika)",
|
||||
"search_filters_features_option_vr180": "VR180",
|
||||
"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: "
|
||||
}
|
||||
|
|
|
@ -486,5 +486,6 @@
|
|||
"search_filters_features_option_purchased": "Придбано",
|
||||
"search_filters_sort_option_relevance": "Відповідні",
|
||||
"search_filters_sort_option_rating": "Рейтингові",
|
||||
"search_filters_sort_option_views": "Популярні"
|
||||
"search_filters_sort_option_views": "Популярні",
|
||||
"Popular enabled: ": "Популярне ввімкнено: "
|
||||
}
|
||||
|
|
|
@ -454,5 +454,6 @@
|
|||
"search_message_change_filters_or_query": "尝试扩大你的搜索查询和/或更改过滤器。",
|
||||
"search_filters_duration_option_none": "任意时长",
|
||||
"search_filters_type_option_all": "任意类型",
|
||||
"search_filters_features_option_vr180": "VR180"
|
||||
"search_filters_features_option_vr180": "VR180",
|
||||
"Popular enabled: ": "已启用流行度: "
|
||||
}
|
||||
|
|
|
@ -454,5 +454,6 @@
|
|||
"search_filters_title": "過濾條件",
|
||||
"search_filters_date_label": "上傳日期",
|
||||
"search_filters_type_option_all": "任何類型",
|
||||
"search_filters_date_option_none": "任何日期"
|
||||
"search_filters_date_option_none": "任何日期",
|
||||
"Popular enabled: ": "已啟用人氣: "
|
||||
}
|
||||
|
|
2
mocks
2
mocks
|
@ -1 +1 @@
|
|||
Subproject commit 020337194dd482c47ee2d53cd111d0ebf2831e52
|
||||
Subproject commit c401dd9203434b561022242c24b0c200d72284c0
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
interactive=true
|
||||
|
||||
if [ "$1" == "--no-interactive" ]; then
|
||||
if [ "$1" = "--no-interactive" ]; then
|
||||
interactive=false
|
||||
fi
|
||||
|
||||
|
@ -21,7 +21,7 @@ sudo systemctl enable postgresql.service
|
|||
# Create databse and user
|
||||
#
|
||||
|
||||
if [ "$interactive" == "true" ]; then
|
||||
if [ "$interactive" = "true" ]; then
|
||||
sudo -u postgres -- createuser -P kemal
|
||||
sudo -u postgres -- createdb -O kemal invidious
|
||||
else
|
||||
|
|
|
@ -74,7 +74,7 @@ install_apt() {
|
|||
sudo apt-get install --yes --no-install-recommends \
|
||||
libssl-dev libxml2-dev libyaml-dev libgmp-dev libevent-dev \
|
||||
libpcre3-dev libreadline-dev libsqlite3-dev zlib1g-dev \
|
||||
crystal postgres git librsvg2-bin make
|
||||
crystal postgresql-13 git librsvg2-bin make
|
||||
}
|
||||
|
||||
install_yum() {
|
||||
|
|
|
@ -197,4 +197,46 @@ Spectator.describe Invidious::Search::Query do
|
|||
)
|
||||
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
|
||||
|
|
113
spec/invidious/videos/scheduled_live_extract_spec.cr
Normal file
113
spec/invidious/videos/scheduled_live_extract_spec.cr
Normal 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
|
|
@ -6,6 +6,7 @@ require "protodec/utils"
|
|||
|
||||
require "spectator"
|
||||
|
||||
require "../src/invidious/exceptions"
|
||||
require "../src/invidious/helpers/macros"
|
||||
require "../src/invidious/helpers/logger"
|
||||
require "../src/invidious/helpers/utils"
|
||||
|
|
301
src/invidious.cr
301
src/invidious.cr
|
@ -136,12 +136,13 @@ Invidious::Database.check_integrity(CONFIG)
|
|||
# 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?
|
||||
|
||||
{% 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) %}
|
||||
{% puts run("../scripts/fetch-player-dependencies.cr", "--minified").stringify %}
|
||||
{% else %}
|
||||
{% puts run("../scripts/fetch-player-dependencies.cr").stringify %}
|
||||
{% end %}
|
||||
{% puts "\nDone checking player dependencies, now compiling Invidious...\n" %}
|
||||
{% end %}
|
||||
|
||||
# Start jobs
|
||||
|
@ -180,305 +181,19 @@ def popular_videos
|
|||
Invidious::Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get
|
||||
end
|
||||
|
||||
# Routing
|
||||
|
||||
before_all do |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
|
||||
|
||||
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)
|
||||
Invidious::Routes::BeforeAll.handle(env)
|
||||
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()
|
||||
Invidious::Routing.register_all
|
||||
|
||||
error 404 do |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"] = "/"
|
||||
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
|
||||
Invidious::Routes::ErrorRoutes.error_404(env)
|
||||
end
|
||||
|
||||
error 500 do |env, ex|
|
||||
locale = env.get("preferences").as(Preferences).locale
|
||||
error_template(500, ex)
|
||||
end
|
||||
|
||||
|
@ -486,6 +201,8 @@ static_headers do |response|
|
|||
response.headers.add("Cache-Control", "max-age=2629800")
|
||||
end
|
||||
|
||||
# Init Kemal
|
||||
|
||||
public_folder "assets"
|
||||
|
||||
Kemal.config.powered_by_header = false
|
||||
|
|
|
@ -31,7 +31,12 @@ def get_about_info(ucid, locale) : AboutChannel
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
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?
|
||||
|
||||
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
|
||||
author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
|
||||
author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
|
||||
|
@ -74,13 +76,17 @@ def get_about_info(ucid, locale) : AboutChannel
|
|||
# end
|
||||
|
||||
description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?
|
||||
|
||||
is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
|
||||
allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s)
|
||||
end
|
||||
|
||||
is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
|
||||
|
||||
allowed_regions = initdata
|
||||
.dig?("microformat", "microformatDataRenderer", "availableCountries")
|
||||
.try &.as_a.map(&.as_s) || [] of String
|
||||
|
||||
description = !description_node.nil? ? description_node.as_s : ""
|
||||
description_html = HTML.escape(description)
|
||||
|
||||
if !description_node.nil?
|
||||
if description_node.as_h?.nil?
|
||||
description_node = text_to_parsed_content(description_node.as_s)
|
||||
|
|
|
@ -6,20 +6,18 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
|
|||
end
|
||||
|
||||
if response.status_code != 200
|
||||
raise InfoException.new("This channel does not exist.")
|
||||
raise NotFoundException.new("This channel does not exist.")
|
||||
end
|
||||
|
||||
ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"]
|
||||
|
||||
if !continuation || continuation.empty?
|
||||
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
|
||||
raise InfoException.new("Could not extract community tab.")
|
||||
end
|
||||
|
||||
body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]
|
||||
else
|
||||
continuation = produce_channel_community_continuation(ucid, continuation)
|
||||
|
||||
|
@ -49,7 +47,11 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
|
|||
error_message = (message["text"]["simpleText"]? ||
|
||||
message["text"]["runs"]?.try &.[0]?.try &.["text"]?)
|
||||
.try &.as_s || ""
|
||||
raise InfoException.new(error_message)
|
||||
if error_message == "This channel does not exist."
|
||||
raise NotFoundException.new(error_message)
|
||||
else
|
||||
raise InfoException.new(error_message)
|
||||
end
|
||||
end
|
||||
|
||||
response = JSON.build do |json|
|
||||
|
|
|
@ -95,7 +95,7 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b
|
|||
contents = body["contents"]?
|
||||
header = body["header"]?
|
||||
else
|
||||
raise InfoException.new("Could not fetch comments")
|
||||
raise NotFoundException.new("Comments not found.")
|
||||
end
|
||||
|
||||
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)
|
||||
else
|
||||
raise InfoException.new("Could not fetch comments")
|
||||
raise NotFoundException.new("Comments not found.")
|
||||
end
|
||||
|
||||
client.close
|
||||
|
|
|
@ -86,7 +86,7 @@ class Config
|
|||
@[YAML::Field(converter: Preferences::URIConverter)]
|
||||
property database_url : URI = URI.parse("")
|
||||
# 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
|
||||
property full_refresh : Bool = false
|
||||
# Used to tell Invidious it is behind a proxy, so links to resources should be https://
|
||||
|
|
|
@ -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.
|
||||
class ChannelSearchException < InfoException
|
||||
getter channel : String
|
||||
|
@ -18,3 +26,7 @@ class BrokenTubeException < Exception
|
|||
return "Missing JSON element \"#{@element}\""
|
||||
end
|
||||
end
|
||||
|
||||
# Exception threw when an element is not found.
|
||||
class NotFoundException < InfoException
|
||||
end
|
||||
|
|
|
@ -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
|
||||
# -------------------
|
||||
|
|
|
@ -317,7 +317,7 @@ def get_playlist(plid : String)
|
|||
if playlist = Invidious::Database::Playlists.select(id: plid)
|
||||
return playlist
|
||||
else
|
||||
raise InfoException.new("Playlist does not exist.")
|
||||
raise NotFoundException.new("Playlist does not exist.")
|
||||
end
|
||||
else
|
||||
return fetch_playlist(plid)
|
||||
|
|
|
@ -16,6 +16,8 @@ module Invidious::Routes::API::Manifest
|
|||
video = get_video(id, region: region)
|
||||
rescue ex : VideoRedirect
|
||||
return env.redirect env.request.resource.gsub(id, ex.video_id)
|
||||
rescue ex : NotFoundException
|
||||
haltf env, status_code: 404
|
||||
rescue ex
|
||||
haltf env, status_code: 403
|
||||
end
|
||||
|
@ -46,7 +48,7 @@ module Invidious::Routes::API::Manifest
|
|||
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!
|
||||
|
||||
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 }
|
||||
next if mime_streams.empty?
|
||||
|
||||
xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do
|
||||
mime_streams.each do |fmt|
|
||||
# OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415)
|
||||
next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange"))
|
||||
mime_streams.each do |fmt|
|
||||
# OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415)
|
||||
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('"')
|
||||
bandwidth = fmt["bitrate"].as_i
|
||||
itag = fmt["itag"].as_i
|
||||
url = fmt["url"].as_s
|
||||
|
||||
xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: i == 0 ? "main" : "alternate")
|
||||
|
||||
xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do
|
||||
xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011",
|
||||
value: "2")
|
||||
|
@ -79,9 +87,8 @@ module Invidious::Routes::API::Manifest
|
|||
end
|
||||
end
|
||||
end
|
||||
i += 1
|
||||
end
|
||||
|
||||
i += 1
|
||||
end
|
||||
|
||||
potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144}
|
||||
|
|
|
@ -237,6 +237,8 @@ module Invidious::Routes::API::V1::Authenticated
|
|||
|
||||
begin
|
||||
video = get_video(video_id)
|
||||
rescue ex : NotFoundException
|
||||
return error_json(404, ex)
|
||||
rescue ex
|
||||
return error_json(500, ex)
|
||||
end
|
||||
|
|
|
@ -13,6 +13,8 @@ module Invidious::Routes::API::V1::Channels
|
|||
rescue ex : ChannelRedirect
|
||||
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
|
||||
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
|
||||
rescue ex : NotFoundException
|
||||
return error_json(404, ex)
|
||||
rescue ex
|
||||
return error_json(500, ex)
|
||||
end
|
||||
|
@ -170,6 +172,8 @@ module Invidious::Routes::API::V1::Channels
|
|||
rescue ex : ChannelRedirect
|
||||
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
|
||||
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
|
||||
rescue ex : NotFoundException
|
||||
return error_json(404, ex)
|
||||
rescue ex
|
||||
return error_json(500, ex)
|
||||
end
|
||||
|
@ -205,6 +209,8 @@ module Invidious::Routes::API::V1::Channels
|
|||
rescue ex : ChannelRedirect
|
||||
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
|
||||
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
|
||||
rescue ex : NotFoundException
|
||||
return error_json(404, ex)
|
||||
rescue ex
|
||||
return error_json(500, ex)
|
||||
end
|
||||
|
|
|
@ -12,6 +12,8 @@ module Invidious::Routes::API::V1::Videos
|
|||
rescue ex : VideoRedirect
|
||||
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
|
||||
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
|
||||
rescue ex : NotFoundException
|
||||
return error_json(404, ex)
|
||||
rescue ex
|
||||
return error_json(500, ex)
|
||||
end
|
||||
|
@ -42,6 +44,8 @@ module Invidious::Routes::API::V1::Videos
|
|||
rescue ex : VideoRedirect
|
||||
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
|
||||
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
|
||||
rescue ex : NotFoundException
|
||||
haltf env, 404
|
||||
rescue ex
|
||||
haltf env, 500
|
||||
end
|
||||
|
@ -167,6 +171,8 @@ module Invidious::Routes::API::V1::Videos
|
|||
rescue ex : VideoRedirect
|
||||
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
|
||||
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
|
||||
rescue ex : NotFoundException
|
||||
haltf env, 404
|
||||
rescue ex
|
||||
haltf env, 500
|
||||
end
|
||||
|
@ -324,6 +330,8 @@ module Invidious::Routes::API::V1::Videos
|
|||
|
||||
begin
|
||||
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
|
||||
return error_json(500, ex)
|
||||
end
|
||||
|
|
152
src/invidious/routes/before_all.cr
Normal file
152
src/invidious/routes/before_all.cr
Normal 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
|
|
@ -85,6 +85,9 @@ module Invidious::Routes::Channels
|
|||
rescue ex : InfoException
|
||||
env.response.status_code = 500
|
||||
error_message = ex.message
|
||||
rescue ex : NotFoundException
|
||||
env.response.status_code = 404
|
||||
error_message = ex.message
|
||||
rescue ex
|
||||
return error_template(500, ex)
|
||||
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}" : ""}")
|
||||
ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"]
|
||||
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
|
||||
|
||||
selected_tab = env.request.path.split("/")[-1]
|
||||
|
@ -141,7 +144,7 @@ module Invidious::Routes::Channels
|
|||
|
||||
user = env.params.query["user"]?
|
||||
if !user
|
||||
raise InfoException.new("This channel does not exist.")
|
||||
return error_template(404, "This channel does not exist.")
|
||||
else
|
||||
env.redirect "/user/#{user}#{uri_params}"
|
||||
end
|
||||
|
@ -197,6 +200,8 @@ module Invidious::Routes::Channels
|
|||
channel = get_about_info(ucid, locale)
|
||||
rescue ex : ChannelRedirect
|
||||
return env.redirect env.request.resource.gsub(ucid, ex.channel_id)
|
||||
rescue ex : NotFoundException
|
||||
return error_template(404, ex)
|
||||
rescue ex
|
||||
return error_template(500, ex)
|
||||
end
|
||||
|
|
|
@ -7,6 +7,8 @@ module Invidious::Routes::Embed
|
|||
playlist = get_playlist(plid)
|
||||
offset = env.params.query["index"]?.try &.to_i? || 0
|
||||
videos = get_playlist_videos(playlist, offset: offset)
|
||||
rescue ex : NotFoundException
|
||||
return error_template(404, ex)
|
||||
rescue ex
|
||||
return error_template(500, ex)
|
||||
end
|
||||
|
@ -60,6 +62,8 @@ module Invidious::Routes::Embed
|
|||
playlist = get_playlist(plid)
|
||||
offset = env.params.query["index"]?.try &.to_i? || 0
|
||||
videos = get_playlist_videos(playlist, offset: offset)
|
||||
rescue ex : NotFoundException
|
||||
return error_template(404, ex)
|
||||
rescue ex
|
||||
return error_template(500, ex)
|
||||
end
|
||||
|
@ -119,6 +123,8 @@ module Invidious::Routes::Embed
|
|||
video = get_video(id, region: params.region)
|
||||
rescue ex : VideoRedirect
|
||||
return env.redirect env.request.resource.gsub(id, ex.video_id)
|
||||
rescue ex : NotFoundException
|
||||
return error_template(404, ex)
|
||||
rescue ex
|
||||
return error_template(500, ex)
|
||||
end
|
||||
|
|
47
src/invidious/routes/errors.cr
Normal file
47
src/invidious/routes/errors.cr
Normal 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
|
|
@ -150,6 +150,8 @@ module Invidious::Routes::Feeds
|
|||
channel = get_about_info(ucid, locale)
|
||||
rescue ex : ChannelRedirect
|
||||
return env.redirect env.request.resource.gsub(ucid, ex.channel_id)
|
||||
rescue ex : NotFoundException
|
||||
return error_atom(404, ex)
|
||||
rescue ex
|
||||
return error_atom(500, ex)
|
||||
end
|
||||
|
@ -202,6 +204,12 @@ module Invidious::Routes::Feeds
|
|||
xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" }
|
||||
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|
|
||||
video.to_xml(channel.auto_generated, params, xml)
|
||||
end
|
||||
|
|
|
@ -66,7 +66,13 @@ module Invidious::Routes::Playlists
|
|||
user = user.as(User)
|
||||
|
||||
playlist_id = env.params.query["list"]
|
||||
playlist = get_playlist(playlist_id)
|
||||
begin
|
||||
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)
|
||||
|
||||
env.redirect "/playlist?list=#{playlist.id}"
|
||||
|
@ -304,6 +310,8 @@ module Invidious::Routes::Playlists
|
|||
playlist_id = env.params.query["playlist_id"]
|
||||
playlist = get_playlist(playlist_id).as(InvidiousPlaylist)
|
||||
raise "Invalid user" if playlist.author != user.email
|
||||
rescue ex : NotFoundException
|
||||
return error_json(404, ex)
|
||||
rescue ex
|
||||
if redirect
|
||||
return error_template(400, ex)
|
||||
|
@ -334,6 +342,8 @@ module Invidious::Routes::Playlists
|
|||
|
||||
begin
|
||||
video = get_video(video_id)
|
||||
rescue ex : NotFoundException
|
||||
return error_json(404, ex)
|
||||
rescue ex
|
||||
if redirect
|
||||
return error_template(500, ex)
|
||||
|
@ -394,6 +404,8 @@ module Invidious::Routes::Playlists
|
|||
|
||||
begin
|
||||
playlist = get_playlist(plid)
|
||||
rescue ex : NotFoundException
|
||||
return error_template(404, ex)
|
||||
rescue ex
|
||||
return error_template(500, ex)
|
||||
end
|
||||
|
|
|
@ -59,6 +59,12 @@ module Invidious::Routes::Search
|
|||
return error_template(500, ex)
|
||||
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
|
||||
templated "search"
|
||||
end
|
||||
|
|
|
@ -265,7 +265,13 @@ module Invidious::Routes::VideoPlayback
|
|||
return error_template(403, "Administrator has disabled this endpoint.")
|
||||
end
|
||||
|
||||
video = get_video(id, region: region)
|
||||
begin
|
||||
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 }
|
||||
url = fmt.try &.["url"]?.try &.as_s
|
||||
|
|
|
@ -63,6 +63,9 @@ module Invidious::Routes::Watch
|
|||
video = get_video(id, region: params.region)
|
||||
rescue ex : VideoRedirect
|
||||
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
|
||||
LOGGER.error("get_video: #{id} : #{ex.message}")
|
||||
return error_template(500, ex)
|
||||
|
|
|
@ -1,131 +1,274 @@
|
|||
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)
|
||||
{{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)
|
||||
end
|
||||
end
|
||||
|
||||
{% end %}
|
||||
end
|
||||
|
||||
macro define_user_routes
|
||||
# User login/out
|
||||
Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page
|
||||
Invidious::Routing.get "/login/oauth/:provider", Invidious::Routes::Login, :login_oauth
|
||||
Invidious::Routing.post "/login", Invidious::Routes::Login, :login
|
||||
Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout
|
||||
Invidious::Routing.get "/Captcha", Invidious::Routes::Login, :captcha
|
||||
|
||||
# User preferences
|
||||
Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show
|
||||
Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update
|
||||
Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
|
||||
Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control
|
||||
Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control
|
||||
|
||||
# User account management
|
||||
Invidious::Routing.get "/change_password", Invidious::Routes::Account, :get_change_password
|
||||
Invidious::Routing.post "/change_password", Invidious::Routes::Account, :post_change_password
|
||||
Invidious::Routing.get "/delete_account", Invidious::Routes::Account, :get_delete
|
||||
Invidious::Routing.post "/delete_account", Invidious::Routes::Account, :post_delete
|
||||
Invidious::Routing.get "/clear_watch_history", Invidious::Routes::Account, :get_clear_history
|
||||
Invidious::Routing.post "/clear_watch_history", Invidious::Routes::Account, :post_clear_history
|
||||
Invidious::Routing.get "/authorize_token", Invidious::Routes::Account, :get_authorize_token
|
||||
Invidious::Routing.post "/authorize_token", Invidious::Routes::Account, :post_authorize_token
|
||||
Invidious::Routing.get "/token_manager", Invidious::Routes::Account, :token_manager
|
||||
Invidious::Routing.post "/token_ajax", Invidious::Routes::Account, :token_ajax
|
||||
end
|
||||
|
||||
macro define_v1_api_routes
|
||||
{{namespace = Invidious::Routes::API::V1}}
|
||||
# Videos
|
||||
Invidious::Routing.get "/api/v1/videos/:id", {{namespace}}::Videos, :videos
|
||||
Invidious::Routing.get "/api/v1/storyboards/:id", {{namespace}}::Videos, :storyboards
|
||||
Invidious::Routing.get "/api/v1/captions/:id", {{namespace}}::Videos, :captions
|
||||
Invidious::Routing.get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations
|
||||
Invidious::Routing.get "/api/v1/comments/:id", {{namespace}}::Videos, :comments
|
||||
|
||||
# Feeds
|
||||
Invidious::Routing.get "/api/v1/trending", {{namespace}}::Feeds, :trending
|
||||
Invidious::Routing.get "/api/v1/popular", {{namespace}}::Feeds, :popular
|
||||
|
||||
# Channels
|
||||
Invidious::Routing.get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home
|
||||
{% for route in {"videos", "latest", "playlists", "community", "search"} %}
|
||||
Invidious::Routing.get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}}
|
||||
Invidious::Routing.get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}}
|
||||
{% end %}
|
||||
|
||||
# 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
|
||||
Invidious::Routing.get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect
|
||||
|
||||
|
||||
# Search
|
||||
Invidious::Routing.get "/api/v1/search", {{namespace}}::Search, :search
|
||||
Invidious::Routing.get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions
|
||||
|
||||
# Authenticated
|
||||
|
||||
# The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr
|
||||
#
|
||||
# Invidious::Routing.get "/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
|
||||
Invidious::Routing.post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences
|
||||
|
||||
Invidious::Routing.get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed
|
||||
|
||||
Invidious::Routing.get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions
|
||||
Invidious::Routing.post "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :subscribe_channel
|
||||
Invidious::Routing.delete "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :unsubscribe_channel
|
||||
|
||||
|
||||
Invidious::Routing.get "/api/v1/auth/playlists", {{namespace}}::Authenticated, :list_playlists
|
||||
Invidious::Routing.post "/api/v1/auth/playlists", {{namespace}}::Authenticated, :create_playlist
|
||||
Invidious::Routing.patch "/api/v1/auth/playlists/:plid",{{namespace}}:: Authenticated, :update_playlist_attribute
|
||||
Invidious::Routing.delete "/api/v1/auth/playlists/:plid", {{namespace}}::Authenticated, :delete_playlist
|
||||
|
||||
|
||||
Invidious::Routing.post "/api/v1/auth/playlists/:plid/videos", {{namespace}}::Authenticated, :insert_video_into_playlist
|
||||
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
|
||||
Invidious::Routing.get "/api/v1/stats", {{namespace}}::Misc, :stats
|
||||
Invidious::Routing.get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist
|
||||
Invidious::Routing.get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist
|
||||
Invidious::Routing.get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# -------------------
|
||||
# Invidious routes
|
||||
# -------------------
|
||||
|
||||
def register_user_routes
|
||||
# User login/out
|
||||
get "/login", Routes::Login, :login_page
|
||||
get "/login/oauth/:provider", Routes::Login, :login_oauth
|
||||
post "/login", Routes::Login, :login
|
||||
post "/signout", Routes::Login, :signout
|
||||
get "/Captcha", Routes::Login, :captcha
|
||||
|
||||
# User preferences
|
||||
get "/preferences", Routes::PreferencesRoute, :show
|
||||
post "/preferences", Routes::PreferencesRoute, :update
|
||||
get "/toggle_theme", Routes::PreferencesRoute, :toggle_theme
|
||||
get "/data_control", Routes::PreferencesRoute, :data_control
|
||||
post "/data_control", Routes::PreferencesRoute, :update_data_control
|
||||
|
||||
# User account management
|
||||
get "/change_password", Routes::Account, :get_change_password
|
||||
post "/change_password", Routes::Account, :post_change_password
|
||||
get "/delete_account", Routes::Account, :get_delete
|
||||
post "/delete_account", Routes::Account, :post_delete
|
||||
get "/clear_watch_history", Routes::Account, :get_clear_history
|
||||
post "/clear_watch_history", Routes::Account, :post_clear_history
|
||||
get "/authorize_token", Routes::Account, :get_authorize_token
|
||||
post "/authorize_token", Routes::Account, :post_authorize_token
|
||||
get "/token_manager", Routes::Account, :token_manager
|
||||
post "/token_ajax", Routes::Account, :token_ajax
|
||||
post "/subscription_ajax", Routes::Subscriptions, :toggle_subscription
|
||||
get "/subscription_manager", Routes::Subscriptions, :subscription_manager
|
||||
end
|
||||
|
||||
def register_iv_playlist_routes
|
||||
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
|
||||
get "/api/v1/videos/:id", {{namespace}}::Videos, :videos
|
||||
get "/api/v1/storyboards/:id", {{namespace}}::Videos, :storyboards
|
||||
get "/api/v1/captions/:id", {{namespace}}::Videos, :captions
|
||||
get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations
|
||||
get "/api/v1/comments/:id", {{namespace}}::Videos, :comments
|
||||
|
||||
# Feeds
|
||||
get "/api/v1/trending", {{namespace}}::Feeds, :trending
|
||||
get "/api/v1/popular", {{namespace}}::Feeds, :popular
|
||||
|
||||
# Channels
|
||||
get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home
|
||||
{% for route in {"videos", "latest", "playlists", "community", "search"} %}
|
||||
get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}}
|
||||
get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}}
|
||||
{% end %}
|
||||
|
||||
# 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community
|
||||
get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect
|
||||
get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect
|
||||
|
||||
# Search
|
||||
get "/api/v1/search", {{namespace}}::Search, :search
|
||||
get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions
|
||||
|
||||
# Authenticated
|
||||
|
||||
# The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr
|
||||
#
|
||||
# Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
|
||||
# Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
|
||||
|
||||
get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences
|
||||
post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences
|
||||
|
||||
get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed
|
||||
|
||||
get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions
|
||||
post "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :subscribe_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
|
||||
|
||||
get "/api/v1/auth/tokens", {{namespace}}::Authenticated, :get_tokens
|
||||
post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token
|
||||
post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token
|
||||
|
||||
get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
|
||||
post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
|
||||
|
||||
# Misc
|
||||
get "/api/v1/stats", {{namespace}}::Misc, :stats
|
||||
get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist
|
||||
get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist
|
||||
get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes
|
||||
{% end %}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -57,7 +57,7 @@ module Invidious::Search
|
|||
# Get the page number (also common to all search types)
|
||||
@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?
|
||||
return if self.empty_raw_query?
|
||||
|
||||
|
@ -127,6 +127,16 @@ module Invidious::Search
|
|||
return items
|
||||
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
|
||||
private def unnest_items(all_items) : Array(SearchItem)
|
||||
items = [] of SearchItem
|
||||
|
|
|
@ -19,7 +19,7 @@ struct Invidious::User
|
|||
expires: Time.utc + 2.years,
|
||||
secure: SECURE,
|
||||
http_only: true,
|
||||
samesite: HTTP::Cookie::SameSite::Strict
|
||||
samesite: HTTP::Cookie::SameSite::Lax
|
||||
)
|
||||
end
|
||||
|
||||
|
@ -34,7 +34,7 @@ struct Invidious::User
|
|||
expires: Time.utc + 2.years,
|
||||
secure: SECURE,
|
||||
http_only: false,
|
||||
samesite: HTTP::Cookie::SameSite::Strict
|
||||
samesite: HTTP::Cookie::SameSite::Lax
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -323,7 +323,7 @@ struct Video
|
|||
|
||||
json.field "viewCount", self.views
|
||||
json.field "likeCount", self.likes
|
||||
json.field "dislikeCount", self.dislikes
|
||||
json.field "dislikeCount", 0_i64
|
||||
|
||||
json.field "paid", self.paid
|
||||
json.field "premium", self.premium
|
||||
|
@ -354,7 +354,7 @@ struct Video
|
|||
|
||||
json.field "lengthSeconds", self.length_seconds
|
||||
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 "liveNow", self.live_now
|
||||
json.field "isUpcoming", self.is_upcoming
|
||||
|
@ -556,11 +556,6 @@ struct Video
|
|||
info["dislikes"]?.try &.as_i64 || 0_i64
|
||||
end
|
||||
|
||||
def average_rating : Float64
|
||||
# (likes / (likes + dislikes) * 4 + 1)
|
||||
info["videoDetails"]["averageRating"]?.try { |t| t.as_f? || t.as_i64?.try &.to_f64 }.try &.round(4) || 0.0
|
||||
end
|
||||
|
||||
def published : Time
|
||||
info
|
||||
.dig?("microformat", "playerMicroformatRenderer", "publishDate")
|
||||
|
@ -813,14 +808,6 @@ struct Video
|
|||
return info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s
|
||||
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?
|
||||
info["reason"]?.try &.as_s
|
||||
end
|
||||
|
@ -899,36 +886,46 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
|
|||
end
|
||||
|
||||
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)
|
||||
if context_screen == "embed"
|
||||
client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed
|
||||
end
|
||||
|
||||
# Fetch data from the player endpoint
|
||||
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")
|
||||
reason = subreason.try &.[]?("simpleText").try &.as_s
|
||||
reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("")
|
||||
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
|
||||
|
||||
params["shortDescription"] = player_response.dig?("videoDetails", "shortDescription") || JSON::Any.new(nil)
|
||||
|
||||
# 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": ""})
|
||||
player_response = player_response.merge(next_response)
|
||||
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
|
||||
# maybe fix throttling issues (#2194).See for the explanation about the decrypted URLs:
|
||||
# https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
|
||||
if !params["reason"]?
|
||||
if reason.nil?
|
||||
if context_screen == "embed"
|
||||
client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed
|
||||
else
|
||||
|
@ -946,10 +943,15 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
|
|||
end
|
||||
end
|
||||
|
||||
# TODO: clean that up
|
||||
{"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f|
|
||||
params[f] = player_response[f] if player_response[f]?
|
||||
end
|
||||
|
||||
return params
|
||||
end
|
||||
|
||||
def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any)
|
||||
# Top level elements
|
||||
|
||||
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
|
||||
|
||||
params["relatedVideos"] = JSON::Any.new(related)
|
||||
|
||||
# Likes/dislikes
|
||||
# Likes
|
||||
|
||||
toplevel_buttons = video_primary_renderer
|
||||
.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.debug("extract_video_info: Likes count is #{likes}") if likes
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
short_description = player_response.dig?("videoDetails", "shortDescription")
|
||||
|
||||
description_html = video_secondary_renderer.try &.dig?("description", "runs")
|
||||
.try &.as_a.try { |t| content_to_comment_html(t, video_id) }
|
||||
|
||||
params["descriptionHtml"] = JSON::Any.new(description_html || "<p></p>")
|
||||
|
||||
# Video metadata
|
||||
|
||||
metadata = video_secondary_renderer
|
||||
.try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows")
|
||||
.try &.as_a
|
||||
|
||||
params["genre"] = params["microformat"]?.try &.["playerMicroformatRenderer"]?.try &.["category"]? || JSON::Any.new("")
|
||||
params["genreUrl"] = JSON::Any.new(nil)
|
||||
genre = player_response.dig?("microformat", "playerMicroformatRenderer", "category")
|
||||
genre_ucid = nil
|
||||
license = nil
|
||||
|
||||
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)
|
||||
|
||||
if title.try &.== "Category"
|
||||
if metadata_title == "Category"
|
||||
contents = contents.try &.dig?("runs", 0)
|
||||
|
||||
params["genre"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "")
|
||||
params["genreUcid"] = JSON::Any.new(contents.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]?
|
||||
.try &.["browseId"]?.try &.as_s || "")
|
||||
elsif title.try &.== "License"
|
||||
contents = contents.try &.["runs"]?
|
||||
.try &.as_a[0]?
|
||||
|
||||
params["license"] = 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 || "")
|
||||
genre = contents.try &.["text"]?
|
||||
genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId")
|
||||
elsif metadata_title == "License"
|
||||
license = contents.try &.dig?("runs", 0, "text")
|
||||
elsif metadata_title == "Licensed to YouTube by"
|
||||
license = contents.try &.["simpleText"]?
|
||||
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")
|
||||
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"]?)
|
||||
params["authorVerified"] = JSON::Any.new(author_verified)
|
||||
|
||||
subs_text = author_info["subscriberCountText"]?
|
||||
.try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }
|
||||
.try &.as_s.split(" ", 2)[0]
|
||||
|
||||
params["subCountText"] = JSON::Any.new(subs_text || "-")
|
||||
end
|
||||
|
||||
# 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
|
||||
end
|
||||
|
||||
|
@ -1158,7 +1142,11 @@ def fetch_video(id, region)
|
|||
end
|
||||
|
||||
if reason = info["reason"]?
|
||||
raise InfoException.new(reason.as_s || "")
|
||||
if reason == "Video unavailable"
|
||||
raise NotFoundException.new(reason.as_s || "")
|
||||
else
|
||||
raise InfoException.new(reason.as_s || "")
|
||||
end
|
||||
end
|
||||
|
||||
video = Video.new({
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<a href="/channel/<%= item.ucid %>">
|
||||
<% if !env.get("preferences").as(Preferences).thin_mode %>
|
||||
<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>
|
||||
<% end %>
|
||||
<p dir="auto"><%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <i class="icon ion ion-md-checkmark-circle"></i><% end %></p>
|
||||
|
@ -23,7 +23,7 @@
|
|||
<a style="width:100%" href="<%= url %>">
|
||||
<% if !env.get("preferences").as(Preferences).thin_mode %>
|
||||
<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>
|
||||
</div>
|
||||
<% end %>
|
||||
|
@ -36,7 +36,7 @@
|
|||
<a href="/watch?v=<%= item.id %>&list=<%= item.rdid %>">
|
||||
<% if !env.get("preferences").as(Preferences).thin_mode %>
|
||||
<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 %>
|
||||
<p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
|
||||
<% end %>
|
||||
|
@ -51,16 +51,13 @@
|
|||
<a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&index=<%= item.index %>">
|
||||
<% if !env.get("preferences").as(Preferences).thin_mode %>
|
||||
<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") %>
|
||||
<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) || "") %>">
|
||||
<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">
|
||||
<i class="icon ion-md-trash"></i>
|
||||
</button>
|
||||
</a>
|
||||
<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>
|
||||
</p>
|
||||
</form>
|
||||
<% end %>
|
||||
|
@ -103,29 +100,21 @@
|
|||
<a style="width:100%" href="/watch?v=<%= item.id %>">
|
||||
<% if !env.get("preferences").as(Preferences).thin_mode %>
|
||||
<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" %>
|
||||
<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) || "") %>">
|
||||
<p class="watched">
|
||||
<a data-onclick="mark_watched" data-id="<%= item.id %>" href="javascript:void(0)">
|
||||
<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>
|
||||
</button>
|
||||
</a>
|
||||
<button type="submit" style="all:unset" data-onclick="mark_watched" data-id="<%= item.id %>">
|
||||
<i data-mouse="switch_classes" data-switch-classes="ion-ios-eye-off,ion-ios-eye" class="icon ion-ios-eye"></i>
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
<% 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">
|
||||
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
||||
<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">
|
||||
<i class="icon ion-md-add"></i>
|
||||
</button>
|
||||
</a>
|
||||
<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>
|
||||
</p>
|
||||
</form>
|
||||
<% end %>
|
||||
|
|
|
@ -7,14 +7,25 @@
|
|||
<source src="<%= URI.parse(hlsvp).request_target %><% if params.local %>?local=true<% end %>" type="application/x-mpegURL" label="livestream">
|
||||
<% else %>
|
||||
<% 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 += "&local=true" if params.local
|
||||
|
||||
bitrate = fmt["bitrate"]
|
||||
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 %>">
|
||||
<% if !params.local && !CONFIG.disabled?("local") %>
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
|
||||
<link rel="stylesheet" href="/css/embed.css?v=<%= ASSET_COMMIT %>">
|
||||
<title><%= HTML.escape(video.title) %> - Invidious</title>
|
||||
<script src="/js/_helpers.js?v=<%= ASSET_COMMIT %>"></script>
|
||||
</head>
|
||||
|
||||
<body class="dark-theme">
|
||||
|
|
|
@ -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">
|
||||
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
|
||||
<p class="watched">
|
||||
<a data-onclick="mark_unwatched" data-id="<%= item %>" href="javascript:void(0)">
|
||||
<button type="submit" style="all:unset"><i class="icon ion-md-trash"></i></button>
|
||||
</a>
|
||||
<button type="submit" style="all:unset" data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -3,16 +3,6 @@
|
|||
<link rel="stylesheet" href="/css/search.css?v=<%= ASSET_COMMIT %>">
|
||||
<% 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 -->
|
||||
<%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
|
||||
<hr/>
|
||||
|
|
|
@ -68,7 +68,7 @@
|
|||
</div>
|
||||
<% if env.get("preferences").as(Preferences).show_nick %>
|
||||
<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>
|
||||
<% end %>
|
||||
<div class="pure-u-1-4">
|
||||
|
|
|
@ -39,9 +39,7 @@
|
|||
<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">
|
||||
<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" value="<%= translate(locale, "unsubscribe") %>">
|
||||
</a>
|
||||
<input style="all:unset" type="submit" data-onclick="remove_subscription" data-ucid="<%= channel.id %>" value="<%= translate(locale, "unsubscribe") %>">
|
||||
</form>
|
||||
</h3>
|
||||
</div>
|
||||
|
|
|
@ -31,9 +31,7 @@
|
|||
<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">
|
||||
<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" value="<%= translate(locale, "revoke") %>">
|
||||
</a>
|
||||
<input style="all:unset" type="submit" data-onclick="revoke_token" data-session="<%= token[:session] %>" value="<%= translate(locale, "revoke") %>">
|
||||
</form>
|
||||
</h3>
|
||||
</div>
|
||||
|
|
|
@ -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="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: ") %>
|
||||
<% if !video.genre_url %>
|
||||
<%= 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>
|
||||
<% end %>
|
||||
<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="rating"></p>
|
||||
<p id="engagement"><%= translate(locale, "Engagement: ") %><%= video.engagement %>%</p>
|
||||
<p id="wilson" style="display: none; visibility: hidden;"></p>
|
||||
<p id="rating" style="display: none; visibility: hidden;"></p>
|
||||
<p id="engagement" style="display: none; visibility: hidden;"></p>
|
||||
<% if video.allowed_regions.size != REGIONS.size %>
|
||||
<p id="allowed_regions">
|
||||
<% if video.allowed_regions.size < REGIONS.size // 2 %>
|
||||
|
|
|
@ -417,7 +417,7 @@ private module Extractors
|
|||
# {"tabRenderer": {
|
||||
# "endpoint": {...}
|
||||
# "title": "Playlists",
|
||||
# "selected": true,
|
||||
# "selected": true, # Is nil unless tab is selected
|
||||
# "content": {...},
|
||||
# ...
|
||||
# }}
|
||||
|
@ -435,20 +435,22 @@ private module Extractors
|
|||
raw_items = [] of JSON::Any
|
||||
content = extract_selected_tab(target["tabs"])["content"]
|
||||
|
||||
content["sectionListRenderer"]["contents"].as_a.each do |renderer_container|
|
||||
renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0]
|
||||
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]
|
||||
|
||||
# Category extraction
|
||||
if items_container = renderer_container_contents["shelfRenderer"]?
|
||||
raw_items << renderer_container_contents
|
||||
next
|
||||
elsif items_container = renderer_container_contents["gridRenderer"]?
|
||||
else
|
||||
items_container = renderer_container_contents
|
||||
end
|
||||
# Category extraction
|
||||
if items_container = renderer_container_contents["shelfRenderer"]?
|
||||
raw_items << renderer_container_contents
|
||||
next
|
||||
elsif items_container = renderer_container_contents["gridRenderer"]?
|
||||
else
|
||||
items_container = renderer_container_contents
|
||||
end
|
||||
|
||||
items_container["items"]?.try &.as_a.each do |item|
|
||||
raw_items << item
|
||||
items_container["items"]?.try &.as_a.each do |item|
|
||||
raw_items << item
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -84,7 +84,7 @@ end
|
|||
|
||||
def extract_selected_tab(tabs)
|
||||
# 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
|
||||
|
||||
def fetch_continuation_token(items : Array(JSON::Any))
|
||||
|
|
|
@ -5,15 +5,28 @@
|
|||
module YoutubeAPI
|
||||
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
|
||||
enum ClientType
|
||||
Web
|
||||
WebEmbeddedPlayer
|
||||
WebMobile
|
||||
WebScreenEmbed
|
||||
|
||||
Android
|
||||
AndroidEmbeddedPlayer
|
||||
AndroidScreenEmbed
|
||||
|
||||
IOS
|
||||
IOSEmbedded
|
||||
IOSMusic
|
||||
|
||||
TvHtml5
|
||||
TvHtml5ScreenEmbed
|
||||
end
|
||||
|
||||
|
@ -21,50 +34,78 @@ module YoutubeAPI
|
|||
HARDCODED_CLIENTS = {
|
||||
ClientType::Web => {
|
||||
name: "WEB",
|
||||
version: "2.20210721.00.00",
|
||||
api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
|
||||
version: "2.20220804.07.00",
|
||||
api_key: DEFAULT_API_KEY,
|
||||
screen: "WATCH_FULL_SCREEN",
|
||||
},
|
||||
ClientType::WebEmbeddedPlayer => {
|
||||
name: "WEB_EMBEDDED_PLAYER", # 56
|
||||
version: "1.20210721.1.0",
|
||||
api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
|
||||
version: "1.20220803.01.00",
|
||||
api_key: DEFAULT_API_KEY,
|
||||
screen: "EMBED",
|
||||
},
|
||||
ClientType::WebMobile => {
|
||||
name: "MWEB",
|
||||
version: "2.20210726.08.00",
|
||||
api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
|
||||
screen: "", # None
|
||||
version: "2.20220805.01.00",
|
||||
api_key: DEFAULT_API_KEY,
|
||||
},
|
||||
ClientType::WebScreenEmbed => {
|
||||
name: "WEB",
|
||||
version: "2.20210721.00.00",
|
||||
api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
|
||||
version: "2.20220804.00.00",
|
||||
api_key: DEFAULT_API_KEY,
|
||||
screen: "EMBED",
|
||||
},
|
||||
|
||||
# Android
|
||||
|
||||
ClientType::Android => {
|
||||
name: "ANDROID",
|
||||
version: "16.20",
|
||||
api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w",
|
||||
screen: "", # ??
|
||||
name: "ANDROID",
|
||||
version: ANDROID_APP_VERSION,
|
||||
api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w",
|
||||
android_sdk_version: ANDROID_SDK_VERSION,
|
||||
},
|
||||
ClientType::AndroidEmbeddedPlayer => {
|
||||
name: "ANDROID_EMBEDDED_PLAYER", # 55
|
||||
version: "16.20",
|
||||
api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
|
||||
screen: "", # None?
|
||||
version: ANDROID_APP_VERSION,
|
||||
api_key: DEFAULT_API_KEY,
|
||||
},
|
||||
ClientType::AndroidScreenEmbed => {
|
||||
name: "ANDROID", # 3
|
||||
version: "16.20",
|
||||
api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
|
||||
screen: "EMBED",
|
||||
name: "ANDROID", # 3
|
||||
version: ANDROID_APP_VERSION,
|
||||
api_key: DEFAULT_API_KEY,
|
||||
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 => {
|
||||
name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
|
||||
name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", # 85
|
||||
version: "2.0",
|
||||
api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
|
||||
api_key: DEFAULT_API_KEY,
|
||||
screen: "EMBED",
|
||||
},
|
||||
}
|
||||
|
@ -131,7 +172,11 @@ module YoutubeAPI
|
|||
|
||||
# :ditto:
|
||||
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
|
||||
|
||||
# Convert to string, for logging purposes
|
||||
|
@ -163,7 +208,7 @@ module YoutubeAPI
|
|||
"gl" => client_config.region || "US", # Can't be empty!
|
||||
"clientName" => client_config.name,
|
||||
"clientVersion" => client_config.version,
|
||||
},
|
||||
} of String => String | Int64,
|
||||
}
|
||||
|
||||
# Add some more context if it exists in the client definitions
|
||||
|
@ -174,7 +219,11 @@ module YoutubeAPI
|
|||
if client_config.screen == "EMBED"
|
||||
client_context["thirdParty"] = {
|
||||
"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
|
||||
|
||||
return client_context
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue