diff options
47 files changed, 630 insertions, 469 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bb4c491..b99ecf18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: crystal: - 1.0.0 - 1.1.1 + - 1.2.0 include: - crystal: nightly stable: false @@ -48,7 +49,7 @@ jobs: - uses: actions/checkout@v2 - name: Install Crystal - uses: oprypin/install-crystal@v1.2.4 + uses: crystal-lang/install-crystal@v1.5.3 with: crystal: ${{ matrix.crystal }} @@ -3,7 +3,7 @@ <h1>Invidious</h1> <a href="https://www.gnu.org/licenses/agpl-3.0.en.html"> - <img alt="License: AGPLv3+" src="https://shields.io/badge/License-AGPL%20v3+-blue.svg"> + <img alt="License: AGPLv3" src="https://shields.io/badge/License-AGPL%20v3-blue.svg"> </a> <a href="https://github.com/iv-org/invidious/actions"> <img alt="Build Status" src="https://github.com/iv-org/invidious/workflows/Invidious%20CI/badge.svg"> @@ -58,7 +58,7 @@ - No JavaScript required - Light/Dark themes - Customizable homepage -- Subscriptions independant from Google +- Subscriptions independent from Google - Notifications for all subscribed channels - Audio-only mode (with background play on mobile) - Support for Reddit comments diff --git a/assets/css/player.css b/assets/css/player.css index 656fb48c..120fd2f8 100644 --- a/assets/css/player.css +++ b/assets/css/player.css @@ -218,6 +218,10 @@ video.video-js { #player-container { position: relative; + padding-left: 0; + padding-right: 0; + margin-left: 1em; + margin-right: 1em; padding-bottom: 82vh; height: 0; } diff --git a/assets/js/watch.js b/assets/js/watch.js index 3909edd4..1579abf4 100644 --- a/assets/js/watch.js +++ b/assets/js/watch.js @@ -149,6 +149,8 @@ function get_playlist(plid, retries) { if (xhr.readyState == 4) { if (xhr.status == 200) { playlist.innerHTML = xhr.response.playlistHtml; + var nextVideo = document.getElementById(xhr.response.nextVideo); + nextVideo.parentNode.parentNode.scrollTop = nextVideo.offsetTop; if (xhr.response.nextVideo) { player.on('ended', function () { diff --git a/config/migrate-scripts/migrate-db-17cf077.sh b/config/migrate-scripts/migrate-db-17cf077.sh index 5e5bb214..1597311d 100755 --- a/config/migrate-scripts/migrate-db-17cf077.sh +++ b/config/migrate-scripts/migrate-db-17cf077.sh @@ -1,4 +1,7 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE channels ADD COLUMN subscribed bool;" -psql invidious kemal -c "UPDATE channels SET subscribed = false;" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels ADD COLUMN subscribed bool;" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channels SET subscribed = false;" diff --git a/config/migrate-scripts/migrate-db-1c8075c.sh b/config/migrate-scripts/migrate-db-1c8075c.sh index 63954397..b6f7b89c 100755 --- a/config/migrate-scripts/migrate-db-1c8075c.sh +++ b/config/migrate-scripts/migrate-db-1c8075c.sh @@ -1,7 +1,10 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE channel_videos DROP COLUMN live_now CASCADE" -psql invidious kemal -c "ALTER TABLE channel_videos DROP COLUMN premiere_timestamp CASCADE" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious -psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN live_now bool" -psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos DROP COLUMN live_now CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos DROP COLUMN premiere_timestamp CASCADE" + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN live_now bool" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz" diff --git a/config/migrate-scripts/migrate-db-1eca969.sh b/config/migrate-scripts/migrate-db-1eca969.sh index f840d924..770a76d3 100755 --- a/config/migrate-scripts/migrate-db-1eca969.sh +++ b/config/migrate-scripts/migrate-db-1eca969.sh @@ -1,19 +1,22 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN title CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN views CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN likes CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN dislikes CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN wilson_score CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN published CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN description CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN language CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN author CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN ucid CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN allowed_regions CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN is_family_friendly CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN genre CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN genre_url CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN license CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN sub_count_text CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN author_thumbnail CASCADE" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN title CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN views CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN likes CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN dislikes CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN wilson_score CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN published CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN description CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN language CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN author CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN ucid CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN allowed_regions CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN is_family_friendly CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN genre CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN genre_url CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN license CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN sub_count_text CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN author_thumbnail CASCADE" diff --git a/config/migrate-scripts/migrate-db-30e6d29.sh b/config/migrate-scripts/migrate-db-30e6d29.sh index 3a377461..9d0b2d30 100755 --- a/config/migrate-scripts/migrate-db-30e6d29.sh +++ b/config/migrate-scripts/migrate-db-30e6d29.sh @@ -1,4 +1,7 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE channels ADD COLUMN deleted bool;" -psql invidious kemal -c "UPDATE channels SET deleted = false;" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels ADD COLUMN deleted bool;" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channels SET deleted = false;" diff --git a/config/migrate-scripts/migrate-db-3646395.sh b/config/migrate-scripts/migrate-db-3646395.sh index 830b85f2..b6efe239 100755 --- a/config/migrate-scripts/migrate-db-3646395.sh +++ b/config/migrate-scripts/migrate-db-3646395.sh @@ -1,5 +1,8 @@ #!/bin/sh -psql invidious kemal < config/sql/session_ids.sql -psql invidious kemal -c "INSERT INTO session_ids (SELECT unnest(id), email, CURRENT_TIMESTAMP FROM users) ON CONFLICT (id) DO NOTHING" -psql invidious kemal -c "ALTER TABLE users DROP COLUMN id" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" < config/sql/session_ids.sql +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "INSERT INTO session_ids (SELECT unnest(id), email, CURRENT_TIMESTAMP FROM users) ON CONFLICT (id) DO NOTHING" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE users DROP COLUMN id" diff --git a/config/migrate-scripts/migrate-db-3bcb98e.sh b/config/migrate-scripts/migrate-db-3bcb98e.sh index cb9fa6ab..444f65ed 100755 --- a/config/migrate-scripts/migrate-db-3bcb98e.sh +++ b/config/migrate-scripts/migrate-db-3bcb98e.sh @@ -1,3 +1,6 @@ #!/bin/sh -psql invidious kemal < config/sql/annotations.sql +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" < config/sql/annotations.sql diff --git a/config/migrate-scripts/migrate-db-52cb239.sh b/config/migrate-scripts/migrate-db-52cb239.sh index db8efeab..da977d97 100755 --- a/config/migrate-scripts/migrate-db-52cb239.sh +++ b/config/migrate-scripts/migrate-db-52cb239.sh @@ -1,3 +1,6 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN views bigint;" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN views bigint;" diff --git a/config/migrate-scripts/migrate-db-6e51189.sh b/config/migrate-scripts/migrate-db-6e51189.sh index ce728118..9132d3d7 100755 --- a/config/migrate-scripts/migrate-db-6e51189.sh +++ b/config/migrate-scripts/migrate-db-6e51189.sh @@ -1,4 +1,7 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN live_now bool;" -psql invidious kemal -c "UPDATE channel_videos SET live_now = false;" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN live_now bool;" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channel_videos SET live_now = false;" diff --git a/config/migrate-scripts/migrate-db-701b5ea.sh b/config/migrate-scripts/migrate-db-701b5ea.sh index 429531a2..46d60c00 100755 --- a/config/migrate-scripts/migrate-db-701b5ea.sh +++ b/config/migrate-scripts/migrate-db-701b5ea.sh @@ -1,3 +1,6 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE users ADD COLUMN feed_needs_update boolean" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE users ADD COLUMN feed_needs_update boolean" diff --git a/config/migrate-scripts/migrate-db-88b7097.sh b/config/migrate-scripts/migrate-db-88b7097.sh index 6bde8399..146ee92d 100755 --- a/config/migrate-scripts/migrate-db-88b7097.sh +++ b/config/migrate-scripts/migrate-db-88b7097.sh @@ -1,3 +1,6 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz;" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz;" diff --git a/config/migrate-scripts/migrate-db-8e884fe.sh b/config/migrate-scripts/migrate-db-8e884fe.sh index 1c8dafd1..0d5de828 100755 --- a/config/migrate-scripts/migrate-db-8e884fe.sh +++ b/config/migrate-scripts/migrate-db-8e884fe.sh @@ -1,5 +1,8 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE channels DROP COLUMN subscribed" -psql invidious kemal -c "ALTER TABLE channels ADD COLUMN subscribed timestamptz" -psql invidious kemal -c "UPDATE channels SET subscribed = '2019-01-01 00:00:00+00'" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels DROP COLUMN subscribed" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels ADD COLUMN subscribed timestamptz" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channels SET subscribed = '2019-01-01 00:00:00+00'" diff --git a/docker-compose.yml b/docker-compose.yml index b94f9813..ea1d2993 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: POSTGRES_PASSWORD: kemal POSTGRES_USER: kemal healthcheck: - test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"] + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER $$POSTGRES_DB"] invidious: build: context: . diff --git a/docker/Dockerfile.arm64 b/docker/Dockerfile.arm64 index 063ba6d2..193ed09d 100644 --- a/docker/Dockerfile.arm64 +++ b/docker/Dockerfile.arm64 @@ -1,5 +1,5 @@ FROM alpine:edge AS builder -RUN apk add --no-cache 'crystal=1.1.1-r0' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev +RUN apk add --no-cache 'crystal=1.1.1-r1' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev ARG release diff --git a/docker/init-invidious-db.sh b/docker/init-invidious-db.sh index cc0e1c3f..22b4cc5f 100755 --- a/docker/init-invidious-db.sh +++ b/docker/init-invidious-db.sh @@ -1,10 +1,6 @@ #!/bin/bash set -eou pipefail -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL - CREATE USER postgres; -EOSQL - psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channels.sql psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/videos.sql psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channel_videos.sql diff --git a/locales/ar.json b/locales/ar.json index 4f7f1e2c..50cdbe80 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -145,7 +145,7 @@ }, "search": "بحث", "Log out": "تسجيل الخروج", - "Released under the AGPLv3 on Github.": "تم إصداره بموجب AGPLv3 على Github.", + "Released under the AGPLv3 on Github.": "صدر تحت AGPLv3 على Github.", "Source available here.": "الأكواد متوفرة هنا.", "View JavaScript license information.": "مشاهدة معلومات حول تراخيص الجافاسكريبت.", "View privacy policy.": "عرض سياسة الخصوصية.", @@ -382,7 +382,7 @@ "News": "الأخبار", "Movies": "الأفلام", "Download": "نزّل", - "Download as: ": "نزله كـ:. ", + "Download as: ": "نزله ك:. ", "%A %B %-d, %Y": "%A %-d %B %Y", "(edited)": "(تم تعديلة)", "YouTube comment permalink": "رابط التعليق على اليوتيوب", @@ -425,5 +425,12 @@ "next_steps_error_message_refresh": "تحديث", "next_steps_error_message_go_to_youtube": "انتقل إلى يوتيوب", "short": "قصير (< 4 دقائق)", - "long": "طويل (> 20 دقيقة)" + "long": "طويل (> 20 دقيقة)", + "footer_source_code": "شفرة المصدر", + "footer_original_source_code": "شفرة المصدر الأصلية", + "footer_modfied_source_code": "شفرة المصدر المعدلة", + "adminprefs_modified_source_code_url_label": "URL إلى مستودع التعليمات البرمجية المصدرية المعدلة", + "footer_documentation": "التوثيق", + "footer_donate": "تبرّع: ", + "footer_donate_page": "تبرّع" } diff --git a/locales/en-US.json b/locales/en-US.json index 1fa1983d..230d96ad 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -72,7 +72,7 @@ "Player volume: ": "Player volume: ", "Default comments: ": "Default comments: ", "youtube": "YouTube", - "reddit": "reddit", + "reddit": "Reddit", "Default captions: ": "Default captions: ", "Fallback captions: ": "Fallback captions: ", "Show related videos: ": "Show related videos: ", @@ -426,7 +426,7 @@ "next_steps_error_message": "After which you should try to: ", "next_steps_error_message_refresh": "Refresh", "next_steps_error_message_go_to_youtube": "Go to YouTube", - "footer_donate": "Donate: ", + "footer_donate_page": "Donate", "footer_documentation": "Documentation", "footer_source_code": "Source code", "footer_original_source_code": "Original source code", diff --git a/locales/eo.json b/locales/eo.json index a00da6aa..054b8dd6 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -425,5 +425,12 @@ "next_steps_error_message_refresh": "Reŝargi", "next_steps_error_message_go_to_youtube": "Iri al JuTubo", "long": "Longa (> 20 minutos)", - "short": "Mallonga (< 4 minutos)" + "short": "Mallonga (< 4 minutos)", + "footer_donate": "Doni: ", + "footer_documentation": "Dokumentaro", + "footer_source_code": "Fontkodo", + "adminprefs_modified_source_code_url_label": "URL al modifita deponejo de fontkodo", + "footer_modfied_source_code": "Modifita Fontkodo", + "footer_original_source_code": "Originala fontkodo", + "footer_donate_page": "Donaci" } diff --git a/locales/es.json b/locales/es.json index 3cbe9be4..1a1b6753 100644 --- a/locales/es.json +++ b/locales/es.json @@ -424,6 +424,13 @@ "next_steps_error_message": "Después de lo cual deberías intentar: ", "next_steps_error_message_refresh": "Recargar", "next_steps_error_message_go_to_youtube": "Ir a YouTube", - "short": "Corto (< minutos)", - "long": "Largo (> minutos)" + "short": "Corto (< 4 minutos)", + "long": "Largo (> 20 minutos)", + "footer_documentation": "Documentación", + "footer_original_source_code": "Código fuente original", + "adminprefs_modified_source_code_url_label": "URL al repositorio de código fuente modificado", + "footer_source_code": "Código fuente", + "footer_donate": "Donar: ", + "footer_modfied_source_code": "Código fuente modificado", + "footer_donate_page": "Donar" } diff --git a/locales/ja.json b/locales/ja.json index c4f78f96..4c2e692d 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -78,7 +78,7 @@ "Show related videos: ": "関連動画を表示: ", "Show annotations by default: ": "デフォルトでアノテーションを表示: ", "Automatically extend video description: ": "動画の説明文を自動的に拡張: ", - "Interactive 360 degree videos: ": "インタラクティブ360°動画: ", + "Interactive 360 degree videos: ": "対話的な360°動画: ", "Visual preferences": "外観設定", "Player style: ": "プレイヤースタイル: ", "Dark mode: ": "ダークモード: ", @@ -137,7 +137,7 @@ }, "Import/export": "インポート/エクスポート", "unsubscribe": "登録解除", - "revoke": "revoke", + "revoke": "取り消す", "Subscriptions": "登録チャンネル", "`x` unseen notifications": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個の未読通知", @@ -145,7 +145,7 @@ }, "search": "検索", "Log out": "ログアウト", - "Released under the AGPLv3 on Github.": "Github 上で AGPLv3 の下で公開されています", + "Released under the AGPLv3 on Github.": "Github 上で AGPLv3 の元で公開されています", "Source available here.": "ソースはここで閲覧可能です。", "View JavaScript license information.": "JavaScript ライセンス情報", "View privacy policy.": "プライバシーポリシー", @@ -423,5 +423,13 @@ "Current version: ": "現在のバージョン: ", "next_steps_error_message": "下記のものを試して下さい: ", "next_steps_error_message_refresh": "再読込", - "next_steps_error_message_go_to_youtube": "YouTubeへ" + "next_steps_error_message_go_to_youtube": "YouTubeへ", + "short": "4 分未満", + "footer_donate": "寄金: ", + "footer_documentation": "文書", + "footer_source_code": "ソースコード", + "footer_original_source_code": "ソースコード(元)", + "footer_modfied_source_code": "ソースコード(編集)", + "adminprefs_modified_source_code_url_label": "編集したソースコードのレポジトリーURL", + "long": "20 分以上" } diff --git a/locales/ko.json b/locales/ko.json index 16d4797e..27fdc683 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -423,5 +423,13 @@ "today": "오늘", "hour": "지난 1시간", "sort": "정렬기준", - "features": "기능별" + "features": "기능별", + "short": "4분 미만", + "long": "20분 초과", + "footer_donate": "후원: ", + "footer_documentation": "문서", + "footer_source_code": "소스 코드", + "footer_original_source_code": "원본 소스 코드", + "footer_modfied_source_code": "수정된 소스 코드", + "adminprefs_modified_source_code_url_label": "수정된 소스 코드 저장소의 URL" } diff --git a/locales/lt.json b/locales/lt.json index 4b2fa5aa..f1667156 100644 --- a/locales/lt.json +++ b/locales/lt.json @@ -72,7 +72,7 @@ "Player volume: ": "Grotuvo garsas: ", "Default comments: ": "Numatytieji komentarai: ", "youtube": "YouTube", - "reddit": "reddit", + "reddit": "Reddit", "Default captions: ": "Numatytieji subtitrai: ", "Fallback captions: ": "Atsarginiai subtitrai: ", "Show related videos: ": "Rodyti susijusius vaizdo įrašus: ", @@ -425,5 +425,12 @@ "next_steps_error_message_refresh": "Atnaujinti", "next_steps_error_message_go_to_youtube": "Eiti į YouTube", "short": "Trumpas (< 4 minučių)", - "long": "Ilgas (> 20 minučių)" + "long": "Ilgas (> 20 minučių)", + "footer_documentation": "Dokumentacija", + "footer_source_code": "Pirminis kodas", + "footer_donate": "Paaukoti: ", + "footer_original_source_code": "Pradinis pirminis kodas", + "adminprefs_modified_source_code_url_label": "URL į pakeisto pirminio kodo repozitoriją", + "footer_modfied_source_code": "Pakeistas pirminis kodas", + "footer_donate_page": "Paaukoti" } diff --git a/locales/nb-NO.json b/locales/nb-NO.json index 9e39a6c7..1a6dcb38 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -145,7 +145,7 @@ }, "search": "søk", "Log out": "Logg ut", - "Released under the AGPLv3 on Github.": "", + "Released under the AGPLv3 on Github.": "Tilgjengelig med AGPLv3-lisens på Github.", "Source available here.": "Kildekode tilgjengelig her.", "View JavaScript license information.": "Vis JavaScript-lisensinfo.", "View privacy policy.": "Vis personvernspraksis.", @@ -423,5 +423,13 @@ "Current version: ": "Gjeldende versjon: ", "next_steps_error_message": "Etterpå bør du prøve dette: ", "next_steps_error_message_refresh": "Gjenoppfrisk", - "next_steps_error_message_go_to_youtube": "Gå til YouTube" + "next_steps_error_message_go_to_youtube": "Gå til YouTube", + "long": "Lang (> 20 minutter)", + "footer_donate_page": "Doner", + "short": "Kort (< 4 minutter)", + "footer_documentation": "Dokumentasjon", + "footer_source_code": "Kildekode", + "footer_original_source_code": "Opprinnelig kildekode", + "footer_modfied_source_code": "Endret kildekode", + "adminprefs_modified_source_code_url_label": "Nettadresse til kodelager inneholdende endret kildekode" } diff --git a/locales/pt.json b/locales/pt.json index 66de7d10..918ab4c0 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -425,5 +425,11 @@ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscritores" }, "short": "Curto (< 4 minutos)", - "long": "Longo (> 20 minutos)" + "long": "Longo (> 20 minutos)", + "footer_source_code": "Código-fonte", + "footer_original_source_code": "Código-fonte original", + "adminprefs_modified_source_code_url_label": "URL do repositório do código-fonte alterado", + "footer_donate": "Fazer um donativo: ", + "footer_documentation": "Documentação", + "footer_modfied_source_code": "Código-fonte alterado" } diff --git a/locales/tr.json b/locales/tr.json index 8432e8a9..60236d2f 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -72,7 +72,7 @@ "Player volume: ": "Oynatıcı ses seviyesi: ", "Default comments: ": "Öntanımlı yorumlar: ", "youtube": "YouTube", - "reddit": "reddit", + "reddit": "Reddit", "Default captions: ": "Öntanımlı altyazılar: ", "Fallback captions: ": "Yedek altyazılar: ", "Show related videos: ": "İlgili videoları göster: ", @@ -425,5 +425,12 @@ "next_steps_error_message_refresh": "Yenile", "next_steps_error_message_go_to_youtube": "YouTube'a git", "short": "Kısa (4 dakikadan az)", - "long": "Uzun (20 dakikadan fazla)" + "long": "Uzun (20 dakikadan fazla)", + "footer_donate": "Bağış yap: ", + "footer_documentation": "Belgelendirme", + "footer_source_code": "Kaynak kodları", + "footer_original_source_code": "Orijinal kaynak kodları", + "footer_modfied_source_code": "Değiştirilmiş kaynak kodları", + "adminprefs_modified_source_code_url_label": "Değiştirilmiş kaynak kodları deposunun URL'si", + "footer_donate_page": "Bağış yap" } diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 1033e77e..db6da6a8 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -425,5 +425,12 @@ "next_steps_error_message_refresh": "刷新", "next_steps_error_message_go_to_youtube": "转到 YouTube", "short": "短(少于4分钟)", - "long": "长(多于 20 分钟)" + "long": "长(多于 20 分钟)", + "footer_donate": "捐赠: ", + "footer_documentation": "文档", + "footer_source_code": "源代码", + "footer_modfied_source_code": "修改的源代码", + "adminprefs_modified_source_code_url_label": "更改的源代码仓库网址", + "footer_original_source_code": "原始源代码", + "footer_donate_page": "捐赠" } diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 403221c4..22aaf643 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -72,7 +72,7 @@ "Player volume: ": "播放器音量: ", "Default comments: ": "預設留言: ", "youtube": "YouTube", - "reddit": "reddit", + "reddit": "Reddit", "Default captions: ": "預設字幕: ", "Fallback captions: ": "汰退字幕: ", "Show related videos: ": "顯示相關的影片: ", @@ -425,5 +425,12 @@ "next_steps_error_message_refresh": "重新整理", "next_steps_error_message_go_to_youtube": "到 YouTube", "short": "短(小於4分鐘)", - "long": "長(多於20分鐘)" + "long": "長(多於20分鐘)", + "footer_donate": "抖內: ", + "footer_documentation": "文件", + "footer_source_code": "原始碼", + "footer_original_source_code": "原本的原始碼", + "footer_modfied_source_code": "修改後的原始碼", + "adminprefs_modified_source_code_url_label": "修改後的原始碼倉庫 URL", + "footer_donate_page": "捐款" } diff --git a/src/invidious.cr b/src/invidious.cr index f8f0784a..570c33e6 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -27,6 +27,7 @@ require "yaml" require "compress/zip" require "protodec/utils" require "./invidious/helpers/*" +require "./invidious/yt_backend/*" require "./invidious/*" require "./invidious/channels/*" require "./invidious/user/*" @@ -390,6 +391,13 @@ end Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post {% 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() @@ -1274,194 +1282,6 @@ post "/api/v1/auth/notifications" do |env| create_notification_stream(env, topics, connection_channel) end -get "/ggpht/*" do |env| - url = env.request.path.lchop("/ggpht") - - headers = HTTP::Headers{":authority" => "yt3.ggpht.com"} - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - begin - YT_POOL.client &.get(url, headers) do |response| - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 - env.response.headers.delete("Transfer-Encoding") - break - end - - proxy_file(response, env) - end - rescue ex - end -end - -options "/sb/:authority/:id/:storyboard/:index" do |env| - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" - env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" -end - -get "/sb/:authority/:id/:storyboard/:index" do |env| - authority = env.params.url["authority"] - id = env.params.url["id"] - storyboard = env.params.url["storyboard"] - index = env.params.url["index"] - - url = "/sb/#{id}/#{storyboard}/#{index}?#{env.params.query}" - - headers = HTTP::Headers.new - - headers[":authority"] = "#{authority}.ytimg.com" - - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - begin - YT_POOL.client &.get(url, headers) do |response| - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Connection"] = "close" - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 - env.response.headers.delete("Transfer-Encoding") - break - end - - proxy_file(response, env) - end - rescue ex - end -end - -get "/s_p/:id/:name" do |env| - id = env.params.url["id"] - name = env.params.url["name"] - - url = env.request.resource - - headers = HTTP::Headers{":authority" => "i9.ytimg.com"} - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - begin - YT_POOL.client &.get(url, headers) do |response| - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 && response.status_code != 404 - env.response.headers.delete("Transfer-Encoding") - break - end - - proxy_file(response, env) - end - rescue ex - end -end - -get "/yts/img/:name" do |env| - headers = HTTP::Headers.new - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - begin - YT_POOL.client &.get(env.request.resource, headers) do |response| - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 && response.status_code != 404 - env.response.headers.delete("Transfer-Encoding") - break - end - - proxy_file(response, env) - end - rescue ex - end -end - -get "/vi/:id/:name" do |env| - id = env.params.url["id"] - name = env.params.url["name"] - - headers = HTTP::Headers{":authority" => "i.ytimg.com"} - - if name == "maxres.jpg" - build_thumbnails(id).each do |thumb| - if YT_POOL.client &.head("/vi/#{id}/#{thumb[:url]}.jpg", headers).status_code == 200 - name = thumb[:url] + ".jpg" - break - end - end - end - url = "/vi/#{id}/#{name}" - - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - begin - YT_POOL.client &.get(url, headers) do |response| - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 && response.status_code != 404 - env.response.headers.delete("Transfer-Encoding") - break - end - - proxy_file(response, env) - end - rescue ex - end -end - get "/Captcha" do |env| headers = HTTP::Headers{":authority" => "accounts.google.com"} response = YT_POOL.client &.get(env.request.resource, headers) diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index a5506b03..9c788253 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -329,7 +329,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML <div class="pure-g" style="width:100%"> <div class="channel-profile pure-u-4-24 pure-u-md-2-24"> - <img style="margin-right:1em;margin-top:1em;width:90%" src="#{author_thumbnail}"> + <img loading="lazy" style="margin-right:1em;margin-top:1em;width:90%" src="#{author_thumbnail}"> </div> <div class="pure-u-20-24 pure-u-md-22-24"> <p> @@ -349,7 +349,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML <div class="pure-g"> <div class="pure-u-1 pure-u-md-1-2"> - <img style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).request_target}"> + <img loading="lazy" style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).request_target}"> </div> </div> END_HTML @@ -410,7 +410,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML <span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}"> <div class="creator-heart"> - <img class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img> + <img loading="lazy" class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img> <div class="creator-heart-small-hearted"> <div class="icon ion-ios-heart creator-heart-small-container"></div> </div> diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 91377b0d..2e61d21f 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -60,43 +60,6 @@ def html_to_content(description_html : String) return description end -def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) - extracted = extract_items(initial_data, author_fallback, author_id_fallback) - - target = [] of SearchItem - extracted.each do |i| - if i.is_a?(Category) - i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video } - else - target << i - end - end - return target.select(&.is_a?(SearchVideo)).map(&.as(SearchVideo)) -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"] -end - -def fetch_continuation_token(items : Array(JSON::Any)) - # Fetches the continuation token from an array of items - return items.last["continuationItemRenderer"]? - .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s -end - -def fetch_continuation_token(initial_data : Hash(String, JSON::Any)) - # Fetches the continuation token from initial data - if initial_data["onResponseReceivedActions"]? - continuation_items = initial_data["onResponseReceivedActions"][0]["appendContinuationItemsAction"]["continuationItems"] - else - tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]) - continuation_items = tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["gridRenderer"]["items"] - end - - return fetch_continuation_token(continuation_items.as_a) -end - def check_enum(db, enum_name, struct_type = nil) return # TODO diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 61356555..a9798f0c 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -237,8 +237,15 @@ class Category def to_json(locale, json : JSON::Builder) json.object do + json.field "type", "category" json.field "title", self.title - json.field "contents", self.contents + json.field "contents" do + json.array do + self.contents.each do |item| + item.to_json(locale, json) + end + end + end end end diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index af5f553b..603b4e1f 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -1,70 +1,5 @@ -require "lsquic" require "db" -def add_yt_headers(request) - request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36" - request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" - request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" - request.headers["accept-language"] ||= "en-us,en;q=0.5" - return if request.resource.starts_with? "/sorry/index" - request.headers["x-youtube-client-name"] ||= "1" - request.headers["x-youtube-client-version"] ||= "2.20200609" - # Preserve original cookies and add new YT consent cookie for EU servers - request.headers["cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+" - if !CONFIG.cookies.empty? - request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" - end -end - -struct YoutubeConnectionPool - property! url : URI - property! capacity : Int32 - property! timeout : Float64 - property pool : DB::Pool(QUIC::Client | HTTP::Client) - - def initialize(url : URI, @capacity = 5, @timeout = 5.0, use_quic = true) - @url = url - @pool = build_pool(use_quic) - end - - def client(region = nil, &block) - if region - conn = make_client(url, region) - response = yield conn - else - conn = pool.checkout - begin - response = yield conn - rescue ex - conn.close - conn = QUIC::Client.new(url) - conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET - conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC - conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" - response = yield conn - ensure - pool.release(conn) - end - end - - response - end - - private def build_pool(use_quic) - DB::Pool(QUIC::Client | HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do - if use_quic - conn = QUIC::Client.new(url) - else - conn = HTTP::Client.new(url) - end - conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET - conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC - conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" - conn - end - end -end - # See http://www.evanmiller.org/how-not-to-sort-by-average-rating.html def ci_lower_bound(pos, n) if n == 0 @@ -85,37 +20,6 @@ def elapsed_text(elapsed) "#{(millis * 1000).round(2)}µs" end -def make_client(url : URI, region = nil) - # TODO: Migrate any applicable endpoints to QUIC - client = HTTPClient.new(url, OpenSSL::SSL::Context::Client.insecure) - client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC - client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" - client.read_timeout = 10.seconds - client.connect_timeout = 10.seconds - - if region - PROXY_LIST[region]?.try &.sample(40).each do |proxy| - begin - proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port]) - client.set_proxy(proxy) - break - rescue ex - end - end - end - - return client -end - -def make_client(url : URI, region = nil, &block) - client = make_client(url, region) - begin - yield client - ensure - client.close - end -end - def decode_length_seconds(string) length_seconds = string.gsub(/[^0-9:]/, "").split(":").map &.to_i length_seconds = [0] * (3 - length_seconds.size) + length_seconds diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr index 55b01174..63ea434f 100644 --- a/src/invidious/mixes.cr +++ b/src/invidious/mixes.cr @@ -97,7 +97,7 @@ def template_mix(mix) <li class="pure-menu-item"> <a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}"> <div class="thumbnail"> - <img class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg"> + <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg"> <p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p> </div> <p style="width:100%">#{video["title"]}</p> diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index f56cc2ea..7940dc1f 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -107,7 +107,7 @@ struct Playlist property updated : Time property thumbnail : String? - def to_json(offset, locale, json : JSON::Builder, continuation : String? = nil) + def to_json(offset, locale, json : JSON::Builder, video_id : String? = nil) json.object do json.field "type", "playlist" json.field "title", self.title @@ -142,7 +142,7 @@ struct Playlist json.field "videos" do json.array do - videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation) + videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, video_id: video_id) videos.each_with_index do |video, index| video.to_json(locale, json) end @@ -151,12 +151,12 @@ struct Playlist end end - def to_json(offset, locale, json : JSON::Builder? = nil, continuation : String? = nil) + def to_json(offset, locale, json : JSON::Builder? = nil, video_id : String? = nil) if json - to_json(offset, locale, json, continuation: continuation) + to_json(offset, locale, json, video_id: video_id) else JSON.build do |json| - to_json(offset, locale, json, continuation: continuation) + to_json(offset, locale, json, video_id: video_id) end end end @@ -196,7 +196,7 @@ struct InvidiousPlaylist end end - def to_json(offset, locale, json : JSON::Builder, continuation : String? = nil) + def to_json(offset, locale, json : JSON::Builder, video_id : String? = nil) json.object do json.field "type", "invidiousPlaylist" json.field "title", self.title @@ -218,11 +218,11 @@ struct InvidiousPlaylist json.field "videos" do json.array do if !offset || offset == 0 - index = PG_DB.query_one?("SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1", self.id, continuation, as: Int64) + index = PG_DB.query_one?("SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1", self.id, video_id, as: Int64) offset = self.index.index(index) || 0 end - videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation) + videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, video_id: video_id) videos.each_with_index do |video, index| video.to_json(locale, json, offset + index) end @@ -231,12 +231,12 @@ struct InvidiousPlaylist end end - def to_json(offset, locale, json : JSON::Builder? = nil, continuation : String? = nil) + def to_json(offset, locale, json : JSON::Builder? = nil, video_id : String? = nil) if json - to_json(offset, locale, json, continuation: continuation) + to_json(offset, locale, json, video_id: video_id) else JSON.build do |json| - to_json(offset, locale, json, continuation: continuation) + to_json(offset, locale, json, video_id: video_id) end end end @@ -426,7 +426,7 @@ def fetch_playlist(plid, locale) }) end -def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) +def get_playlist_videos(db, playlist, offset, locale = nil, video_id = nil) # Show empy playlist if requested page is out of range # (e.g, when a new playlist has been created, offset will be negative) if offset >= playlist.video_count || offset < 0 @@ -437,17 +437,26 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) db.query_all("SELECT * FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 100 OFFSET $3", playlist.id, playlist.index, offset, as: PlaylistVideo) else - if offset >= 100 - # Normalize offset to match youtube's behavior (100 videos chunck per request) - offset = (offset / 100).to_i64 * 100_i64 + if video_id + initial_data = YoutubeAPI.next({ + "videoId" => video_id, + "playlistId" => playlist.id, + }) + offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "playlist", "playlist", "currentIndex").try &.as_i || offset + end + + videos = [] of PlaylistVideo + until videos.size >= 200 || videos.size == playlist.video_count || offset >= playlist.video_count + # 100 videos per request ctoken = produce_playlist_continuation(playlist.id, offset) initial_data = YoutubeAPI.browse(ctoken) - else - initial_data = YoutubeAPI.browse("VL" + playlist.id, params: "") + videos += extract_playlist_videos(initial_data) + + offset += 100 end - return extract_playlist_videos(initial_data) + return videos end end @@ -523,10 +532,10 @@ def template_playlist(playlist) playlist["videos"].as_a.each do |video| html += <<-END_HTML - <li class="pure-menu-item"> - <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}"> + <li class="pure-menu-item" id="#{video["videoId"]}"> + <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}"> <div class="thumbnail"> - <img class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg"> + <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg"> <p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p> </div> <p style="width:100%">#{video["title"]}</p> diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index cf95bd9b..80b59fd5 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -24,7 +24,7 @@ module Invidious::Routes::API::V1::Misc offset ||= env.params.query["page"]?.try &.to_i?.try { |page| (page - 1) * 100 } offset ||= 0 - continuation = env.params.query["continuation"]? + video_id = env.params.query["continuation"]? format = env.params.query["format"]? format ||= "json" @@ -46,12 +46,32 @@ module Invidious::Routes::API::V1::Misc return error_json(404, "Playlist does not exist.") end - response = playlist.to_json(offset, locale, continuation: continuation) + # includes into the playlist a maximum of 20 videos, before the offset + if offset > 0 + lookback = offset < 50 ? offset : 50 + response = playlist.to_json(offset - lookback, locale) + json_response = JSON.parse(response) + else + # Unless the continuation is really the offset 0, it becomes expensive. + # It happens when the offset is not set. + # First we find the actual offset, and then we lookback + # it shouldn't happen often though + + lookback = 0 + response = playlist.to_json(offset, locale, video_id: video_id) + json_response = JSON.parse(response) + + if json_response["videos"].as_a[0]["index"] != offset + offset = json_response["videos"].as_a[0]["index"].as_i + lookback = offset < 50 ? offset : 50 + response = playlist.to_json(offset - lookback, locale) + json_response = JSON.parse(response) + end + end if format == "html" - response = JSON.parse(response) - playlist_html = template_playlist(response) - index, next_video = response["videos"].as_a.skip(1).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil} + playlist_html = template_playlist(json_response) + index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil} response = { "playlistHtml" => playlist_html, diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr new file mode 100644 index 00000000..bb924cdf --- /dev/null +++ b/src/invidious/routes/images.cr @@ -0,0 +1,191 @@ +module Invidious::Routes::Images + # Avatars, banners and other large image assets. + def self.ggpht(env) + url = env.request.path.lchop("/ggpht") + + headers = HTTP::Headers{":authority" => "yt3.ggpht.com"} + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + YT_POOL.client &.get(url, headers) do |response| + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 + env.response.headers.delete("Transfer-Encoding") + break + end + + proxy_file(response, env) + end + rescue ex + end + end + + def self.options_storyboard(env) + env.response.headers["Access-Control-Allow-Origin"] = "*" + env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" + env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" + end + + def self.get_storyboard(env) + authority = env.params.url["authority"] + id = env.params.url["id"] + storyboard = env.params.url["storyboard"] + index = env.params.url["index"] + + url = "/sb/#{id}/#{storyboard}/#{index}?#{env.params.query}" + + headers = HTTP::Headers.new + + headers[":authority"] = "#{authority}.ytimg.com" + + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + YT_POOL.client &.get(url, headers) do |response| + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Connection"] = "close" + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 + env.response.headers.delete("Transfer-Encoding") + break + end + + proxy_file(response, env) + end + rescue ex + end + end + + # ??? maybe also for storyboards? + def self.s_p_image(env) + id = env.params.url["id"] + name = env.params.url["name"] + + url = env.request.resource + + headers = HTTP::Headers{":authority" => "i9.ytimg.com"} + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + YT_POOL.client &.get(url, headers) do |response| + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 && response.status_code != 404 + env.response.headers.delete("Transfer-Encoding") + break + end + + proxy_file(response, env) + end + rescue ex + end + end + + def self.yts_image(env) + headers = HTTP::Headers.new + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + YT_POOL.client &.get(env.request.resource, headers) do |response| + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 && response.status_code != 404 + env.response.headers.delete("Transfer-Encoding") + break + end + + proxy_file(response, env) + end + rescue ex + end + end + + def self.thumbnails(env) + id = env.params.url["id"] + name = env.params.url["name"] + + headers = HTTP::Headers{":authority" => "i.ytimg.com"} + + if name == "maxres.jpg" + build_thumbnails(id).each do |thumb| + if YT_POOL.client &.head("/vi/#{id}/#{thumb[:url]}.jpg", headers).status_code == 200 + name = thumb[:url] + ".jpg" + break + end + end + end + url = "/vi/#{id}/#{name}" + + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + YT_POOL.client &.get(url, headers) do |response| + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 && response.status_code != 404 + env.response.headers.delete("Transfer-Encoding") + break + end + + proxy_file(response, env) + end + rescue ex + end + end +end diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index b15ae255..5788bf51 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -5,7 +5,7 @@ <a href="/channel/<%= item.ucid %>"> <% if !env.get("preferences").as(Preferences).thin_mode %> <center> - <img style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/> + <img loading="lazy" 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) %></p> @@ -23,7 +23,7 @@ <a style="width:100%" href="<%= url %>"> <% if !env.get("preferences").as(Preferences).thin_mode %> <div class="thumbnail"> - <img class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>"/> + <img loading="lazy" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>"/> <p class="length"><%= number_with_separator(item.video_count) %> videos</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 class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> + <img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> <% if item.length_seconds != 0 %> <p class="length"><%= recode_length_seconds(item.length_seconds) %></p> <% end %> @@ -48,10 +48,10 @@ <p dir="auto"><b><%= HTML.escape(item.author) %></b></p> </a> <% when PlaylistVideo %> - <a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>"> + <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 class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> + <img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> <% if plid = 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 %>&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) || "") %>"> @@ -114,7 +114,7 @@ <a style="width:100%" href="/watch?v=<%= item.id %>"> <% if !env.get("preferences").as(Preferences).thin_mode %> <div class="thumbnail"> - <img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> + <img loading="lazy" 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="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index b7020598..3fb2fe18 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -149,9 +149,7 @@ <div class="pure-u-1 pure-u-md-1-3"> <span> <i class="icon ion-ios-wallet"></i> - <%= translate(locale, "footer_donate") %> - <a href="bitcoin:bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr">BTC</a> / - <a href="monero:41nMCtek197boJtiUvGnTFYMatrLEpnpkQDmUECqx5Es2uX3sTKKWVhSL76suXsG3LXqkEJBrCZBgPTwJrDp1FrZJfycGPR">XMR</a> + <a href="https://invidious.io/donate/"><%= translate(locale, "footer_donate_page") %></a> </span> <span><%= translate(locale, "Current version: ") %> <%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %> @ <%= CURRENT_BRANCH %></span> </div> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 68e7eb80..398e25b6 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -303,7 +303,7 @@ we're going to need to do it here in order to allow for translations. <a href="/watch?v=<%= rv["id"] %>"> <% if !env.get("preferences").as(Preferences).thin_mode %> <div class="thumbnail"> - <img class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg"> + <img loading="lazy" class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg"> <p class="length"><%= recode_length_seconds(rv["length_seconds"]?.try &.to_i? || 0) %></p> </div> <% end %> diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr new file mode 100644 index 00000000..5ba2d73c --- /dev/null +++ b/src/invidious/yt_backend/connection_pool.cr @@ -0,0 +1,96 @@ +require "lsquic" + +def add_yt_headers(request) + request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36" + request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" + request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" + request.headers["accept-language"] ||= "en-us,en;q=0.5" + return if request.resource.starts_with? "/sorry/index" + request.headers["x-youtube-client-name"] ||= "1" + request.headers["x-youtube-client-version"] ||= "2.20200609" + # Preserve original cookies and add new YT consent cookie for EU servers + request.headers["cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+" + if !CONFIG.cookies.empty? + request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" + end +end + +struct YoutubeConnectionPool + property! url : URI + property! capacity : Int32 + property! timeout : Float64 + property pool : DB::Pool(QUIC::Client | HTTP::Client) + + def initialize(url : URI, @capacity = 5, @timeout = 5.0, use_quic = true) + @url = url + @pool = build_pool(use_quic) + end + + def client(region = nil, &block) + if region + conn = make_client(url, region) + response = yield conn + else + conn = pool.checkout + begin + response = yield conn + rescue ex + conn.close + conn = QUIC::Client.new(url) + conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET + conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC + conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" + response = yield conn + ensure + pool.release(conn) + end + end + + response + end + + private def build_pool(use_quic) + DB::Pool(QUIC::Client | HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do + if use_quic + conn = QUIC::Client.new(url) + else + conn = HTTP::Client.new(url) + end + conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET + conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC + conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" + conn + end + end +end + +def make_client(url : URI, region = nil) + # TODO: Migrate any applicable endpoints to QUIC + client = HTTPClient.new(url, OpenSSL::SSL::Context::Client.insecure) + client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC + client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" + client.read_timeout = 10.seconds + client.connect_timeout = 10.seconds + + if region + PROXY_LIST[region]?.try &.sample(40).each do |proxy| + begin + proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port]) + client.set_proxy(proxy) + break + rescue ex + end + end + end + + return client +end + +def make_client(url : URI, region = nil, &block) + client = make_client(url, region) + begin + yield client + ensure + client.close + end +end diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/yt_backend/extractors.cr index c8a6cd4a..8398ca8e 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -43,7 +43,7 @@ private module Parsers private def self.parse(item_contents, author_fallback) video_id = item_contents["videoId"].as_s - title = extract_text(item_contents["title"]) || "" + title = extract_text(item_contents["title"]?) || "" # Extract author information if author_info = item_contents.dig?("ownerText", "runs", 0) @@ -321,11 +321,13 @@ private module Parsers content_container = item_contents["contents"] end - raw_contents = content_container["items"].as_a - raw_contents.each do |item| - result = extract_item(item) - if !result.nil? - contents << result + raw_contents = content_container["items"]?.try &.as_a + if !raw_contents.nil? + raw_contents.each do |item| + result = extract_item(item) + if !result.nil? + contents << result + end end end @@ -399,7 +401,7 @@ private module Extractors items_container = renderer_container_contents end - items_container["items"].as_a.each do |item| + items_container["items"]?.try &.as_a.each do |item| raw_items << item end end @@ -531,37 +533,6 @@ private module HelperExtractors end end -# Extracts text from InnerTube response -# -# InnerTube can package text in three different formats -# "runs": [ -# {"text": "something"}, -# {"text": "cont"}, -# ... -# ] -# -# "SimpleText": "something" -# -# Or sometimes just none at all as with the data returned from -# category continuations. -# -# In order to facilitate calling this function with `#[]?`: -# A nil will be accepted. Of course, since nil cannot be parsed, -# another nil will be returned. -def extract_text(item : JSON::Any?) : String? - if item.nil? - return nil - end - - if text_container = item["simpleText"]? - return text_container.as_s - elsif text_container = item["runs"]? - return text_container.as_a.map(&.["text"].as_s).join("") - else - nil - end -end - # Parses an item from Youtube's JSON response into a more usable structure. # The end result can either be a SearchVideo, SearchPlaylist or SearchChannel. def extract_item(item : JSON::Any, author_fallback : String? = "", diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr new file mode 100644 index 00000000..97cc0997 --- /dev/null +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -0,0 +1,67 @@ +# Extracts text from InnerTube response +# +# InnerTube can package text in three different formats +# "runs": [ +# {"text": "something"}, +# {"text": "cont"}, +# ... +# ] +# +# "SimpleText": "something" +# +# Or sometimes just none at all as with the data returned from +# category continuations. +# +# In order to facilitate calling this function with `#[]?`: +# A nil will be accepted. Of course, since nil cannot be parsed, +# another nil will be returned. +def extract_text(item : JSON::Any?) : String? + if item.nil? + return nil + end + + if text_container = item["simpleText"]? + return text_container.as_s + elsif text_container = item["runs"]? + return text_container.as_a.map(&.["text"].as_s).join("") + else + nil + end +end + +def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) + extracted = extract_items(initial_data, author_fallback, author_id_fallback) + + target = [] of SearchItem + extracted.each do |i| + if i.is_a?(Category) + i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video } + else + target << i + end + end + return target.select(&.is_a?(SearchVideo)).map(&.as(SearchVideo)) +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"] +end + +def fetch_continuation_token(items : Array(JSON::Any)) + # Fetches the continuation token from an array of items + return items.last["continuationItemRenderer"]? + .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s +end + +def fetch_continuation_token(initial_data : Hash(String, JSON::Any)) + # Fetches the continuation token from initial data + if initial_data["onResponseReceivedActions"]? + continuation_items = initial_data["onResponseReceivedActions"][0]["appendContinuationItemsAction"]["continuationItems"] + else + tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]) + continuation_items = tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["gridRenderer"]["items"] + end + + return fetch_continuation_token(continuation_items.as_a) +end diff --git a/src/invidious/helpers/proxy.cr b/src/invidious/yt_backend/proxy.cr index 3418d887..3418d887 100644 --- a/src/invidious/helpers/proxy.cr +++ b/src/invidious/yt_backend/proxy.cr diff --git a/src/invidious/helpers/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index b3815f6a..b3815f6a 100644 --- a/src/invidious/helpers/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr |
