From 11fd419089209d90b29759ba1b1618f363e37c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=86=E3=82=AF=E3=83=8B=E3=82=AB=E3=83=AB=E8=AB=8F?= =?UTF-8?q?=E8=A8=AA=E5=AD=90?= <2-TechnicalSuwako@users.noreply.git.076.ne.jp> Date: Wed, 15 Sep 2021 19:02:43 +0900 Subject: [PATCH] =?UTF-8?q?iv-org=E3=81=8B=E3=82=895054510=E3=81=BE?= =?UTF-8?q?=E3=81=A7=E3=83=AA=E3=83=99=E3=83=BC=E3=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 + assets/js/player.js | 9 +- docker-compose.yml | 2 +- docker/Dockerfile | 53 +- docker/Dockerfile.arm64 | 54 +- kubernetes/values.yaml | 2 +- locales/ar.json | 6 +- locales/bn_BD.json | 2 +- locales/cs.json | 2 +- locales/da.json | 2 +- locales/de.json | 4 +- locales/el.json | 2 +- locales/en-US.json | 2 +- locales/eo.json | 8 +- locales/es.json | 18 +- locales/eu.json | 2 +- locales/fa.json | 2 +- locales/fi.json | 94 +- locales/fr.json | 2 +- locales/he.json | 2 +- locales/hr.json | 2 +- locales/hu-HU.json | 2 +- locales/id.json | 8 +- locales/is.json | 2 +- locales/it.json | 2 +- locales/ja.json | 4 +- locales/ko.json | 50 +- locales/lt.json | 10 +- locales/nb-NO.json | 2 +- locales/nl.json | 2 +- locales/pl.json | 42 +- locales/pt-BR.json | 4 +- locales/pt-PT.json | 2 +- locales/ro.json | 2 +- locales/ru.json | 2 +- locales/si.json | 2 +- locales/sk.json | 2 +- locales/sr.json | 2 +- locales/sr_Cyrl.json | 2 +- locales/sv-SE.json | 28 +- locales/tr.json | 2 +- locales/uk.json | 2 +- locales/vi.json | 2 +- locales/zh-CN.json | 2 +- locales/zh-TW.json | 2 +- shard.lock | 16 +- shard.yml | 7 +- src/invidious.cr | 2299 +---------------- src/invidious/channels/about.cr | 36 +- src/invidious/comments.cr | 126 +- src/invidious/helpers/helpers.cr | 4 - src/invidious/helpers/macros.cr | 9 + src/invidious/helpers/youtube_api.cr | 25 +- src/invidious/routes/api/manifest.cr | 224 ++ src/invidious/routes/api/v1/authenticated.cr | 415 +++ src/invidious/routes/api/v1/channels.cr | 278 ++ src/invidious/routes/api/v1/feeds.cr | 45 + src/invidious/routes/api/v1/misc.cr | 136 + src/invidious/routes/api/v1/search.cr | 78 + src/invidious/routes/api/v1/videos.cr | 363 +++ src/invidious/routes/base_route.cr | 2 - src/invidious/routes/channels.cr | 19 +- src/invidious/routes/embed.cr | 6 +- src/invidious/routes/feeds.cr | 431 +++ src/invidious/routes/login.cr | 15 +- src/invidious/routes/misc.cr | 12 +- src/invidious/routes/playlists.cr | 51 +- src/invidious/routes/preferences.cr | 8 +- src/invidious/routes/search.cr | 8 +- src/invidious/routes/video_playback.cr | 280 ++ src/invidious/routes/watch.cr | 12 +- src/invidious/routing.cr | 107 +- src/invidious/search.cr | 2 - src/invidious/videos.cr | 165 +- src/invidious/views/{ => feeds}/history.ecr | 0 .../playlists.ecr} | 0 src/invidious/views/{ => feeds}/popular.ecr | 0 .../views/{ => feeds}/subscriptions.ecr | 0 src/invidious/views/{ => feeds}/trending.ecr | 0 src/invidious/views/playlist.ecr | 2 +- src/invidious/views/preferences.ecr | 2 +- src/invidious/views/template.ecr | 2 +- src/invidious/views/watch.ecr | 3 +- 83 files changed, 2858 insertions(+), 2785 deletions(-) create mode 100644 src/invidious/routes/api/manifest.cr create mode 100644 src/invidious/routes/api/v1/authenticated.cr create mode 100644 src/invidious/routes/api/v1/channels.cr create mode 100644 src/invidious/routes/api/v1/feeds.cr create mode 100644 src/invidious/routes/api/v1/misc.cr create mode 100644 src/invidious/routes/api/v1/search.cr create mode 100644 src/invidious/routes/api/v1/videos.cr delete mode 100644 src/invidious/routes/base_route.cr create mode 100644 src/invidious/routes/feeds.cr create mode 100644 src/invidious/routes/video_playback.cr rename src/invidious/views/{ => feeds}/history.ecr (100%) rename src/invidious/views/{view_all_playlists.ecr => feeds/playlists.ecr} (100%) rename src/invidious/views/{ => feeds}/popular.ecr (100%) rename src/invidious/views/{ => feeds}/subscriptions.ecr (100%) rename src/invidious/views/{ => feeds}/trending.ecr (100%) diff --git a/README.md b/README.md index 091a74eb..69080a59 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,11 @@ Bitcoin (BTC): [bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr](bitcoin:bc1qfhe7rq3l Monero (XMR): [41nMCtek197boJtiUvGnTFYMatrLEpnpkQDmUECqx5Es2uX3sTKKWVhSL76suXsG3LXqkEJBrCZBgPTwJrDp1FrZJfycGPR](monero:41nMCtek197boJtiUvGnTFYMatrLEpnpkQDmUECqx5Es2uX3sTKKWVhSL76suXsG3LXqkEJBrCZBgPTwJrDp1FrZJfycGPR) +Ethereum (ETH): [0xD1F7E3Bfb19Ee5a52baED396Ad34717aF18d995B](ethereum:0xD1F7E3Bfb19Ee5a52baED396Ad34717aF18d995B) + +Litecoin (LTC): [ltc1q8787aq2xrseq5yx52axx8c4fqks88zj5vr0zx9](litecoin:ltc1q8787aq2xrseq5yx52axx8c4fqks88zj5vr0zx9) + + ## Liability diff --git a/assets/js/player.js b/assets/js/player.js index 0de18d92..a461c53d 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -149,9 +149,14 @@ player.on('error', function (event) { }); // Enable VR video support -if (video_data.vr && video_data.params.vr_mode) { +if (!video_data.params.listen && video_data.vr && video_data.params.vr_mode) { player.crossOrigin("anonymous") - player.vr({projection: "EAC"}); + switch (video_data.projection_type) { + case "EQUIRECTANGULAR": + player.vr({projection: "equirectangular"}); + default: // Should only be "MESH" but we'll use this as a fallback. + player.vr({projection: "EAC"}); + } } // Add markers diff --git a/docker-compose.yml b/docker-compose.yml index bc292c53..b94f9813 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: POSTGRES_PASSWORD: kemal POSTGRES_USER: kemal healthcheck: - test: ["CMD", "pg_isready", "-U", "postgres"] + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"] invidious: build: context: . diff --git a/docker/Dockerfile b/docker/Dockerfile index 9a535414..08feb554 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,52 +1,35 @@ -FROM alpine:edge AS liblsquic-builder -WORKDIR /src - -RUN apk add --no-cache build-base git apk-tools abuild cmake go perl linux-headers - -RUN abuild-keygen -a -n && \ - cp /root/.abuild/-*.rsa.pub /etc/apk/keys/ - -COPY docker/APKBUILD-boringssl boringssl/APKBUILD -RUN cd boringssl && abuild -F -r && cd .. - -RUN apk add --repository /root/packages/src boringssl boringssl-dev boringssl-static - -RUN apk add --no-cache zlib-dev zlib-static libevent-dev libevent-static - -COPY docker/APKBUILD-lsquic lsquic/APKBUILD -RUN cd lsquic && abuild -F -r && cd .. - -RUN apk add --repository /root/packages/src lsquic-static - -RUN mkdir tmp && cd tmp && \ - ar -x /usr/lib/libssl.a && \ - ar -x /usr/lib/libcrypto.a && \ - ar -x /usr/lib/liblsquic.a && \ - ar rc liblsquic.a *.o && \ - strip --strip-unneeded liblsquic.a && \ - ranlib liblsquic.a && \ - cp liblsquic.a /root/liblsquic.a && \ - cd .. && rm -rf tmp - - -FROM crystallang/crystal:1.0.0-alpine AS builder +FROM crystallang/crystal:1.1.1-alpine AS builder RUN apk add --no-cache sqlite-static yaml-static +ARG release + WORKDIR /invidious COPY ./shard.yml ./shard.yml COPY ./shard.lock ./shard.lock RUN shards install -COPY --from=liblsquic-builder /root/liblsquic.a ./lib/lsquic/src/lsquic/ext/liblsquic.a +COPY --from=quay.io/invidious/lsquic-compiled /root/liblsquic.a ./lib/lsquic/src/lsquic/ext/liblsquic.a COPY ./src/ ./src/ # TODO: .git folder is required for building – this is destructive. # See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. COPY ./.git/ ./.git/ -RUN crystal build ./src/invidious.cr \ - --static --warnings all \ + +RUN crystal spec --warnings all \ --link-flags "-lxml2 -llzma" +RUN if [ ${release} == 1 ] ; then \ + crystal build ./src/invidious.cr \ + --release \ + --static --warnings all \ + --link-flags "-lxml2 -llzma"; \ + else \ + crystal build ./src/invidious.cr \ + --static --warnings all \ + --link-flags "-lxml2 -llzma"; \ + fi + + FROM alpine:latest RUN apk add --no-cache librsvg ttf-opensans WORKDIR /invidious diff --git a/docker/Dockerfile.arm64 b/docker/Dockerfile.arm64 index 1ec95d8a..063ba6d2 100644 --- a/docker/Dockerfile.arm64 +++ b/docker/Dockerfile.arm64 @@ -1,53 +1,35 @@ -FROM alpine:3.14 AS liblsquic-builder -WORKDIR /src +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 build-base git apk-tools abuild cmake go perl linux-headers - -RUN abuild-keygen -a -n && \ - cp /root/.abuild/-*.rsa.pub /etc/apk/keys/ - -COPY docker/APKBUILD-boringssl boringssl/APKBUILD -RUN cd boringssl && abuild -F -r && cd .. - -RUN apk add --repository /root/packages/src boringssl boringssl-dev boringssl-static - -RUN apk add --no-cache zlib-dev zlib-static libevent-dev libevent-static - -COPY docker/APKBUILD-lsquic lsquic/APKBUILD -RUN cd lsquic && abuild -F -r && cd .. - -RUN apk add --repository /root/packages/src lsquic-static - -RUN mkdir tmp && cd tmp && \ - ar -x /usr/lib/libssl.a && \ - ar -x /usr/lib/libcrypto.a && \ - ar -x /usr/lib/liblsquic.a && \ - ar rc liblsquic.a *.o && \ - strip --strip-unneeded liblsquic.a && \ - ranlib liblsquic.a && \ - cp liblsquic.a /root/liblsquic.a && \ - cd .. && rm -rf tmp - - -FROM alpine:3.14 AS builder -RUN apk add --no-cache 'crystal<2' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev +ARG release WORKDIR /invidious COPY ./shard.yml ./shard.yml COPY ./shard.lock ./shard.lock RUN shards install -COPY --from=liblsquic-builder /root/liblsquic.a ./lib/lsquic/src/lsquic/ext/liblsquic.a +COPY --from=quay.io/invidious/lsquic-compiled /root/liblsquic.a ./lib/lsquic/src/lsquic/ext/liblsquic.a COPY ./src/ ./src/ # TODO: .git folder is required for building – this is destructive. # See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. COPY ./.git/ ./.git/ -RUN crystal build ./src/invidious.cr \ - --static --warnings all \ + +RUN crystal spec --warnings all \ --link-flags "-lxml2 -llzma" -FROM alpine:latest +RUN if [ ${release} == 1 ] ; then \ + crystal build ./src/invidious.cr \ + --release \ + --static --warnings all \ + --link-flags "-lxml2 -llzma"; \ + else \ + crystal build ./src/invidious.cr \ + --static --warnings all \ + --link-flags "-lxml2 -llzma"; \ + fi + +FROM alpine:edge RUN apk add --no-cache librsvg ttf-opensans WORKDIR /invidious RUN addgroup -g 1000 -S invidious && \ diff --git a/kubernetes/values.yaml b/kubernetes/values.yaml index 08def6e4..f241970c 100644 --- a/kubernetes/values.yaml +++ b/kubernetes/values.yaml @@ -1,7 +1,7 @@ name: invidious image: - repository: iv-org/invidious + repository: quay.io/invidious/invidious tag: latest pullPolicy: Always diff --git a/locales/ar.json b/locales/ar.json index 352fb38d..9488e309 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -28,7 +28,7 @@ "New passwords must match": "يَجبُ أن تكون كلمتي المرور متطابقتان", "Cannot change password for Google accounts": "لا يُمكن تغيير كلمة المرور لِحسابات جوجل", "Authorize token?": "رمز التفويض؟", - "Authorize token for `x`?": "تصريح الرمز لـ `x` ؟", + "Authorize token for `x`?": "رمز التفويض لـ `x` ؟", "Yes": "نعم", "No": "لا", "Import and Export Data": "اِستيراد البيانات وتصديرها", @@ -145,7 +145,7 @@ }, "search": "بحث", "Log out": "تسجيل الخروج", - "Released under the AGPLv3 by Omar Roth.": "تم الإنشاء تحت AGPLv3 بواسطة عمر روث.", + "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": "رابط التعليق على اليوتيوب", diff --git a/locales/bn_BD.json b/locales/bn_BD.json index 5f91c67e..c9e1150b 100644 --- a/locales/bn_BD.json +++ b/locales/bn_BD.json @@ -145,7 +145,7 @@ }, "search": "", "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", + "Released under the AGPLv3 on Github.": "", "Source available here.": "", "View JavaScript license information.": "", "View privacy policy.": "", diff --git a/locales/cs.json b/locales/cs.json index abb2d503..094ad09c 100644 --- a/locales/cs.json +++ b/locales/cs.json @@ -145,7 +145,7 @@ }, "search": "hledat", "Log out": "Odhlásit se", - "Released under the AGPLv3 by Omar Roth.": "Vydáno Omarem Roth pod AGPLv3.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Zdrojový kód dostupný zde.", "View JavaScript license information.": "Zobrazit informace o licenci JavaScript .", "View privacy policy.": "Zobrazit Zásady ochrany osobních údajů.", diff --git a/locales/da.json b/locales/da.json index 2d0dad84..5919283d 100644 --- a/locales/da.json +++ b/locales/da.json @@ -145,7 +145,7 @@ }, "search": "søg", "Log out": "Log ud", - "Released under the AGPLv3 by Omar Roth.": "Offentliggjort under AGPLv3 af Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Kilde tilgængelig her.", "View JavaScript license information.": "Vis JavaScriptlicensinformation.", "View privacy policy.": "Vis privatpolitik.", diff --git a/locales/de.json b/locales/de.json index d97dfad5..44725cbc 100644 --- a/locales/de.json +++ b/locales/de.json @@ -13,7 +13,7 @@ }, "LIVE": "LIVE", "Shared `x` ago": "Vor `x` geteilt", - "Unsubscribe": "Abbestellen", + "Unsubscribe": "Abo beenden", "Subscribe": "Abonnieren", "View channel on YouTube": "Kanal auf YouTube anzeigen", "View playlist on YouTube": "Wiedergabeliste auf YouTube anzeigen", @@ -145,7 +145,7 @@ }, "search": "Suchen", "Log out": "Abmelden", - "Released under the AGPLv3 by Omar Roth.": "Veröffentlicht unter AGPLv3 von Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Quellcode verfügbar hier.", "View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.", "View privacy policy.": "Datenschutzerklärung einsehen.", diff --git a/locales/el.json b/locales/el.json index 830fb0fe..6ad0c47f 100644 --- a/locales/el.json +++ b/locales/el.json @@ -145,7 +145,7 @@ }, "search": "αναζήτηση", "Log out": "Αποσύνδεση", - "Released under the AGPLv3 by Omar Roth.": "Κυκλοφορεί υπό την άδεια AGPLv3 από τον Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Προβολή πηγαίου κώδικα εδώ.", "View JavaScript license information.": "Προβολή πληροφοριών άδειας JavaScript.", "View privacy policy.": "Προβολή πολιτικής απορρήτου.", diff --git a/locales/en-US.json b/locales/en-US.json index 0836409e..a1e39777 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -145,7 +145,7 @@ }, "search": "search", "Log out": "Log out", - "Released under the AGPLv3 by Omar Roth.": "Released under the AGPLv3 by Omar Roth.", + "Released under the AGPLv3 on Github.": "Released under the AGPLv3 on Github.", "Source available here.": "Source available here.", "View JavaScript license information.": "View JavaScript license information.", "View privacy policy.": "View privacy policy.", diff --git a/locales/eo.json b/locales/eo.json index e3970159..7c2c7482 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -145,7 +145,7 @@ }, "search": "serĉi", "Log out": "Elsaluti", - "Released under the AGPLv3 by Omar Roth.": "Eldonita sub la AGPLv3 de Omar Roth.", + "Released under the AGPLv3 on Github.": "Eldonita sub la AGPLv3 en Github.", "Source available here.": "Fonto havebla ĉi tie.", "View JavaScript license information.": "Vidi Ĝavoskriptan licencan informon.", "View privacy policy.": "Vidi regularon pri privateco.", @@ -421,7 +421,7 @@ "hdr": "granddinamikgama", "filter": "filtri", "Current version: ": "Nuna versio: ", - "next_steps_error_message": "", - "next_steps_error_message_refresh": "", - "next_steps_error_message_go_to_youtube": "" + "next_steps_error_message": "Poste, vi provu: ", + "next_steps_error_message_refresh": "Reŝargi", + "next_steps_error_message_go_to_youtube": "Iri al JuTubo" } diff --git a/locales/es.json b/locales/es.json index d999d3bf..1f3f1c9e 100644 --- a/locales/es.json +++ b/locales/es.json @@ -86,8 +86,8 @@ "dark": "oscuro", "light": "claro", "Thin mode: ": "Modo compacto: ", - "Miscellaneous preferences": "", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", + "Miscellaneous preferences": "Preferencias misceláneas", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Redirección automática de instancia (segunda opción a redirect.invidious.io): ", "Subscription preferences": "Preferencias de la suscripción", "Show annotations by default for subscribed channels: ": "¿Mostrar anotaciones por defecto para los canales suscritos? ", "Redirect homepage to feed: ": "Redirigir la página de inicio a la fuente: ", @@ -117,7 +117,7 @@ "Administrator preferences": "Preferencias de administrador", "Default homepage: ": "Página de inicio por defecto: ", "Feed menu: ": "Menú de fuentes: ", - "Show nickname on top: ": "", + "Show nickname on top: ": "Mostrar nombre de usuario arriba: ", "Top enabled: ": "¿Habilitar los destacados? ", "CAPTCHA enabled: ": "¿Habilitar los CAPTCHA? ", "Login enabled: ": "¿Habilitar el inicio de sesión? ", @@ -145,7 +145,7 @@ }, "search": "buscar", "Log out": "Cerrar la sesión", - "Released under the AGPLv3 by Omar Roth.": "Publicado bajo licencia AGPLv3 por Omar Roth.", + "Released under the AGPLv3 on Github.": "Publicado bajo la AGPLv3 en Github.", "Source available here.": "Código fuente disponible aquí.", "View JavaScript license information.": "Ver información de licencia de JavaScript.", "View privacy policy.": "Ver la política de privacidad.", @@ -164,8 +164,8 @@ "Show more": "Mostrar más", "Show less": "Mostrar menos", "Watch on YouTube": "Ver el vídeo en YouTube", - "Switch Invidious Instance": "", - "Broken? Try another Invidious Instance": "", + "Switch Invidious Instance": "Cambiar Instancia de Invidious", + "Broken? Try another Invidious Instance": "¿Algún error? Prueba otra instancia de Invidious", "Hide annotations": "Ocultar anotaciones", "Show annotations": "Mostrar anotaciones", "Genre: ": "Género: ", @@ -421,7 +421,7 @@ "hdr": "hdr", "filter": "filtro", "Current version: ": "Versión actual: ", - "next_steps_error_message": "", - "next_steps_error_message_refresh": "", - "next_steps_error_message_go_to_youtube": "" + "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" } diff --git a/locales/eu.json b/locales/eu.json index 2fdb278b..df3f4329 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -145,7 +145,7 @@ }, "search": "", "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", + "Released under the AGPLv3 on Github.": "", "Source available here.": "", "View JavaScript license information.": "", "View privacy policy.": "", diff --git a/locales/fa.json b/locales/fa.json index d449948a..68a016c4 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -145,7 +145,7 @@ }, "search": "جستجو", "Log out": "خروج", - "Released under the AGPLv3 by Omar Roth.": "منتشر شده تحت مجوز AGPLv3 توسط Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "منبع اینجا دردسترس است.", "View JavaScript license information.": "نمایش اطلاعات مجوز جاوا اسکریپت.", "View privacy policy.": "نمایش سیاست حفظ حریم خصوصی.", diff --git a/locales/fi.json b/locales/fi.json index 60c2aed6..6a830177 100644 --- a/locales/fi.json +++ b/locales/fi.json @@ -77,8 +77,8 @@ "Fallback captions: ": "Toissijaiset tekstitykset: ", "Show related videos: ": "Näytä aiheeseen liittyviä videoita: ", "Show annotations by default: ": "Näytä huomautukset oletuksena: ", - "Automatically extend video description: ": "", - "Interactive 360 degree videos: ": "", + "Automatically extend video description: ": "Laajenna automaattisesti videon kuvausta: ", + "Interactive 360 degree videos: ": "Interaktiiviset 360-asteiset videot: ", "Visual preferences": "Visuaaliset asetukset", "Player style: ": "Soittimen tyyli: ", "Dark mode: ": "Tumma tila: ", @@ -86,8 +86,8 @@ "dark": "tumma", "light": "vaalea", "Thin mode: ": "Kapea tila ", - "Miscellaneous preferences": "", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", + "Miscellaneous preferences": "Sekalaiset asetukset", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automaattinen palveluntarjoajan uudelleenohjaus (perääntyminen sivulle redirect.invidious.io) ", "Subscription preferences": "Tilausten asetukset", "Show annotations by default for subscribed channels: ": "Näytä oletuksena tilattujen kanavien huomautukset: ", "Redirect homepage to feed: ": "Uudelleenohjaa kotisivu syötteeseen: ", @@ -117,7 +117,7 @@ "Administrator preferences": "Järjestelmänvalvojan asetukset", "Default homepage: ": "Oletuskotisivu: ", "Feed menu: ": "Syötevalikko: ", - "Show nickname on top: ": "", + "Show nickname on top: ": "Näytä nimimerkki ylimpänä: ", "Top enabled: ": "Yläosa käytössä: ", "CAPTCHA enabled: ": "CAPTCHA käytössä: ", "Login enabled: ": "Kirjautuminen käytössä: ", @@ -145,7 +145,7 @@ }, "search": "haku", "Log out": "Kirjaudu ulos", - "Released under the AGPLv3 by Omar Roth.": "Julkaissut AGPLv3-lisenssillä: Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Lähdekoodi on saatavilla täällä.", "View JavaScript license information.": "JavaScript-koodin lisenssit.", "View privacy policy.": "Katso tietosuojaseloste.", @@ -161,11 +161,11 @@ "Title": "Nimi", "Playlist privacy": "Soittolistan yksityisyys", "Editing playlist `x`": "Muokataan soittolistaa `x`", - "Show more": "", - "Show less": "", + "Show more": "Näytä enemmän", + "Show less": "Näytä vähemmän", "Watch on YouTube": "Katso YouTubessa", - "Switch Invidious Instance": "", - "Broken? Try another Invidious Instance": "", + "Switch Invidious Instance": "Vaihda Invidious-palveluntarjoajaa", + "Broken? Try another Invidious Instance": "Rikki? Kokeile toista Invidious-palveluntarjoajaa", "Hide annotations": "Piilota merkkaukset", "Show annotations": "Näytä merkkaukset", "Genre: ": "Genre: ", @@ -173,11 +173,11 @@ "Family friendly? ": "Kaiken ikäisille sopiva? ", "Wilson score: ": "Wilson-pistemäärä: ", "Engagement: ": "Huomio: ", - "Whitelisted regions: ": "valkolistatut alueet: ", - "Blacklisted regions: ": "mustalla listalla olevat alueet: ", + "Whitelisted regions: ": "Sallitut alueet: ", + "Blacklisted regions: ": "Estetyt alueet: ", "Shared `x`": "Jaettu `x`", "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` katselukertaa", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` katselukerta", "": "`x` katselukertaa" }, "Premieres in `x`": "Ensiesitykseen aikaa `x`", @@ -227,8 +227,8 @@ "Empty playlist": "Tyhjennä soittolista", "Not a playlist.": "Ei ole soittolista.", "Playlist does not exist.": "Soittolistaa ei ole olemassa.", - "Could not pull trending pages.": "Nousussa olevien sivujen lataus epäonnitui.", - "Hidden field \"challenge\" is a required field": "Piilotettu kenttä \"challenge\" on vaaditaan", + "Could not pull trending pages.": "Nousussa olevien sivujen lataus epäonnistui.", + "Hidden field \"challenge\" is a required field": "Piilotettu kenttä \"challenge\" vaaditaan", "Hidden field \"token\" is a required field": "Piilotettu kenttä \"tunniste\" vaaditaan", "Erroneous challenge": "Virheellinen haaste", "Erroneous token": "Virheellinen tunniste", @@ -368,9 +368,9 @@ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekuntia", "": "`x` sekuntia" }, - "Fallback comments: ": "varakommentit: ", + "Fallback comments: ": "Varakommentit: ", "Popular": "Suosittu", - "Search": "", + "Search": "Etsi", "Top": "Ylin", "About": "Tietoa", "Rating: ": "Arvosana: ", @@ -393,35 +393,35 @@ "Videos": "Videot", "Playlists": "Soittolistat", "Community": "Yhteisö", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", + "relevance": "Osuvuus", + "rating": "Arvostelu", + "date": "Latauspäivämäärä", + "views": "Katselukerrat", + "content_type": "Tyyppi", + "duration": "Kesto", + "features": "Ominaisuudet", + "sort": "Luokittele", + "hour": "Viimeisin tunti", + "today": "Tänään", + "week": "Tämä viikko", + "month": "Tämä kuukausi", + "year": "Tämä vuosi", + "video": "Video", + "channel": "Kanava", + "playlist": "Soittolista", + "movie": "Elokuva", + "show": "Ohjelma", + "hd": "HD", + "subtitles": "Tekstitys/CC", + "creative_commons": "Creative Commons", + "3d": "3D", + "live": "Suora lähetys", + "4k": "4K", + "location": "Sijainti", + "hdr": "HDR", + "filter": "Suodatin", "Current version: ": "Tämänhetkinen versio: ", - "next_steps_error_message": "", - "next_steps_error_message_refresh": "", - "next_steps_error_message_go_to_youtube": "" + "next_steps_error_message": "Sinun tulisi kokeilla seuraavia: ", + "next_steps_error_message_refresh": "Päivitä", + "next_steps_error_message_go_to_youtube": "Siirry YouTubeen" } diff --git a/locales/fr.json b/locales/fr.json index 80760fce..a7fe004d 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -145,7 +145,7 @@ }, "search": "rechercher", "Log out": "Se déconnecter", - "Released under the AGPLv3 by Omar Roth.": "Publié sous licence AGPLv3 par Omar Roth.", + "Released under the AGPLv3 on Github.": "Publié sous licence AGPLv3 sur Github.", "Source available here.": "Code source disponible ici.", "View JavaScript license information.": "Informations des licences JavaScript.", "View privacy policy.": "Politique de confidentialité.", diff --git a/locales/he.json b/locales/he.json index 5d7f85c6..6778e4dd 100644 --- a/locales/he.json +++ b/locales/he.json @@ -145,7 +145,7 @@ }, "search": "חיפוש", "Log out": "יציאה", - "Released under the AGPLv3 by Omar Roth.": "מופץ תחת רישיון AGPLv3 על ידי עמר רות׳ (Omar Roth).", + "Released under the AGPLv3 on Github.": "", "Source available here.": "קוד המקור זמין כאן.", "View JavaScript license information.": "", "View privacy policy.": "להצגת מדיניות הפרטיות.", diff --git a/locales/hr.json b/locales/hr.json index d4a31323..dd6d14a9 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -145,7 +145,7 @@ }, "search": "traži", "Log out": "Odjavi se", - "Released under the AGPLv3 by Omar Roth.": "Izdano pod licencom AGPLv3, Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Izvor je ovdje dostupan.", "View JavaScript license information.": "Prikaži informacije o JavaScript licenci.", "View privacy policy.": "Prikaži politiku privatnosti.", diff --git a/locales/hu-HU.json b/locales/hu-HU.json index d69a0792..d5570a18 100644 --- a/locales/hu-HU.json +++ b/locales/hu-HU.json @@ -145,7 +145,7 @@ }, "search": "keresés", "Log out": "Kijelentkezés", - "Released under the AGPLv3 by Omar Roth.": "Omar Roth által kiadva AGPLv3 licensz alatt.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "A forráskód itt érhető el.", "View JavaScript license information.": "JavaScript licensz inforkációk megtekintése.", "View privacy policy.": "Adatvédelmi irányelvek megtekintése.", diff --git a/locales/id.json b/locales/id.json index a2821257..e15c6aaf 100644 --- a/locales/id.json +++ b/locales/id.json @@ -145,7 +145,7 @@ }, "search": "cari", "Log out": "Keluar", - "Released under the AGPLv3 by Omar Roth.": "Dirilis dibawah AGPLv3 oleh Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Sumber tersedia di sini.", "View JavaScript license information.": "Tampilkan informasi lisensi JavaScript.", "View privacy policy.": "Lihat kebijakan privasi.", @@ -421,7 +421,7 @@ "hdr": "hdr", "filter": "saring", "Current version: ": "Versi saat ini: ", - "next_steps_error_message": "", - "next_steps_error_message_refresh": "", - "next_steps_error_message_go_to_youtube": "" + "next_steps_error_message": "Setelah itu Anda harus mencoba: ", + "next_steps_error_message_refresh": "Segarkan", + "next_steps_error_message_go_to_youtube": "Buka YouTube" } diff --git a/locales/is.json b/locales/is.json index 827abaeb..478f363a 100644 --- a/locales/is.json +++ b/locales/is.json @@ -145,7 +145,7 @@ }, "search": "leita", "Log out": "Útskrá", - "Released under the AGPLv3 by Omar Roth.": "Útgefið undir AGPLv3 eftir Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Frumkóði aðgengilegur hér.", "View JavaScript license information.": "Skoða JavaScript leyfisupplýsingar.", "View privacy policy.": "Skoða meðferð persónuupplýsinga.", diff --git a/locales/it.json b/locales/it.json index 676bd650..df3642db 100644 --- a/locales/it.json +++ b/locales/it.json @@ -145,7 +145,7 @@ }, "search": "Cerca", "Log out": "Esci", - "Released under the AGPLv3 by Omar Roth.": "Pubblicato con licenza AGPLv3 da Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Codice sorgente.", "View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.", "View privacy policy.": "Vedi la politica sulla privacy.", diff --git a/locales/ja.json b/locales/ja.json index 831671c6..c4f78f96 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -145,7 +145,7 @@ }, "search": "検索", "Log out": "ログアウト", - "Released under the AGPLv3 by Omar Roth.": "Omar Roth によって AGPLv3 でリリースされています", + "Released under the AGPLv3 on Github.": "Github 上で AGPLv3 の下で公開されています", "Source available here.": "ソースはここで閲覧可能です。", "View JavaScript license information.": "JavaScript ライセンス情報", "View privacy policy.": "プライバシーポリシー", @@ -402,7 +402,7 @@ "features": "機能", "sort": "順番", "hour": "1時間前", - "today": "本日", + "today": "今日", "week": "今週", "month": "今月", "year": "今年", diff --git a/locales/ko.json b/locales/ko.json index 70a79a7d..94f781d4 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -55,10 +55,10 @@ "Export subscriptions as OPML": "구독을 OPML로 내보내기", "Export": "내보내기", "Import NewPipe data (.zip)": "NewPipe 데이터 가져오기 (.zip)", - "Import NewPipe subscriptions (.json)": "NewPipe 구독을 가져 오기 (.json)", - "Import FreeTube subscriptions (.db)": "FreeTube 구독 가져 오기 (.db)", - "Import YouTube subscriptions": "YouTube 구독 가져 오기", - "Import Invidious data": "Invidious 데이터 가져 오기", + "Import NewPipe subscriptions (.json)": "NewPipe 구독을 가져오기 (.json)", + "Import FreeTube subscriptions (.db)": "FreeTube 구독 가져오기 (.db)", + "Import YouTube subscriptions": "YouTube 구독 가져오기", + "Import Invidious data": "Invidious 데이터 가져오기", "Import": "가져오기", "Import and Export Data": "데이터 가져오기 및 내보내기", "No": "아니요", @@ -73,7 +73,7 @@ "Next page": "다음 페이지", "last": "마지막", "Shared `x` ago": "`x` 전에 공유", - "popular": "인기순", + "popular": "인기", "oldest": "오래된순", "newest": "최신순", "View playlist on YouTube": "YouTube에서 재생목록 보기", @@ -136,6 +136,7 @@ "Delete playlist": "재생목록 삭제", "Delete playlist `x`?": "재생목록 `x` 를 삭제 하시겠습니까?", "Updated `x` ago": "`x` 전에 업데이트됨", + "Released under the AGPLv3 on Github.": "", "View all playlists": "모든 재생목록 보기", "Private": "비공개", "Unlisted": "목록에 없음", @@ -143,7 +144,6 @@ "View privacy policy.": "개인정보 처리방침 보기.", "View JavaScript license information.": "JavaScript 라이센스 정보 보기.", "Source available here.": "소스는 여기에서 사용할 수 있습니다.", - "Released under the AGPLv3 by Omar Roth.": "Omar Roth에 의해 AGPLv3에 따라 공개되었습니다.", "Log out": "로그아웃", "search": "검색", "`x` unseen notifications": { @@ -221,7 +221,7 @@ "Current version: ": "현재 버전: ", "next_steps_error_message_refresh": "새로 고침", "next_steps_error_message_go_to_youtube": "YouTube로 가기", - "subtitles": "자막/CC", + "subtitles": "자막", "`x` marked it with a ❤": "`x`님의 ❤", "Download as: ": "다음으로 다운로드: ", "Download": "다운로드", @@ -389,5 +389,39 @@ }, "Invidious Private Feed for `x`": "`x` 에 대한 Invidious 비공개 피드", "Premieres `x`": "최초 공개 `x`", - "Premieres in `x`": "`x` 에 최초 공개" + "Premieres in `x`": "`x` 에 최초 공개", + "next_steps_error_message": "다음 방법을 시도해 보세요: ", + "creative_commons": "크리에이티브 커먼즈", + "duration": "길이", + "content_type": "구분", + "date": "업로드 날짜", + "rating": "평점", + "relevance": "관련성", + "Community": "커뮤니티", + "Videos": "동영상", + "Video mode": "비디오 모드", + "Audio mode": "오디오 모드", + "permalink": "퍼머링크", + "YouTube comment permalink": "YouTube 댓글 퍼머링크", + "(edited)": "(수정됨)", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "Movies": "영화", + "News": "뉴스", + "Gaming": "게임", + "Music": "음악", + "Default": "디폴트", + "Rating: ": "평점: ", + "About": "정보", + "Top": "최고", + "hd": "HD", + "show": "쇼", + "movie": "영화", + "video": "동영상", + "year": "올해", + "month": "이번 달", + "week": "이번 주", + "today": "오늘", + "hour": "지난 1시간", + "sort": "정렬기준", + "features": "기능별" } diff --git a/locales/lt.json b/locales/lt.json index 89b8223c..e8e84dcf 100644 --- a/locales/lt.json +++ b/locales/lt.json @@ -145,11 +145,11 @@ }, "search": "ieškoti", "Log out": "Atsijungti", - "Released under the AGPLv3 by Omar Roth.": "Išleista pagal AGPLv3 - Omar Roth.", + "Released under the AGPLv3 on Github.": "Išleista pagal AGPLv3 licenciją Github.", "Source available here.": "Kodas prieinamas čia.", "View JavaScript license information.": "Žiūrėti JavaScript licencijos informaciją.", "View privacy policy.": "Žiūrėti privatumo politiką.", - "Trending": "Populiarūs", + "Trending": "Tendencijos", "Public": "Viešas", "Unlisted": "Neįtrauktas į sąrašą", "Private": "Neviešas", @@ -227,7 +227,7 @@ "Empty playlist": "Tuščias grojaraštis", "Not a playlist.": "Ne grojaraštis.", "Playlist does not exist.": "Grojaraštis neegzistuoja.", - "Could not pull trending pages.": "Nepavyko pritraukti 'dabar populiaru' puslapių.", + "Could not pull trending pages.": "Nepavyko ištraukti tendencijų puslapių.", "Hidden field \"challenge\" is a required field": "Paslėptas laukas „iššūkis“ yra privalomas laukas", "Hidden field \"token\" is a required field": "Paslėptas laukas „žetonas“ yra privalomas laukas", "Erroneous challenge": "Klaidingas iššūkis", @@ -357,7 +357,7 @@ "": "`x` dienas" }, "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x`valandą", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` valandą", "": "`x` valandas" }, "`x` minutes": { @@ -369,7 +369,7 @@ "": "`x` sekundes" }, "Fallback comments: ": "Atsarginiai komentarai: ", - "Popular": "Šiuo metu populiaru", + "Popular": "Populiaru", "Search": "Paieška", "Top": "Top", "About": "Apie", diff --git a/locales/nb-NO.json b/locales/nb-NO.json index 5088baff..9e39a6c7 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 by Omar Roth.": "Utgitt med AGPLv3+lisens av Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Kildekode tilgjengelig her.", "View JavaScript license information.": "Vis JavaScript-lisensinfo.", "View privacy policy.": "Vis personvernspraksis.", diff --git a/locales/nl.json b/locales/nl.json index c4948fd1..9fe604ad 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -145,7 +145,7 @@ }, "search": "zoeken", "Log out": "Uitloggen", - "Released under the AGPLv3 by Omar Roth.": "Uitgebracht onder de AGPLv3-licentie, door Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "De broncode is hier beschikbaar.", "View JavaScript license information.": "JavaScript-licentieinformatie tonen.", "View privacy policy.": "Privacybeleid tonen.", diff --git a/locales/pl.json b/locales/pl.json index 2da80747..a33bbd45 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -86,8 +86,8 @@ "dark": "ciemny", "light": "jasny", "Thin mode: ": "Tryb minimalny: ", - "Miscellaneous preferences": "", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", + "Miscellaneous preferences": "Różne preferencje", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatyczne przekierowanie instancji (powrót do redirect.invidious.io): ", "Subscription preferences": "Preferencje subskrybcji", "Show annotations by default for subscribed channels: ": "Domyślnie wyświetlaj adnotacje dla subskrybowanych kanałów: ", "Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ", @@ -116,8 +116,8 @@ "Delete account": "Usuń konto", "Administrator preferences": "Preferencje administratora", "Default homepage: ": "Domyślna strona główna: ", - "Feed menu: ": "Menu aktualności: ", - "Show nickname on top: ": "", + "Feed menu: ": "Menu aktualności ", + "Show nickname on top: ": "Pokaż pseudonim na górze: ", "Top enabled: ": "\"Top\" aktywne: ", "CAPTCHA enabled: ": "CAPTCHA aktywna? ", "Login enabled: ": "Logowanie włączone? ", @@ -132,8 +132,8 @@ "": "`x` subskrybcji" }, "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token", + "": "`x` tokenów" }, "Import/export": "Import/Eksport", "unsubscribe": "odsubskrybuj", @@ -145,7 +145,7 @@ }, "search": "szukaj", "Log out": "Wyloguj", - "Released under the AGPLv3 by Omar Roth.": "Wydano na licencji AGPLv3 przez Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Kod źródłowy dostępny tutaj.", "View JavaScript license information.": "Wyświetl informację o licencji JavaScript.", "View privacy policy.": "Polityka prywatności.", @@ -164,8 +164,8 @@ "Show more": "Pokaż więcej", "Show less": "Pokaż mniej", "Watch on YouTube": "Zobacz film na YouTube", - "Switch Invidious Instance": "", - "Broken? Try another Invidious Instance": "", + "Switch Invidious Instance": "Przełącz instancję Invidious", + "Broken? Try another Invidious Instance": "Nie działa? Spróbuj innej instancji Invidious", "Hide annotations": "Ukryj adnotacje", "Show annotations": "Pokaż adnotacje", "Genre: ": "Gatunek: ", @@ -393,20 +393,20 @@ "Videos": "Filmy", "Playlists": "Playlisty", "Community": "Społeczność", - "relevance": "", - "rating": "", + "relevance": "Trafność", + "rating": "Ocena", "date": "data", - "views": "", - "content_type": "", - "duration": "", - "features": "", + "views": "Liczba wyświetleń", + "content_type": "Typ", + "duration": "Długość", + "features": "Funkcje", "sort": "sortuj", "hour": "godzina", "today": "dzisiaj", "week": "tydzień", "month": "miesiąc", "year": "rok", - "video": "", + "video": "Film", "channel": "kanał", "playlist": "playlista", "movie": "film", @@ -415,13 +415,13 @@ "subtitles": "napisy", "creative_commons": "creative_commons", "3d": "3d", - "live": "", + "live": "Na żywo", "4k": "4k", - "location": "", + "location": "Lokalizacja", "hdr": "hdr", "filter": "filtr", "Current version: ": "Aktualna wersja: ", - "next_steps_error_message": "", - "next_steps_error_message_refresh": "", - "next_steps_error_message_go_to_youtube": "" + "next_steps_error_message": "Po czym powinien*ś spróbować: ", + "next_steps_error_message_refresh": "Odśwież", + "next_steps_error_message_go_to_youtube": "Przejdź do YouTube" } diff --git a/locales/pt-BR.json b/locales/pt-BR.json index 6c238025..f1ffb7a8 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -133,7 +133,7 @@ }, "`x` tokens": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens", - "": "`x` tokens" + "": "Símbolos `x`" }, "Import/export": "Importar/Exportar", "unsubscribe": "cancelar inscrição", @@ -145,7 +145,7 @@ }, "search": "Pesquisar", "Log out": "Sair", - "Released under the AGPLv3 by Omar Roth.": "Publicado sob a licença AGPLv3, por Omar Roth.", + "Released under the AGPLv3 on Github.": "Lançado sob a AGPLv3 no Github.", "Source available here.": "Código-fonte disponível aqui.", "View JavaScript license information.": "Ver informações da licença do JavaScript.", "View privacy policy.": "Ver a política de privacidade.", diff --git a/locales/pt-PT.json b/locales/pt-PT.json index 704a105f..a5e4bca8 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -145,7 +145,7 @@ }, "search": "Pesquisar", "Log out": "Terminar sessão", - "Released under the AGPLv3 by Omar Roth.": "Publicado sob a licença AGPLv3, por Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Código-fonte disponível aqui.", "View JavaScript license information.": "Ver informações da licença do JavaScript.", "View privacy policy.": "Ver a política de privacidade.", diff --git a/locales/ro.json b/locales/ro.json index ce961c39..a8877853 100644 --- a/locales/ro.json +++ b/locales/ro.json @@ -145,7 +145,7 @@ }, "search": "căutați", "Log out": "Deconectați-vă", - "Released under the AGPLv3 by Omar Roth.": "Publicat sub licența AGPLv3 de Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Codul sursă este disponibil aici.", "View JavaScript license information.": "Informații legate de licența JavaScript.", "View privacy policy.": "Politica de confidențialitate.", diff --git a/locales/ru.json b/locales/ru.json index 4896b3d0..d26cd058 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -145,7 +145,7 @@ }, "search": "поиск", "Log out": "Выйти", - "Released under the AGPLv3 by Omar Roth.": "Реализовано Омаром Ротом по лицензии AGPLv3.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Исходный код доступен здесь.", "View JavaScript license information.": "Посмотреть информацию по лицензии JavaScript.", "View privacy policy.": "Посмотреть политику конфиденциальности.", diff --git a/locales/si.json b/locales/si.json index f59629d0..f38c56b7 100644 --- a/locales/si.json +++ b/locales/si.json @@ -145,7 +145,7 @@ }, "search": "", "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", + "Released under the AGPLv3 on Github.": "", "Source available here.": "", "View JavaScript license information.": "", "View privacy policy.": "", diff --git a/locales/sk.json b/locales/sk.json index 32df0569..cdeca6c0 100644 --- a/locales/sk.json +++ b/locales/sk.json @@ -145,7 +145,7 @@ }, "search": "", "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", + "Released under the AGPLv3 on Github.": "", "Source available here.": "", "View JavaScript license information.": "", "View privacy policy.": "", diff --git a/locales/sr.json b/locales/sr.json index 83cc12c1..314f0367 100644 --- a/locales/sr.json +++ b/locales/sr.json @@ -145,7 +145,7 @@ }, "search": "", "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", + "Released under the AGPLv3 on Github.": "", "Source available here.": "", "View JavaScript license information.": "", "View privacy policy.": "", diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json index 92cfd103..056b79cb 100644 --- a/locales/sr_Cyrl.json +++ b/locales/sr_Cyrl.json @@ -145,7 +145,7 @@ }, "search": "претрага", "Log out": "Одјавите се", - "Released under the AGPLv3 by Omar Roth.": "Издао Омар Рот (Omar Roth) под условима AGPLv3 лиценце.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Изворни код доступан овде.", "View JavaScript license information.": "Прикажи информације о JavaScript лиценци.", "View privacy policy.": "Прикажи извештај о приватности.", diff --git a/locales/sv-SE.json b/locales/sv-SE.json index 7aaaab7b..ae8e6fc4 100644 --- a/locales/sv-SE.json +++ b/locales/sv-SE.json @@ -77,8 +77,8 @@ "Fallback captions: ": "Ersättningsundertexter: ", "Show related videos: ": "Visa relaterade videor? ", "Show annotations by default: ": "Visa länkar-i-videon som förval? ", - "Automatically extend video description: ": "", - "Interactive 360 degree videos: ": "", + "Automatically extend video description: ": "Förläng videobeskrivning automatiskt: ", + "Interactive 360 degree videos: ": "Interaktiva 360-gradervideos: ", "Visual preferences": "Visuella inställningar", "Player style: ": "Spelarstil: ", "Dark mode: ": "Mörkt läge: ", @@ -86,7 +86,7 @@ "dark": "Mörkt", "light": "Ljust", "Thin mode: ": "Lättviktigt läge: ", - "Miscellaneous preferences": "", + "Miscellaneous preferences": "Övriga inställningar", "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Prenumerationsinställningar", "Show annotations by default for subscribed channels: ": "Visa länkar-i-videor som förval för kanaler som prenumereras på? ", @@ -117,7 +117,7 @@ "Administrator preferences": "Administratörsinställningar", "Default homepage: ": "Förvald hemsida: ", "Feed menu: ": "Flödesmeny: ", - "Show nickname on top: ": "", + "Show nickname on top: ": "Visa smeknamn överst: ", "Top enabled: ": "Topp påslaget? ", "CAPTCHA enabled: ": "CAPTCHA påslaget? ", "Login enabled: ": "Inloggning påslaget? ", @@ -145,7 +145,7 @@ }, "search": "sök", "Log out": "Logga ut", - "Released under the AGPLv3 by Omar Roth.": "Utgiven under AGPLv3-licens av Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Källkod tillgänglig här.", "View JavaScript license information.": "Visa JavaScript-licensinformation.", "View privacy policy.": "Visa privatlivspolicy.", @@ -164,8 +164,8 @@ "Show more": "Visa mer", "Show less": "Visa mindre", "Watch on YouTube": "Titta på YouTube", - "Switch Invidious Instance": "", - "Broken? Try another Invidious Instance": "", + "Switch Invidious Instance": "Byt Invidious Instans", + "Broken? Try another Invidious Instance": "Trasig? Prova en annan Invidious Instance", "Hide annotations": "Dölj länkar-i-video", "Show annotations": "Visa länkar-i-video", "Genre: ": "Genre: ", @@ -397,10 +397,10 @@ "rating": "rankning", "date": "datum", "views": "visningar", - "content_type": "", - "duration": "", - "features": "", - "sort": "", + "content_type": "Typ", + "duration": "Varaktighet", + "features": "Funktioner", + "sort": "Sortera efter", "hour": "timme", "today": "idag", "week": "vecka", @@ -419,9 +419,9 @@ "4k": "4k", "location": "plats", "hdr": "hdr", - "filter": "", + "filter": "Filter", "Current version: ": "Nuvarande version: ", "next_steps_error_message": "", - "next_steps_error_message_refresh": "", - "next_steps_error_message_go_to_youtube": "" + "next_steps_error_message_refresh": "Uppdatera", + "next_steps_error_message_go_to_youtube": "Gå till Youtube" } diff --git a/locales/tr.json b/locales/tr.json index 01bb2ead..493f1295 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -145,7 +145,7 @@ }, "search": "ara", "Log out": "Çıkış yap", - "Released under the AGPLv3 by Omar Roth.": "Omar Roth tarafından AGPLv3 altında yayımlandı.", + "Released under the AGPLv3 on Github.": "Github'da AGPLv3 altında yayınlandı.", "Source available here.": "Kaynak kodları burada bulunabilir.", "View JavaScript license information.": "JavaScript lisans bilgilerini görüntüle.", "View privacy policy.": "Gizlilik politikasını görüntüle.", diff --git a/locales/uk.json b/locales/uk.json index e51aa5ba..5100206c 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -145,7 +145,7 @@ }, "search": "пошук", "Log out": "Вийти", - "Released under the AGPLv3 by Omar Roth.": "Реалізовано Омаром Ротом за ліцензією AGPLv3.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Програмний код доступний тут.", "View JavaScript license information.": "Переглянути інформацію щодо ліцензії JavaScript.", "View privacy policy.": "Переглянути політику приватності.", diff --git a/locales/vi.json b/locales/vi.json index d2e38ff6..5a2812f7 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -145,7 +145,7 @@ }, "search": "Tìm kiếm", "Log out": "Đăng xuất", - "Released under the AGPLv3 by Omar Roth.": "Được phát hành theo AGPLv3 bởi Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Nguồn có sẵn ở đây.", "View JavaScript license information.": "Xem thông tin giấy phép JavaScript.", "View privacy policy.": "Xem chính sách bảo mật.", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 4c1f9eae..5f89f964 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -145,7 +145,7 @@ }, "search": "搜索", "Log out": "登出", - "Released under the AGPLv3 by Omar Roth.": "由 Omar Roth 开发,以 AGPLv3 授权。", + "Released under the AGPLv3 on Github.": "依据 AGPLv3 许可证发布于 Github。", "Source available here.": "源码可在此查看。", "View JavaScript license information.": "查看 JavaScript 协议信息。", "View privacy policy.": "查看隐私政策。", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index e554b23a..96e04594 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -145,7 +145,7 @@ }, "search": "搜尋", "Log out": "登出", - "Released under the AGPLv3 by Omar Roth.": "Omar Roth 以 AGPLv3 釋出。", + "Released under the AGPLv3 on Github.": "在 GitHub 上以 AGPLv3 釋出。", "Source available here.": "原始碼在此提供。", "View JavaScript license information.": "檢視 JavaScript 授權條款資訊。", "View privacy policy.": "檢視隱私權政策。", diff --git a/shard.lock b/shard.lock index 35d1aefd..c5f89aef 100644 --- a/shard.lock +++ b/shard.lock @@ -1,20 +1,28 @@ version: 2.0 shards: + athena-negotiation: + git: https://github.com/athena-framework/negotiation.git + version: 0.1.1 + + backtracer: + git: https://github.com/Sija/backtracer.cr.git + version: 1.2.1 + db: git: https://github.com/crystal-lang/crystal-db.git version: 0.10.1 exception_page: git: https://github.com/crystal-loot/exception_page.git - version: 0.1.5 + version: 0.2.0 kemal: git: https://github.com/kemalcr/kemal.git - version: 1.0.0 + version: 1.1.0 kilt: git: https://github.com/jeromegn/kilt.git - version: 0.4.1 + version: 0.6.1 lsquic: git: https://github.com/iv-org/lsquic.cr.git @@ -22,7 +30,7 @@ shards: pg: git: https://github.com/will/crystal-pg.git - version: 0.23.2 + version: 0.24.0 protodec: git: https://github.com/iv-org/protodec.git diff --git a/shard.yml b/shard.yml index 2df4909c..b32054e6 100644 --- a/shard.yml +++ b/shard.yml @@ -12,19 +12,22 @@ targets: dependencies: pg: github: will/crystal-pg - version: ~> 0.23.2 + version: ~> 0.24.0 sqlite3: github: crystal-lang/crystal-sqlite3 version: ~> 0.18.0 kemal: github: kemalcr/kemal - version: ~> 1.0.0 + version: ~> 1.1.0 protodec: github: iv-org/protodec version: ~> 0.1.4 lsquic: github: iv-org/lsquic.cr version: ~> 2.18.1-2 + athena-negotiation: + github: athena-framework/negotiation + version: ~> 0.1.1 crystal: ">= 1.0.0, < 2.0.0" diff --git a/src/invidious.cr b/src/invidious.cr index e4fe995d..0f720a2e 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -17,6 +17,7 @@ require "digest/md5" require "file_utils" require "kemal" +require "athena-negotiation" require "openssl/hmac" require "option_parser" require "pg" @@ -166,10 +167,20 @@ def popular_videos end before_all do |env| - preferences = begin - Preferences.from_json(URI.decode_www_form(env.request.cookies["PREFS"]?.try &.value || "{}")) + 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.from_json("{}") + preferences = Preferences.from_json("{}") end env.set "preferences", preferences @@ -338,7 +349,6 @@ Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_red Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show -Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Playlists, :index 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 @@ -363,6 +373,31 @@ Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :sho Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme +# 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 + +# API routes (macro) +define_v1_api_routes() + +# Video playback (macros) +define_api_manifest_routes() +define_video_playback_routes() + # Users post "/watch_ajax" do |env| @@ -813,6 +848,7 @@ post "/data_control" do |env| if match = channel["url"].as_s.match(/\/channel\/(?UC[a-zA-Z0-9_-]{22})/) next match["channel"] elsif match = channel["url"].as_s.match(/\/user\/(?.+)/) + #response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US") response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=JP") html = XML.parse_html(response.body) ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] @@ -1179,430 +1215,6 @@ post "/token_ajax" do |env| end end -# Feeds - -get "/feed/playlists" do |env| - env.redirect "/view_all_playlists" -end - -get "/feed/top" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - message = translate(locale, "The Top feed has been removed from Invidious.") - templated "message" -end - -get "/feed/popular" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - if CONFIG.popular_enabled - templated "popular" - else - message = translate(locale, "The Popular feed has been disabled by the administrator.") - templated "message" - end -end - -get "/feed/trending" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - trending_type = env.params.query["type"]? - trending_type ||= "Default" - - region = env.params.query["region"]? - region ||= "JP" - #region ||= "US" - - begin - trending, plid = fetch_trending(trending_type, region, locale) - rescue ex - next error_template(500, ex) - end - - templated "trending" -end - -get "/feed/subscriptions" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - sid = sid.as(String) - token = user.token - - if user.preferences.unseen_only - env.set "show_watched", true - end - - # Refresh account - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - if !user.password - user, sid = get_user(sid, headers, PG_DB) - end - - max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) - max_results ||= user.preferences.max_results - max_results ||= CONFIG.default_user_preferences.max_results - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) - - # "updated" here is used for delivering new notifications, so if - # we know a user has looked at their feed e.g. in the past 10 minutes, - # they've already seen a video posted 20 minutes ago, and don't need - # to be notified. - PG_DB.exec("UPDATE users SET notifications = $1, updated = $2 WHERE email = $3", [] of String, Time.utc, - user.email) - user.notifications = [] of String - env.set "user", user - - templated "subscriptions" -end - -get "/feed/history" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - referer = get_referer(env) - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - if !user - next env.redirect referer - end - - user = user.as(User) - - max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) - max_results ||= user.preferences.max_results - max_results ||= CONFIG.default_user_preferences.max_results - - if user.watched[(page - 1) * max_results]? - watched = user.watched.reverse[(page - 1) * max_results, max_results] - end - watched ||= [] of String - - templated "history" -end - -get "/feed/channel/:ucid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/atom+xml" - - ucid = env.params.url["ucid"] - - params = HTTP::Params.parse(env.params.query["params"]? || "") - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - next env.redirect env.request.resource.gsub(ucid, ex.channel_id) - rescue ex - next error_atom(500, ex) - end - - response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}") - rss = XML.parse_html(response.body) - - videos = rss.xpath_nodes("//feed/entry").map do |entry| - video_id = entry.xpath_node("videoid").not_nil!.content - title = entry.xpath_node("title").not_nil!.content - - published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) - updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) - - author = entry.xpath_node("author/name").not_nil!.content - ucid = entry.xpath_node("channelid").not_nil!.content - description_html = entry.xpath_node("group/description").not_nil!.to_s - views = entry.xpath_node("group/community/statistics").not_nil!.["views"].to_i64 - - SearchVideo.new({ - title: title, - id: video_id, - author: author, - ucid: ucid, - published: published, - views: views, - description_html: description_html, - length_seconds: 0, - live_now: false, - paid: false, - premium: false, - premiere_timestamp: nil, - }) - end - - XML.build(indent: " ", encoding: "UTF-8") do |xml| - xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", - "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", - "xml:lang": "ja-JP") do - #"xml:lang": "en-US") do - xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") - xml.element("id") { xml.text "yt:channel:#{channel.ucid}" } - xml.element("yt:channelId") { xml.text channel.ucid } - xml.element("icon") { xml.text channel.author_thumbnail } - xml.element("title") { xml.text channel.author } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}") - - xml.element("author") do - xml.element("name") { xml.text channel.author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" } - end - - videos.each do |video| - video.to_xml(channel.auto_generated, params, xml) - end - end - end -end - -get "/feed/private" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/atom+xml" - - token = env.params.query["token"]? - - if !token - env.response.status_code = 403 - next - end - - user = PG_DB.query_one?("SELECT * FROM users WHERE token = $1", token.strip, as: User) - if !user - env.response.status_code = 403 - next - end - - max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) - max_results ||= user.preferences.max_results - max_results ||= CONFIG.default_user_preferences.max_results - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - params = HTTP::Params.parse(env.params.query["params"]? || "") - - videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) - - XML.build(indent: " ", encoding: "UTF-8") do |xml| - xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", - "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", - "xml:lang": "ja-JP") do - #"xml:lang": "en-US") do - xml.element("link", "type": "text/html", rel: "alternate", href: "#{HOST_URL}/feed/subscriptions") - xml.element("link", "type": "application/atom+xml", rel: "self", - href: "#{HOST_URL}#{env.request.resource}") - xml.element("title") { xml.text translate(locale, "Invidious Private Feed for `x`", user.email) } - - (notifications + videos).each do |video| - video.to_xml(locale, params, xml) - end - end - end -end - -get "/feed/playlist/:plid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/atom+xml" - - plid = env.params.url["plid"] - - params = HTTP::Params.parse(env.params.query["params"]? || "") - path = env.request.path - - if plid.starts_with? "IV" - if playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - videos = get_playlist_videos(PG_DB, playlist, offset: 0, locale: locale) - - next XML.build(indent: " ", encoding: "UTF-8") do |xml| - xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", - "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", - "xml:lang": "ja-JP") do - #"xml:lang": "en-US") do - xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") - xml.element("id") { xml.text "iv:playlist:#{plid}" } - xml.element("iv:playlistId") { xml.text plid } - xml.element("title") { xml.text playlist.title } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/playlist?list=#{plid}") - - xml.element("author") do - xml.element("name") { xml.text playlist.author } - end - - videos.each do |video| - video.to_xml(false, xml) - end - end - end - else - env.response.status_code = 404 - next - end - end - - response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}") - document = XML.parse(response.body) - - document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node| - node.attributes.each do |attribute| - case attribute.name - when "url", "href" - request_target = URI.parse(node[attribute.name]).request_target - query_string_opt = request_target.starts_with?("/watch?v=") ? "&#{params}" : "" - node[attribute.name] = "#{HOST_URL}#{request_target}#{query_string_opt}" - else nil # Skip - end - end - end - - document = document.to_xml(options: XML::SaveOptions::NO_DECL) - - document.scan(/(?[^<]+)<\/uri>/).each do |match| - content = "#{HOST_URL}#{URI.parse(match["url"]).request_target}" - document = document.gsub(match[0], "#{content}") - end - - document -end - -get "/feeds/videos.xml" do |env| - if ucid = env.params.query["channel_id"]? - env.redirect "/feed/channel/#{ucid}" - elsif user = env.params.query["user"]? - env.redirect "/feed/channel/#{user}" - elsif plid = env.params.query["playlist_id"]? - env.redirect "/feed/playlist/#{plid}" - end -end - -# Support push notifications via PubSubHubbub - -get "/feed/webhook/:token" do |env| - verify_token = env.params.url["token"] - - mode = env.params.query["hub.mode"]? - topic = env.params.query["hub.topic"]? - challenge = env.params.query["hub.challenge"]? - - if !mode || !topic || !challenge - env.response.status_code = 400 - next - else - mode = mode.not_nil! - topic = topic.not_nil! - challenge = challenge.not_nil! - end - - case verify_token - when .starts_with? "v1" - _, time, nonce, signature = verify_token.split(":") - data = "#{time}:#{nonce}" - when .starts_with? "v2" - time, signature = verify_token.split(":") - data = "#{time}" - else - env.response.status_code = 400 - next - end - - # The hub will sometimes check if we're still subscribed after delivery errors, - # so we reply with a 200 as long as the request hasn't expired - if Time.utc.to_unix - time.to_i > 432000 - env.response.status_code = 400 - next - end - - if OpenSSL::HMAC.hexdigest(:sha1, HMAC_KEY, data) != signature - env.response.status_code = 400 - next - end - - if ucid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["channel_id"]? - PG_DB.exec("UPDATE channels SET subscribed = $1 WHERE id = $2", Time.utc, ucid) - elsif plid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["playlist_id"]? - PG_DB.exec("UPDATE playlists SET subscribed = $1 WHERE id = $2", Time.utc, ucid) - else - env.response.status_code = 400 - next - end - - env.response.status_code = 200 - challenge -end - -post "/feed/webhook/:token" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - token = env.params.url["token"] - body = env.request.body.not_nil!.gets_to_end - signature = env.request.headers["X-Hub-Signature"].lchop("sha1=") - - if signature != OpenSSL::HMAC.hexdigest(:sha1, HMAC_KEY, body) - LOGGER.error("/feed/webhook/#{token} : Invalid signature") - env.response.status_code = 200 - next - end - - spawn do - rss = XML.parse_html(body) - rss.xpath_nodes("//feed/entry").each do |entry| - id = entry.xpath_node("videoid").not_nil!.content - author = entry.xpath_node("author/name").not_nil!.content - published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) - updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) - - video = get_video(id, PG_DB, force_refresh: true) - - # Deliver notifications to `/api/v1/auth/notifications` - payload = { - "topic" => video.ucid, - "videoId" => video.id, - "published" => published.to_unix, - }.to_json - PG_DB.exec("NOTIFY notifications, E'#{payload}'") - - video = ChannelVideo.new({ - id: id, - title: video.title, - published: published, - updated: updated, - ucid: video.ucid, - author: author, - length_seconds: video.length_seconds, - live_now: video.live_now, - premiere_timestamp: video.premiere_timestamp, - views: video.views, - }) - - was_insert = PG_DB.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, - updated = $4, ucid = $5, author = $6, length_seconds = $7, - live_now = $8, premiere_timestamp = $9, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) - - PG_DB.exec("UPDATE users SET notifications = array_append(notifications, $1), - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert - end - end - - env.response.status_code = 200 - next -end - # Channels {"/channel/:ucid/live", "/user/:user/live", "/c/:user/live"}.each do |route| @@ -1640,923 +1252,12 @@ end end end -# API Endpoints - -get "/api/v1/stats" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - env.response.content_type = "application/json" - - if !CONFIG.statistics_enabled - next error_json(400, "Statistics are not enabled.") - end - - Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json -end - -# YouTube provides "storyboards", which are sprites containing x * y -# preview thumbnails for individual scenes in a video. -# See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails -get "/api/v1/storyboards/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - region = env.params.query["region"]? - - begin - video = get_video(id, PG_DB, region: region) - rescue ex : VideoRedirect - env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - next error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) - rescue ex - env.response.status_code = 500 - next - end - - storyboards = video.storyboards - width = env.params.query["width"]? - height = env.params.query["height"]? - - if !width && !height - response = JSON.build do |json| - json.object do - json.field "storyboards" do - generate_storyboards(json, id, storyboards) - end - end - end - - next response - end - - env.response.content_type = "text/vtt" - - storyboard = storyboards.select { |storyboard| width == "#{storyboard[:width]}" || height == "#{storyboard[:height]}" } - - if storyboard.empty? - env.response.status_code = 404 - next - else - storyboard = storyboard[0] - end - - String.build do |str| - str << <<-END_VTT - WEBVTT - - - END_VTT - - start_time = 0.milliseconds - end_time = storyboard[:interval].milliseconds - - storyboard[:storyboard_count].times do |i| - url = storyboard[:url] - authority = /(i\d?).ytimg.com/.match(url).not_nil![1]? - url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "") - url = "#{HOST_URL}/sb/#{authority}/#{url}" - - storyboard[:storyboard_height].times do |j| - storyboard[:storyboard_width].times do |k| - str << <<-END_CUE - #{start_time}.000 --> #{end_time}.000 - #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]} - - - END_CUE - - start_time += storyboard[:interval].milliseconds - end_time += storyboard[:interval].milliseconds - end - end - end - end -end - -get "/api/v1/captions/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - region = env.params.query["region"]? - - # See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354 - # It is possible to use `/api/timedtext?type=list&v=#{id}` and - # `/api/timedtext?type=track&v=#{id}&lang=#{lang_code}` directly, - # but this does not provide links for auto-generated captions. - # - # In future this should be investigated as an alternative, since it does not require - # getting video info. - - begin - video = get_video(id, PG_DB, region: region) - rescue ex : VideoRedirect - env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - next error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) - rescue ex - env.response.status_code = 500 - next - end - - captions = video.captions - - label = env.params.query["label"]? - lang = env.params.query["lang"]? - tlang = env.params.query["tlang"]? - - if !label && !lang - response = JSON.build do |json| - json.object do - json.field "captions" do - json.array do - captions.each do |caption| - json.object do - json.field "label", caption.name - json.field "languageCode", caption.languageCode - json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" - end - end - end - end - end - end - - next response - end - - env.response.content_type = "text/vtt; charset=UTF-8" - - if lang - caption = captions.select { |caption| caption.languageCode == lang } - else - caption = captions.select { |caption| caption.name == label } - end - - if caption.empty? - env.response.status_code = 404 - next - else - caption = caption[0] - end - - url = URI.parse("#{caption.baseUrl}&tlang=#{tlang}").request_target - - # Auto-generated captions often have cues that aren't aligned properly with the video, - # as well as some other markup that makes it cumbersome, so we try to fix that here - if caption.name.includes? "auto-generated" - caption_xml = YT_POOL.client &.get(url).body - caption_xml = XML.parse(caption_xml) - - webvtt = String.build do |str| - str << <<-END_VTT - WEBVTT - Kind: captions - Language: #{tlang || caption.languageCode} - - - END_VTT - - caption_nodes = caption_xml.xpath_nodes("//transcript/text") - caption_nodes.each_with_index do |node, i| - start_time = node["start"].to_f.seconds - duration = node["dur"]?.try &.to_f.seconds - duration ||= start_time - - if caption_nodes.size > i + 1 - end_time = caption_nodes[i + 1]["start"].to_f.seconds - else - end_time = start_time + duration - end - - start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" - end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" - - text = HTML.unescape(node.content) - text = text.gsub(//, "") - text = text.gsub(/<\/font>/, "") - if md = text.match(/(?.*) : (?.*)/) - text = "#{md["text"]}" - end - - str << <<-END_CUE - #{start_time} --> #{end_time} - #{text} - - - END_CUE - end - end - else - webvtt = YT_POOL.client &.get("#{url}&format=vtt").body - end - - if title = env.params.query["title"]? - # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ - env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" - end - - webvtt -end - -get "/api/v1/comments/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - region = env.params.query["region"]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - - source = env.params.query["source"]? - source ||= "youtube" - - thin_mode = env.params.query["thin_mode"]? - thin_mode = thin_mode == "true" - - format = env.params.query["format"]? - format ||= "json" - - action = env.params.query["action"]? - action ||= "action_get_comments" - - continuation = env.params.query["continuation"]? - sort_by = env.params.query["sort_by"]?.try &.downcase - - if source == "youtube" - sort_by ||= "top" - - begin - comments = fetch_youtube_comments(id, PG_DB, continuation, format, locale, thin_mode, region, sort_by: sort_by, action: action) - rescue ex - next error_json(500, ex) - end - - next comments - elsif source == "reddit" - sort_by ||= "confidence" - - begin - comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by) - content_html = template_reddit_comments(comments, locale) - - content_html = fill_links(content_html, "https", "www.reddit.com") - content_html = replace_links(content_html) - rescue ex - comments = nil - reddit_thread = nil - content_html = "" - end - - if !reddit_thread || !comments - env.response.status_code = 404 - next - end - - if format == "json" - reddit_thread = JSON.parse(reddit_thread.to_json).as_h - reddit_thread["comments"] = JSON.parse(comments.to_json) - - next reddit_thread.to_json - else - response = { - "title" => reddit_thread.title, - "permalink" => reddit_thread.permalink, - "contentHtml" => content_html, - } - - next response.to_json - end - end -end - -get "/api/v1/insights/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - next error_json(410, "YouTube has removed publicly available analytics.") -end - -get "/api/v1/annotations/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "text/xml" - - id = env.params.url["id"] - source = env.params.query["source"]? - source ||= "archive" - - if !id.match(/[a-zA-Z0-9_-]{11}/) - env.response.status_code = 400 - next - end - - annotations = "" - - case source - when "archive" - if CONFIG.cache_annotations && (cached_annotation = PG_DB.query_one?("SELECT * FROM annotations WHERE id = $1", id, as: Annotation)) - annotations = cached_annotation.annotations - else - index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0') - - # IA doesn't handle leading hyphens, - # so we use https://archive.org/details/youtubeannotations_64 - if index == "62" - index = "64" - id = id.sub(/^-/, 'A') - end - - file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml") - - location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}")) - - if !location.headers["Location"]? - env.response.status_code = location.status_code - end - - response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"])) - - if response.body.empty? - env.response.status_code = 404 - next - end - - if response.status_code != 200 - env.response.status_code = response.status_code - next - end - - annotations = response.body - - cache_annotation(PG_DB, id, annotations) - end - else # "youtube" - response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") - - if response.status_code != 200 - env.response.status_code = response.status_code - next - end - - annotations = response.body - end - - etag = sha256(annotations)[0, 16] - if env.request.headers["If-None-Match"]?.try &.== etag - env.response.status_code = 304 - else - env.response.headers["ETag"] = etag - annotations - end -end - -get "/api/v1/videos/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - region = env.params.query["region"]? - - begin - video = get_video(id, PG_DB, region: region) - rescue ex : VideoRedirect - env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - next error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) - rescue ex - next error_json(500, ex) - end - - video.to_json(locale) -end - -get "/api/v1/trending" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - region = env.params.query["region"]? - trending_type = env.params.query["type"]? - - begin - trending, plid = fetch_trending(trending_type, region, locale) - rescue ex - next error_json(500, ex) - end - - videos = JSON.build do |json| - json.array do - trending.each do |video| - video.to_json(locale, json) - end - end - end - - videos -end - -get "/api/v1/popular" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - if !CONFIG.popular_enabled - error_message = {"error" => "Administrator has disabled this endpoint."}.to_json - env.response.status_code = 400 - next error_message - end - - JSON.build do |json| - json.array do - popular_videos.each do |video| - video.to_json(locale, json) - end - end - end -end - -get "/api/v1/top" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - env.response.status_code = 400 - {"error" => "The Top feed has been removed from Invidious."}.to_json -end - -get "/api/v1/channels/:ucid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - sort_by = env.params.query["sort_by"]?.try &.downcase - sort_by ||= "newest" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex - next error_json(500, ex) - end - - page = 1 - if channel.auto_generated - videos = [] of SearchVideo - count = 0 - else - begin - count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - rescue ex - next error_json(500, ex) - end - end - - JSON.build do |json| - # TODO: Refactor into `to_json` for InvidiousChannel - json.object do - json.field "author", channel.author - json.field "authorId", channel.ucid - json.field "authorUrl", channel.author_url - - json.field "authorBanners" do - json.array do - if channel.banner - qualities = { - {width: 2560, height: 424}, - {width: 2120, height: 351}, - {width: 1060, height: 175}, - } - qualities.each do |quality| - json.object do - json.field "url", channel.banner.not_nil!.gsub("=w1060-", "=w#{quality[:width]}-") - json.field "width", quality[:width] - json.field "height", quality[:height] - end - end - - json.object do - json.field "url", channel.banner.not_nil!.split("=w1060-")[0] - json.field "width", 512 - json.field "height", 288 - end - end - end - end - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", channel.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "subCount", channel.sub_count - json.field "totalViews", channel.total_views - json.field "joined", channel.joined.to_unix - json.field "paid", channel.paid - - json.field "autoGenerated", channel.auto_generated - json.field "isFamilyFriendly", channel.is_family_friendly - json.field "description", html_to_content(channel.description_html) - json.field "descriptionHtml", channel.description_html - - json.field "allowedRegions", channel.allowed_regions - - json.field "latestVideos" do - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - - json.field "relatedChannels" do - json.array do - channel.related_channels.each do |related_channel| - json.object do - json.field "author", related_channel.author - json.field "authorId", related_channel.ucid - json.field "authorUrl", related_channel.author_url - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - end - end - end - end - end - end -end - -{"/api/v1/channels/:ucid/videos", "/api/v1/channels/videos/:ucid"}.each do |route| - get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - sort_by = env.params.query["sort"]?.try &.downcase - sort_by ||= env.params.query["sort_by"]?.try &.downcase - sort_by ||= "newest" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex - next error_json(500, ex) - end - - begin - count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - rescue ex - next error_json(500, ex) - end - - JSON.build do |json| - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - end -end - -{"/api/v1/channels/:ucid/latest", "/api/v1/channels/latest/:ucid"}.each do |route| - get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - begin - videos = get_latest_videos(ucid) - rescue ex - next error_json(500, ex) - end - - JSON.build do |json| - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - end -end - -{"/api/v1/channels/:ucid/playlists", "/api/v1/channels/playlists/:ucid"}.each do |route| - get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - continuation = env.params.query["continuation"]? - sort_by = env.params.query["sort"]?.try &.downcase || - env.params.query["sort_by"]?.try &.downcase || - "last" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex - next error_json(500, ex) - end - - items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) - - JSON.build do |json| - json.object do - json.field "playlists" do - json.array do - items.each do |item| - item.to_json(locale, json) if item.is_a?(SearchPlaylist) - end - end - end - - json.field "continuation", continuation - end - end - end -end - -{"/api/v1/channels/:ucid/comments", "/api/v1/channels/comments/:ucid"}.each do |route| - get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - thin_mode = env.params.query["thin_mode"]? - thin_mode = thin_mode == "true" - - format = env.params.query["format"]? - format ||= "json" - - continuation = env.params.query["continuation"]? - # sort_by = env.params.query["sort_by"]?.try &.downcase - - begin - fetch_channel_community(ucid, continuation, locale, format, thin_mode) - rescue ex - next error_json(500, ex) - end - end -end - -get "/api/v1/channels/search/:ucid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - query = env.params.query["q"]? - query ||= "" - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - count, search_results = channel_search(query, page, ucid) - JSON.build do |json| - json.array do - search_results.each do |item| - item.to_json(locale, json) - end - end - end -end - -get "/api/v1/search" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - region = env.params.query["region"]? - - env.response.content_type = "application/json" - - query = env.params.query["q"]? - query ||= "" - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - sort_by = env.params.query["sort_by"]?.try &.downcase - sort_by ||= "relevance" - - date = env.params.query["date"]?.try &.downcase - date ||= "" - - duration = env.params.query["duration"]?.try &.downcase - duration ||= "" - - features = env.params.query["features"]?.try &.split(",").map { |feature| feature.downcase } - features ||= [] of String - - content_type = env.params.query["type"]?.try &.downcase - content_type ||= "video" - - begin - search_params = produce_search_params(page, sort_by, date, content_type, duration, features) - rescue ex - next error_json(400, ex) - end - - count, search_results = search(query, search_params, region).as(Tuple) - JSON.build do |json| - json.array do - search_results.each do |item| - item.to_json(locale, json) - end - end - end -end - -get "/api/v1/search/suggestions" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - region = env.params.query["region"]? - - env.response.content_type = "application/json" - - query = env.params.query["q"]? - query ||= "" - - begin - headers = HTTP::Headers{":authority" => "suggestqueries.google.com"} - response = YT_POOL.client &.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback", headers).body - - body = response[35..-2] - body = JSON.parse(body).as_a - suggestions = body[1].as_a[0..-2] - - JSON.build do |json| - json.object do - json.field "query", body[0].as_s - json.field "suggestions" do - json.array do - suggestions.each do |suggestion| - json.string suggestion[0].as_s - end - end - end - end - end - rescue ex - next error_json(500, ex) - end -end - -{"/api/v1/playlists/:plid", "/api/v1/auth/playlists/:plid"}.each do |route| - get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - plid = env.params.url["plid"] - - offset = env.params.query["index"]?.try &.to_i? - offset ||= env.params.query["page"]?.try &.to_i?.try { |page| (page - 1) * 100 } - offset ||= 0 - - continuation = env.params.query["continuation"]? - - format = env.params.query["format"]? - format ||= "json" - - if plid.starts_with? "RD" - next env.redirect "/api/v1/mixes/#{plid}" - end - - begin - playlist = get_playlist(PG_DB, plid, locale) - rescue ex : InfoException - next error_json(404, ex) - rescue ex - next error_json(404, "Playlist does not exist.") - end - - user = env.get?("user").try &.as(User) - if !playlist || playlist.privacy.private? && playlist.author != user.try &.email - next error_json(404, "Playlist does not exist.") - end - - response = playlist.to_json(offset, locale, continuation: continuation) - - 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} - - response = { - "playlistHtml" => playlist_html, - "index" => index, - "nextVideo" => next_video, - }.to_json - end - - response - end -end - -get "/api/v1/mixes/:rdid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - rdid = env.params.url["rdid"] - - continuation = env.params.query["continuation"]? - continuation ||= rdid.lchop("RD")[0, 11] - - format = env.params.query["format"]? - format ||= "json" - - begin - mix = fetch_mix(rdid, continuation, locale: locale) - - if !rdid.ends_with? continuation - mix = fetch_mix(rdid, mix.videos[1].id) - index = mix.videos.index(mix.videos.select { |video| video.id == continuation }[0]?) - end - - mix.videos = mix.videos[index..-1] - rescue ex - next error_json(500, ex) - end - - response = JSON.build do |json| - json.object do - json.field "title", mix.title - json.field "mixId", mix.id - - json.field "videos" do - json.array do - mix.videos.each do |video| - json.object do - json.field "title", video.title - json.field "videoId", video.id - json.field "author", video.author - - json.field "authorId", video.ucid - json.field "authorUrl", "/channel/#{video.ucid}" - - json.field "videoThumbnails" do - json.array do - generate_thumbnails(json, video.id) - end - end - - json.field "index", video.index - json.field "lengthSeconds", video.length_seconds - end - end - end - end - end - end - - if format == "html" - response = JSON.parse(response) - playlist_html = template_mix(response) - next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"] - - response = { - "playlistHtml" => playlist_html, - "nextVideo" => next_video, - }.to_json - end - - response -end - # Authenticated endpoints +# The notification APIs can't be extracted yet +# due to the requirement of the `connection_channel` +# used by the `NotificationJob` + get "/api/v1/auth/notifications" do |env| env.response.content_type = "text/event-stream" @@ -2575,917 +1276,6 @@ post "/api/v1/auth/notifications" do |env| create_notification_stream(env, topics, connection_channel) end -get "/api/v1/auth/preferences" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - user.preferences.to_json -end - -post "/api/v1/auth/preferences" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - - begin - preferences = Preferences.from_json(env.request.body || "{}") - rescue - preferences = user.preferences - end - - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) - - env.response.status_code = 204 -end - -get "/api/v1/auth/feed" do |env| - env.response.content_type = "application/json" - - user = env.get("user").as(User) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - max_results = env.params.query["max_results"]?.try &.to_i? - max_results ||= user.preferences.max_results - max_results ||= CONFIG.default_user_preferences.max_results - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) - - JSON.build do |json| - json.object do - json.field "notifications" do - json.array do - notifications.each do |video| - video.to_json(locale, json) - end - end - end - - json.field "videos" do - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - end - end -end - -get "/api/v1/auth/subscriptions" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - - if user.subscriptions.empty? - values = "'{}'" - else - values = "VALUES #{user.subscriptions.map { |id| %(('#{id}')) }.join(",")}" - end - - subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY(#{values})", as: InvidiousChannel) - - JSON.build do |json| - json.array do - subscriptions.each do |subscription| - json.object do - json.field "author", subscription.author - json.field "authorId", subscription.id - end - end - end - end -end - -post "/api/v1/auth/subscriptions/:ucid" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - - ucid = env.params.url["ucid"] - - if !user.subscriptions.includes? ucid - get_channel(ucid, PG_DB, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email) - end - - # For Google accounts, access tokens don't have enough information to - # make a request on the user's behalf, which is why we don't sync with - # YouTube. - - env.response.status_code = 204 -end - -delete "/api/v1/auth/subscriptions/:ucid" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - - ucid = env.params.url["ucid"] - - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", ucid, user.email) - - env.response.status_code = 204 -end - -get "/api/v1/auth/playlists" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - user = env.get("user").as(User) - - playlists = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1", user.email, as: InvidiousPlaylist) - - JSON.build do |json| - json.array do - playlists.each do |playlist| - playlist.to_json(0, locale, json) - end - end - end -end - -post "/api/v1/auth/playlists" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150) - if !title - next error_json(400, "Invalid title.") - end - - privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) } - if !privacy - next error_json(400, "Invalid privacy setting.") - end - - if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 - next error_json(400, "User cannot have more than 100 playlists.") - end - - playlist = create_playlist(PG_DB, title, privacy, user) - env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{playlist.id}" - env.response.status_code = 201 - { - "title" => title, - "playlistId" => playlist.id, - }.to_json -end - -patch "/api/v1/auth/playlists/:plid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - user = env.get("user").as(User) - - plid = env.params.url["plid"] - - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - if !playlist || playlist.author != user.email && playlist.privacy.private? - next error_json(404, "Playlist does not exist.") - end - - if playlist.author != user.email - next error_json(403, "Invalid user") - end - - title = env.params.json["title"].try &.as(String).delete("<>").byte_slice(0, 150) || playlist.title - privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) } || playlist.privacy - description = env.params.json["description"]?.try &.as(String).delete("\r") || playlist.description - - if title != playlist.title || - privacy != playlist.privacy || - description != playlist.description - updated = Time.utc - else - updated = playlist.updated - end - - PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid) - env.response.status_code = 204 -end - -delete "/api/v1/auth/playlists/:plid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - user = env.get("user").as(User) - - plid = env.params.url["plid"] - - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - if !playlist || playlist.author != user.email && playlist.privacy.private? - next error_json(404, "Playlist does not exist.") - end - - if playlist.author != user.email - next error_json(403, "Invalid user") - end - - PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid) - PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid) - - env.response.status_code = 204 -end - -post "/api/v1/auth/playlists/:plid/videos" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - user = env.get("user").as(User) - - plid = env.params.url["plid"] - - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - if !playlist || playlist.author != user.email && playlist.privacy.private? - next error_json(404, "Playlist does not exist.") - end - - if playlist.author != user.email - next error_json(403, "Invalid user") - end - - if playlist.index.size >= 500 - next error_json(400, "Playlist cannot have more than 500 videos") - end - - video_id = env.params.json["videoId"].try &.as(String) - if !video_id - next error_json(403, "Invalid videoId") - end - - begin - video = get_video(video_id, PG_DB) - rescue ex - next error_json(500, ex) - end - - playlist_video = PlaylistVideo.new({ - title: video.title, - id: video.id, - author: video.author, - ucid: video.ucid, - length_seconds: video.length_seconds, - published: video.published, - plid: plid, - live_now: video.live_now, - index: Random::Secure.rand(0_i64..Int64::MAX), - }) - - video_array = playlist_video.to_a - args = arg_array(video_array) - - PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array) - PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, plid) - - env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{plid}/videos/#{playlist_video.index.to_u64.to_s(16).upcase}" - env.response.status_code = 201 - playlist_video.to_json(locale, index: playlist.index.size) -end - -delete "/api/v1/auth/playlists/:plid/videos/:index" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - user = env.get("user").as(User) - - plid = env.params.url["plid"] - index = env.params.url["index"].to_i64(16) - - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - if !playlist || playlist.author != user.email && playlist.privacy.private? - next error_json(404, "Playlist does not exist.") - end - - if playlist.author != user.email - next error_json(403, "Invalid user") - end - - if !playlist.index.includes? index - next error_json(404, "Playlist does not contain index") - end - - PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index) - PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = cardinality(index) - 1, updated = $2 WHERE id = $3", index, Time.utc, plid) - - env.response.status_code = 204 -end - -# patch "/api/v1/auth/playlists/:plid/videos/:index" do |env| -# TODO: Playlist stub -# end - -get "/api/v1/auth/tokens" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - scopes = env.get("scopes").as(Array(String)) - - tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1", user.email, as: {session: String, issued: Time}) - - JSON.build do |json| - json.array do - tokens.each do |token| - json.object do - json.field "session", token[:session] - json.field "issued", token[:issued].to_unix - end - end - end - end -end - -post "/api/v1/auth/tokens/register" do |env| - user = env.get("user").as(User) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - case env.request.headers["Content-Type"]? - when "application/x-www-form-urlencoded" - scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v } - callback_url = env.params.body["callbackUrl"]? - expire = env.params.body["expire"]?.try &.to_i? - when "application/json" - scopes = env.params.json["scopes"].as(Array).map { |v| v.as_s } - callback_url = env.params.json["callbackUrl"]?.try &.as(String) - expire = env.params.json["expire"]?.try &.as(Int64) - else - next error_json(400, "Invalid or missing header 'Content-Type'") - end - - if callback_url && callback_url.empty? - callback_url = nil - end - - if callback_url - callback_url = URI.parse(callback_url) - end - - if sid = env.get?("sid").try &.as(String) - env.response.content_type = "text/html" - - csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, PG_DB, use_nonce: true) - next templated "authorize_token" - else - env.response.content_type = "application/json" - - superset_scopes = env.get("scopes").as(Array(String)) - - authorized_scopes = [] of String - scopes.each do |scope| - if scopes_include_scope(superset_scopes, scope) - authorized_scopes << scope - end - end - - access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY, PG_DB) - - if callback_url - access_token = URI.encode_www_form(access_token) - - if query = callback_url.query - query = HTTP::Params.parse(query.not_nil!) - else - query = HTTP::Params.new - end - - query["token"] = access_token - callback_url.query = query.to_s - - env.redirect callback_url.to_s - else - access_token - end - end -end - -post "/api/v1/auth/tokens/unregister" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - env.response.content_type = "application/json" - user = env.get("user").as(User) - scopes = env.get("scopes").as(Array(String)) - - session = env.params.json["session"]?.try &.as(String) - session ||= env.get("session").as(String) - - # Allow tokens to revoke other tokens with correct scope - if session == env.get("session").as(String) - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) - elsif scopes_include_scope(scopes, "GET:tokens") - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) - else - next error_json(400, "Cannot revoke session #{session}") - end - - env.response.status_code = 204 -end - -get "/api/manifest/dash/id/videoplayback" do |env| - env.response.headers.delete("Content-Type") - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.redirect "/videoplayback?#{env.params.query}" -end - -get "/api/manifest/dash/id/videoplayback/*" do |env| - env.response.headers.delete("Content-Type") - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.redirect env.request.path.lchop("/api/manifest/dash/id") -end - -get "/api/manifest/dash/id/:id" do |env| - env.response.headers.add("Access-Control-Allow-Origin", "*") - env.response.content_type = "application/dash+xml" - - local = env.params.query["local"]?.try &.== "true" - id = env.params.url["id"] - region = env.params.query["region"]? - - # Since some implementations create playlists based on resolution regardless of different codecs, - # we can opt to only add a source to a representation if it has a unique height within that representation - unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe } - - begin - video = get_video(id, PG_DB, region: region) - rescue ex : VideoRedirect - next env.redirect env.request.resource.gsub(id, ex.video_id) - rescue ex - env.response.status_code = 403 - next - end - - if dashmpd = video.dash_manifest_url - manifest = YT_POOL.client &.get(URI.parse(dashmpd).request_target).body - - manifest = manifest.gsub(/[^<]+<\/BaseURL>/) do |baseurl| - url = baseurl.lchop("") - url = url.rchop("") - - if local - uri = URI.parse(url) - url = "#{uri.request_target}host/#{uri.host}/" - end - - "#{url}" - end - - next manifest - end - - adaptive_fmts = video.adaptive_fmts - - if local - adaptive_fmts.each do |fmt| - fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) - end - end - - audio_streams = video.audio_streams - video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse - - XML.build(indent: " ", encoding: "UTF-8") do |xml| - xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011", - "profiles": "urn:mpeg:dash:profile:full:2011", minBufferTime: "PT1.5S", type: "static", - mediaPresentationDuration: "PT#{video.length_seconds}S") do - xml.element("Period") do - i = 0 - - {"audio/mp4", "audio/webm"}.each do |mime_type| - mime_streams = audio_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } - next if mime_streams.empty? - - xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do - mime_streams.each do |fmt| - codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') - bandwidth = fmt["bitrate"].as_i - itag = fmt["itag"].as_i - url = fmt["url"].as_s - - xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do - xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011", - value: "2") - xml.element("BaseURL") { xml.text url } - xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do - xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}") - end - end - end - end - - i += 1 - end - - potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144} - - {"video/mp4", "video/webm"}.each do |mime_type| - mime_streams = video_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } - next if mime_streams.empty? - - heights = [] of Int32 - xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, scanType: "progressive") do - mime_streams.each do |fmt| - codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') - bandwidth = fmt["bitrate"].as_i - itag = fmt["itag"].as_i - url = fmt["url"].as_s - width = fmt["width"].as_i - height = fmt["height"].as_i - - # Resolutions reported by YouTube player (may not accurately reflect source) - height = potential_heights.min_by { |i| (height - i).abs } - next if unique_res && heights.includes? height - heights << height - - xml.element("Representation", id: itag, codecs: codecs, width: width, height: height, - startWithSAP: "1", maxPlayoutRate: "1", - bandwidth: bandwidth, frameRate: fmt["fps"]) do - xml.element("BaseURL") { xml.text url } - xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do - xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}") - end - end - end - end - - i += 1 - end - end - end - end -end - -get "/api/manifest/hls_variant/*" do |env| - response = YT_POOL.client &.get(env.request.path) - - if response.status_code != 200 - env.response.status_code = response.status_code - next - end - - local = env.params.query["local"]?.try &.== "true" - - env.response.content_type = "application/x-mpegURL" - env.response.headers.add("Access-Control-Allow-Origin", "*") - - manifest = response.body - - if local - manifest = manifest.gsub("https://www.youtube.com", HOST_URL) - manifest = manifest.gsub("index.m3u8", "index.m3u8?local=true") - end - - manifest -end - -get "/api/manifest/hls_playlist/*" do |env| - response = YT_POOL.client &.get(env.request.path) - - if response.status_code != 200 - env.response.status_code = response.status_code - next - end - - local = env.params.query["local"]?.try &.== "true" - - env.response.content_type = "application/x-mpegURL" - env.response.headers.add("Access-Control-Allow-Origin", "*") - - manifest = response.body - - if local - manifest = manifest.gsub(/^https:\/\/r\d---.{11}\.c\.youtube\.com[^\n]*/m) do |match| - path = URI.parse(match).path - - path = path.lchop("/videoplayback/") - path = path.rchop("/") - - path = path.gsub(/mime\/\w+\/\w+/) do |mimetype| - mimetype = mimetype.split("/") - mimetype[0] + "/" + mimetype[1] + "%2F" + mimetype[2] - end - - path = path.split("/") - - raw_params = {} of String => Array(String) - path.each_slice(2) do |pair| - key, value = pair - value = URI.decode_www_form(value) - - if raw_params[key]? - raw_params[key] << value - else - raw_params[key] = [value] - end - end - - raw_params = HTTP::Params.new(raw_params) - if fvip = raw_params["hls_chunk_host"].match(/r(?\d+)---/) - raw_params["fvip"] = fvip["fvip"] - end - - raw_params["local"] = "true" - - "#{HOST_URL}/videoplayback?#{raw_params}" - end - end - - manifest -end - -# YouTube /videoplayback links expire after 6 hours, -# so we have a mechanism here to redirect to the latest version -get "/latest_version" do |env| - if env.params.query["download_widget"]? - download_widget = JSON.parse(env.params.query["download_widget"]) - - id = download_widget["id"].as_s - title = download_widget["title"].as_s - - if label = download_widget["label"]? - env.redirect "/api/v1/captions/#{id}?label=#{label}&title=#{title}" - next - else - itag = download_widget["itag"].as_s.to_i - local = "true" - end - end - - id ||= env.params.query["id"]? - itag ||= env.params.query["itag"]?.try &.to_i - - region = env.params.query["region"]? - - local ||= env.params.query["local"]? - local ||= "false" - local = local == "true" - - if !id || !itag - env.response.status_code = 400 - next - end - - video = get_video(id, PG_DB, region: region) - - 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 - - if !url - env.response.status_code = 404 - next - end - - url = URI.parse(url).request_target.not_nil! if local - url = "#{url}&title=#{title}" if title - - env.redirect url -end - -options "/videoplayback" do |env| - env.response.headers.delete("Content-Type") - 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 - -options "/videoplayback/*" do |env| - env.response.headers.delete("Content-Type") - 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 - -options "/api/manifest/dash/id/videoplayback" do |env| - env.response.headers.delete("Content-Type") - 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 - -options "/api/manifest/dash/id/videoplayback/*" do |env| - env.response.headers.delete("Content-Type") - 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 "/videoplayback/*" do |env| - path = env.request.path - - path = path.lchop("/videoplayback/") - path = path.rchop("/") - - path = path.gsub(/mime\/\w+\/\w+/) do |mimetype| - mimetype = mimetype.split("/") - mimetype[0] + "/" + mimetype[1] + "%2F" + mimetype[2] - end - - path = path.split("/") - - raw_params = {} of String => Array(String) - path.each_slice(2) do |pair| - key, value = pair - value = URI.decode_www_form(value) - - if raw_params[key]? - raw_params[key] << value - else - raw_params[key] = [value] - end - end - - query_params = HTTP::Params.new(raw_params) - - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.redirect "/videoplayback?#{query_params}" -end - -get "/videoplayback" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - query_params = env.params.query - - fvip = query_params["fvip"]? || "3" - mns = query_params["mn"]?.try &.split(",") - mns ||= [] of String - - if query_params["region"]? - region = query_params["region"] - query_params.delete("region") - end - - if query_params["host"]? && !query_params["host"].empty? - host = "https://#{query_params["host"]}" - query_params.delete("host") - else - host = "https://r#{fvip}---#{mns.pop}.googlevideo.com" - end - - url = "/videoplayback?#{query_params.to_s}" - - headers = HTTP::Headers.new - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - client = make_client(URI.parse(host), region) - response = HTTP::Client::Response.new(500) - error = "" - 5.times do - begin - response = client.head(url, headers) - - if response.headers["Location"]? - location = URI.parse(response.headers["Location"]) - env.response.headers["Access-Control-Allow-Origin"] = "*" - - new_host = "#{location.scheme}://#{location.host}" - if new_host != host - host = new_host - client.close - client = make_client(URI.parse(new_host), region) - end - - url = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" - else - break - end - rescue Socket::Addrinfo::Error - if !mns.empty? - mn = mns.pop - end - fvip = "3" - - host = "https://r#{fvip}---#{mn}.googlevideo.com" - client = make_client(URI.parse(host), region) - rescue ex - error = ex.message - end - end - - if response.status_code >= 400 - env.response.status_code = response.status_code - env.response.content_type = "text/plain" - next error - end - - if url.includes? "&file=seg.ts" - if CONFIG.disabled?("livestreams") - next error_template(403, "Administrator has disabled this endpoint.") - end - - begin - client.get(url, headers) do |response| - 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 location = response.headers["Location"]? - location = URI.parse(location) - location = "#{location.request_target}&host=#{location.host}" - - if region - location += "®ion=#{region}" - end - - next env.redirect location - end - - IO.copy(response.body_io, env.response) - end - rescue ex - end - else - if query_params["title"]? && CONFIG.disabled?("downloads") || - CONFIG.disabled?("dash") - next error_template(403, "Administrator has disabled this endpoint.") - end - - content_length = nil - first_chunk = true - range_start, range_end = parse_range(env.request.headers["Range"]?) - chunk_start = range_start - chunk_end = range_end - - if !chunk_end || chunk_end - chunk_start > HTTP_CHUNK_SIZE - chunk_end = chunk_start + HTTP_CHUNK_SIZE - 1 - end - - # TODO: Record bytes written so we can restart after a chunk fails - while true - if !range_end && content_length - range_end = content_length - end - - if range_end && chunk_start > range_end - break - end - - if range_end && chunk_end > range_end - chunk_end = range_end - end - - headers["Range"] = "bytes=#{chunk_start}-#{chunk_end}" - - begin - client.get(url, headers) do |response| - if first_chunk - if !env.request.headers["Range"]? && response.status_code == 206 - env.response.status_code = 200 - else - env.response.status_code = response.status_code - end - - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) && key.downcase != "content-range" - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if location = response.headers["Location"]? - location = URI.parse(location) - location = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" - - env.redirect location - break - end - - if title = query_params["title"]? - # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ - env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" - end - - if !response.headers.includes_word?("Transfer-Encoding", "chunked") - content_length = response.headers["Content-Range"].split("/")[-1].to_i64 - if env.request.headers["Range"]? - env.response.headers["Content-Range"] = "bytes #{range_start}-#{range_end || (content_length - 1)}/#{content_length}" - env.response.content_length = ((range_end.try &.+ 1) || content_length) - range_start - else - env.response.content_length = content_length - end - end - end - - proxy_file(response, env) - end - rescue ex - if ex.message != "Error reading socket: Connection reset by peer" - break - else - client.close - client = make_client(URI.parse(host), region) - end - end - - chunk_start = chunk_end + 1 - chunk_end += HTTP_CHUNK_SIZE - first_chunk = false - end - end - client.close -end - get "/ggpht/*" do |env| url = env.request.path.lchop("/ggpht") @@ -3761,4 +1551,5 @@ add_context_storage_type(User) Kemal.config.logger = LOGGER Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port +Kemal.config.app_name = "Invidious" Kemal.run diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 1a945f7b..628d5b6f 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -9,7 +9,6 @@ struct AboutChannel property author_thumbnail : String property banner : String? property description_html : String - property paid : Bool property total_views : Int64 property sub_count : Int32 property joined : Time @@ -29,29 +28,15 @@ struct AboutRelatedChannel end def get_about_info(ucid, locale) - result = YT_POOL.client &.get("/channel/#{ucid}/about?gl=JP&hl=en") - if result.status_code != 200 - result = YT_POOL.client &.get("/user/#{ucid}/about?gl=JP&hl=en") + begin + # "EgVhYm91dA==" is the base64-encoded protobuf object {"2:string":"about"} + initdata = YoutubeAPI.browse(browse_id: ucid, params: "EgVhYm91dA==") + rescue + raise InfoException.new("Could not get channel info.") end - if md = result.headers["location"]?.try &.match(/\/channel\/(?UC[a-zA-Z0-9_-]{22})/) - raise ChannelRedirect.new(channel_id: md["ucid"]) - end - - if result.status_code != 200 - raise InfoException.new("This channel does not exist.") - end - - about = XML.parse_html(result.body) - if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")])) - raise InfoException.new("This channel does not exist.") - end - - initdata = extract_initial_data(result.body) - if initdata.empty? - error_message = about.xpath_node(%q(//div[@class="yt-alert-content"])).try &.content.strip - error_message ||= translate(locale, "Could not get channel info.") - raise InfoException.new(error_message) + if initdata.dig?("alerts", 0, "alertRenderer", "type") == "ERROR" + raise InfoException.new(initdata["alerts"][0]["alertRenderer"]["text"]["simpleText"].as_s) end if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]? @@ -76,7 +61,6 @@ def get_about_info(ucid, locale) description = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]["simpleText"].as_s description_html = HTML.escape(description).gsub("\n", "
") - paid = false is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map { |a| a.as_s } @@ -99,9 +83,8 @@ def get_about_info(ucid, locale) description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || "" description_html = HTML.escape(description).gsub("\n", "
") - paid = about.xpath_node(%q(//meta[@itemprop="paid"])).not_nil!["content"] == "True" - is_family_friendly = about.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).not_nil!["content"] == "True" - allowed_regions = about.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).not_nil!["content"].split(",") + is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool + allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map { |a| a.as_s } related_channels = initdata["contents"]["twoColumnBrowseResultsRenderer"] .["secondaryContents"]?.try &.["browseSecondaryContentsRenderer"]["contents"][0]? @@ -180,7 +163,6 @@ def get_about_info(ucid, locale) author_thumbnail: author_thumbnail, banner: banner, description_html: description_html, - paid: paid, total_views: total_views, sub_count: sub_count, joined: joined, diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 439c13f7..a5506b03 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -56,10 +56,7 @@ class RedditListing property modhash : String end -def fetch_youtube_comments(id, db, cursor, format, locale, thin_mode, region, sort_by = "top", action = "action_get_comments") - video = get_video(id, db, region: region) - session_token = video.session_token - +def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_by = "top") case cursor when nil, "" ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by) @@ -71,43 +68,41 @@ def fetch_youtube_comments(id, db, cursor, format, locale, thin_mode, region, so ctoken = cursor end - if !session_token - if format == "json" - return {"comments" => [] of String}.to_json - else - return {"contentHtml" => "", "commentCount" => 0}.to_json + client_config = YoutubeAPI::ClientConfig.new(region: region) + response = YoutubeAPI.next(continuation: ctoken, client_config: client_config) + contents = nil + + if response["onResponseReceivedEndpoints"]? + onResponseReceivedEndpoints = response["onResponseReceivedEndpoints"] + header = nil + onResponseReceivedEndpoints.as_a.each do |item| + if item["reloadContinuationItemsCommand"]? + case item["reloadContinuationItemsCommand"]["slot"] + when "RELOAD_CONTINUATION_SLOT_HEADER" + header = item["reloadContinuationItemsCommand"]["continuationItems"][0] + when "RELOAD_CONTINUATION_SLOT_BODY" + contents = item["reloadContinuationItemsCommand"]["continuationItems"] + end + elsif item["appendContinuationItemsAction"]? + contents = item["appendContinuationItemsAction"]["continuationItems"] + end end - end - - post_req = { - page_token: ctoken, - session_token: session_token, - } - - headers = HTTP::Headers{ - "cookie" => video.cookie, - } - - response = YT_POOL.client(region, &.post("/comment_service_ajax?#{action}=1&hl=en&gl=JP&pbj=1", headers, form: post_req)) - response = JSON.parse(response.body) - - # For some reason youtube puts it in an array for comment_replies but otherwise it's the same - if action == "action_get_comment_replies" - response = response[1] - end - - if !response["response"]["continuationContents"]? + elsif response["continuationContents"]? + response = response["continuationContents"] + if response["commentRepliesContinuation"]? + body = response["commentRepliesContinuation"] + else + body = response["itemSectionContinuation"] + end + contents = body["contents"]? + header = body["header"]? + if body["continuations"]? + moreRepliesContinuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s + end + else raise InfoException.new("Could not fetch comments") end - response = response["response"]["continuationContents"] - if response["commentRepliesContinuation"]? - body = response["commentRepliesContinuation"] - else - body = response["itemSectionContinuation"] - end - - contents = body["contents"]? if !contents if format == "json" return {"comments" => [] of String}.to_json @@ -116,13 +111,20 @@ def fetch_youtube_comments(id, db, cursor, format, locale, thin_mode, region, so end end + continuationItemRenderer = nil + contents.as_a.reject! do |item| + if item["continuationItemRenderer"]? + continuationItemRenderer = item["continuationItemRenderer"] + true + end + end + response = JSON.build do |json| json.object do - if body["header"]? - count_text = body["header"]["commentsHeaderRenderer"]["countText"] + if header + count_text = header["commentsHeaderRenderer"]["countText"] comment_count = (count_text["simpleText"]? || count_text["runs"]?.try &.[0]?.try &.["text"]?) .try &.as_s.gsub(/\D/, "").to_i? || 0 - json.field "commentCount", comment_count end @@ -132,7 +134,7 @@ def fetch_youtube_comments(id, db, cursor, format, locale, thin_mode, region, so json.array do contents.as_a.each do |node| json.object do - if !response["commentRepliesContinuation"]? + if node["commentThreadRenderer"]? node = node["commentThreadRenderer"] end @@ -140,7 +142,7 @@ def fetch_youtube_comments(id, db, cursor, format, locale, thin_mode, region, so node_replies = node["replies"]["commentRepliesRenderer"] end - if !response["commentRepliesContinuation"]? + if node["comment"]? node_comment = node["comment"]["commentRenderer"] else node_comment = node["commentRenderer"] @@ -211,7 +213,11 @@ def fetch_youtube_comments(id, db, cursor, format, locale, thin_mode, region, so reply_count = 1 end - continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s + if node_replies["continuations"]? + continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s + elsif node_replies["contents"]? + continuation = node_replies["contents"]?.try &.as_a[0]["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].as_s + end continuation ||= "" json.field "replies" do @@ -226,16 +232,22 @@ def fetch_youtube_comments(id, db, cursor, format, locale, thin_mode, region, so end end - if body["continuations"]? - continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s - json.field "continuation", continuation + if continuationItemRenderer + if continuationItemRenderer["continuationEndpoint"]? + continuationEndpoint = continuationItemRenderer["continuationEndpoint"] + elsif continuationItemRenderer["button"]? + continuationEndpoint = continuationItemRenderer["button"]["buttonRenderer"]["command"] + end + if continuationEndpoint + json.field "continuation", continuationEndpoint["continuationCommand"]["token"].as_s + end end end end if format == "html" response = JSON.parse(response) - content_html = template_youtube_comments(response, locale, thin_mode, action == "action_get_comment_replies") + content_html = template_youtube_comments(response, locale, thin_mode) response = JSON.build do |json| json.object do @@ -483,12 +495,16 @@ def replace_links(html) html.xpath_nodes(%q(//a)).each do |anchor| url = URI.parse(anchor["href"]) - if {"www.youtube.com", "m.youtube.com", "youtu.be"}.includes?(url.host) - if url.path == "/redirect" - params = HTTP::Params.parse(url.query.not_nil!) - anchor["href"] = params["q"]? + if url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") || url.host.not_nil!.ends_with?("youtu.be") + if url.host.try &.ends_with? "youtu.be" + url = "/watch?v=#{url.path.lstrip('/')}#{url.query_params}" else - anchor["href"] = url.request_target + if url.path == "/redirect" + params = HTTP::Params.parse(url.query.not_nil!) + anchor["href"] = params["q"]? + else + anchor["href"] = url.request_target + end end elsif url.to_s == "#" begin @@ -555,7 +571,9 @@ def content_to_comment_html(content) if url = run["navigationEndpoint"]["urlEndpoint"]?.try &.["url"].as_s url = URI.parse(url) - if !url.host || {"m.youtube.com", "www.youtube.com", "youtu.be"}.includes? url.host + if url.host == "youtu.be" + url = "/watch?v=#{url.request_target.lstrip('/')}" + elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") if url.path == "/redirect" url = HTTP::Params.parse(url.query.not_nil!)["q"] else @@ -573,7 +591,7 @@ def content_to_comment_html(content) else text = %(#{text}) end - elsif url = run["navigationEndpoint"]["commandMetadata"]?.try &.["webCommandMetadata"]["url"].as_s + elsif url = run.dig?("navigationEndpoint", "commandMetadata", "webCommandMetadata", "url").try &.as_s text = %(#{text}) end end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index d32edaa4..e3320dd7 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -268,7 +268,6 @@ def extract_item(item : JSON::Any, author_fallback : String? = nil, author_id_fa .try &.["text"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || 0 live_now = false - paid = false premium = false premiere_timestamp = i["upcomingEventData"]?.try &.["startTime"]?.try { |t| Time.unix(t.as_s.to_i64) } @@ -281,8 +280,6 @@ def extract_item(item : JSON::Any, author_fallback : String? = nil, author_id_fa when "New", "4K", "CC" # TODO when "Premium" - paid = true - # TODO: Potentially available as i["topStandaloneBadge"]["metadataBadgeRenderer"] premium = true else nil # Ignore @@ -299,7 +296,6 @@ def extract_item(item : JSON::Any, author_fallback : String? = nil, author_id_fa description_html: description_html, length_seconds: length_seconds, live_now: live_now, - paid: paid, premium: premium, premiere_timestamp: premiere_timestamp, }) diff --git a/src/invidious/helpers/macros.cr b/src/invidious/helpers/macros.cr index 5d426a8b..75df1612 100644 --- a/src/invidious/helpers/macros.cr +++ b/src/invidious/helpers/macros.cr @@ -56,3 +56,12 @@ end macro rendered(filename) render "src/invidious/views/#{{{filename}}}.ecr" end + +# Similar to Kemals halt method but works in a +# method. +macro haltf(env, status_code = 200, response = "") + {{env}}.response.status_code = {{status_code}} + {{env}}.response.print {{response}} + {{env}}.response.close + return +end diff --git a/src/invidious/helpers/youtube_api.cr b/src/invidious/helpers/youtube_api.cr index f4dd1e16..71297101 100644 --- a/src/invidious/helpers/youtube_api.cr +++ b/src/invidious/helpers/youtube_api.cr @@ -8,12 +8,12 @@ module YoutubeAPI # Enumerate used to select one of the clients supported by the API enum ClientType Web - WebEmbed + WebEmbeddedPlayer WebMobile - WebAgeBypass + WebScreenEmbed Android - AndroidEmbed - AndroidAgeBypass + AndroidEmbeddedPlayer + AndroidScreenEmbed end # List of hard-coded values used by the different clients @@ -24,7 +24,7 @@ module YoutubeAPI api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", screen: "WATCH_FULL_SCREEN", }, - ClientType::WebEmbed => { + ClientType::WebEmbeddedPlayer => { name: "WEB_EMBEDDED_PLAYER", # 56 version: "1.20210721.1.0", api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", @@ -36,7 +36,7 @@ module YoutubeAPI api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", screen: "", # None }, - ClientType::WebAgeBypass => { + ClientType::WebScreenEmbed => { name: "WEB", version: "2.20210721.00.00", api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", @@ -48,13 +48,13 @@ module YoutubeAPI api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", screen: "", # ?? }, - ClientType::AndroidEmbed => { + ClientType::AndroidEmbeddedPlayer => { name: "ANDROID_EMBEDDED_PLAYER", # 55 version: "16.20", api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", screen: "", # None? }, - ClientType::AndroidAgeBypass => { + ClientType::AndroidScreenEmbed => { name: "ANDROID", # 3 version: "16.20", api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", @@ -156,9 +156,6 @@ module YoutubeAPI "gl" => "JP", #client_config.region || "JP", # Can't be empty! "clientName" => client_config.name, "clientVersion" => client_config.version, - "thirdParty" => { - "embedUrl" => "", # Placeholder - }, }, } @@ -167,14 +164,10 @@ module YoutubeAPI client_context["client"]["clientScreen"] = client_config.screen end - # Replacing/removing the placeholder is easier than trying to - # merge two different Hash structures. if client_config.screen == "EMBED" - client_context["client"]["thirdParty"] = { + client_context["thirdParty"] = { "embedUrl" => "https://www.youtube.com/embed/dQw4w9WgXcQ", } - else - client_context["client"].delete("thirdParty") end return client_context diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr new file mode 100644 index 00000000..93bee55c --- /dev/null +++ b/src/invidious/routes/api/manifest.cr @@ -0,0 +1,224 @@ +module Invidious::Routes::API::Manifest + # /api/manifest/dash/id/:id + def self.get_dash_video_id(env) + env.response.headers.add("Access-Control-Allow-Origin", "*") + env.response.content_type = "application/dash+xml" + + local = env.params.query["local"]?.try &.== "true" + id = env.params.url["id"] + region = env.params.query["region"]? + + # Since some implementations create playlists based on resolution regardless of different codecs, + # we can opt to only add a source to a representation if it has a unique height within that representation + unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe } + + begin + video = get_video(id, PG_DB, region: region) + rescue ex : VideoRedirect + return env.redirect env.request.resource.gsub(id, ex.video_id) + rescue ex + haltf env, status_code: 403 + end + + if dashmpd = video.dash_manifest_url + manifest = YT_POOL.client &.get(URI.parse(dashmpd).request_target).body + + manifest = manifest.gsub(/[^<]+<\/BaseURL>/) do |baseurl| + url = baseurl.lchop("") + url = url.rchop("") + + if local + uri = URI.parse(url) + url = "#{uri.request_target}host/#{uri.host}/" + end + + "#{url}" + end + + return manifest + end + + adaptive_fmts = video.adaptive_fmts + + if local + adaptive_fmts.each do |fmt| + fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) + end + end + + audio_streams = video.audio_streams + 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| + xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011", + "profiles": "urn:mpeg:dash:profile:full:2011", minBufferTime: "PT1.5S", type: "static", + mediaPresentationDuration: "PT#{video.length_seconds}S") do + xml.element("Period") do + i = 0 + + {"audio/mp4", "audio/webm"}.each do |mime_type| + mime_streams = audio_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } + next if mime_streams.empty? + + xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do + mime_streams.each do |fmt| + codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') + bandwidth = fmt["bitrate"].as_i + itag = fmt["itag"].as_i + url = fmt["url"].as_s + + xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do + xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011", + value: "2") + xml.element("BaseURL") { xml.text url } + xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do + xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}") + end + end + end + end + + i += 1 + end + + potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144} + + {"video/mp4", "video/webm"}.each do |mime_type| + mime_streams = video_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } + next if mime_streams.empty? + + heights = [] of Int32 + xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, scanType: "progressive") do + mime_streams.each do |fmt| + codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') + bandwidth = fmt["bitrate"].as_i + itag = fmt["itag"].as_i + url = fmt["url"].as_s + width = fmt["width"].as_i + height = fmt["height"].as_i + + # Resolutions reported by YouTube player (may not accurately reflect source) + height = potential_heights.min_by { |i| (height - i).abs } + next if unique_res && heights.includes? height + heights << height + + xml.element("Representation", id: itag, codecs: codecs, width: width, height: height, + startWithSAP: "1", maxPlayoutRate: "1", + bandwidth: bandwidth, frameRate: fmt["fps"]) do + xml.element("BaseURL") { xml.text url } + xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do + xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}") + end + end + end + end + + i += 1 + end + end + end + end + + return manifest + end + + # /api/manifest/dash/id/videoplayback + def self.get_dash_video_playback(env) + env.response.headers.delete("Content-Type") + env.response.headers["Access-Control-Allow-Origin"] = "*" + env.redirect "/videoplayback?#{env.params.query}" + end + + # /api/manifest/dash/id/videoplayback/* + def self.get_dash_video_playback_greedy(env) + env.response.headers.delete("Content-Type") + env.response.headers["Access-Control-Allow-Origin"] = "*" + env.redirect env.request.path.lchop("/api/manifest/dash/id") + end + + # /api/manifest/dash/id/videoplayback && /api/manifest/dash/id/videoplayback/* + def self.options_dash_video_playback(env) + env.response.headers.delete("Content-Type") + 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 + + # /api/manifest/hls_playlist/* + def self.get_hls_playlist(env) + response = YT_POOL.client &.get(env.request.path) + + if response.status_code != 200 + haltf env, status_code: response.status_code + end + + local = env.params.query["local"]?.try &.== "true" + + env.response.content_type = "application/x-mpegURL" + env.response.headers.add("Access-Control-Allow-Origin", "*") + + manifest = response.body + + if local + manifest = manifest.gsub(/^https:\/\/r\d---.{11}\.c\.youtube\.com[^\n]*/m) do |match| + path = URI.parse(match).path + + path = path.lchop("/videoplayback/") + path = path.rchop("/") + + path = path.gsub(/mime\/\w+\/\w+/) do |mimetype| + mimetype = mimetype.split("/") + mimetype[0] + "/" + mimetype[1] + "%2F" + mimetype[2] + end + + path = path.split("/") + + raw_params = {} of String => Array(String) + path.each_slice(2) do |pair| + key, value = pair + value = URI.decode_www_form(value) + + if raw_params[key]? + raw_params[key] << value + else + raw_params[key] = [value] + end + end + + raw_params = HTTP::Params.new(raw_params) + if fvip = raw_params["hls_chunk_host"].match(/r(?\d+)---/) + raw_params["fvip"] = fvip["fvip"] + end + + raw_params["local"] = "true" + + "#{HOST_URL}/videoplayback?#{raw_params}" + end + end + + manifest + end + + # /api/manifest/hls_variant/* + def self.get_hls_variant(env) + response = YT_POOL.client &.get(env.request.path) + + if response.status_code != 200 + haltf env, status_code: response.status_code + end + + local = env.params.query["local"]?.try &.== "true" + + env.response.content_type = "application/x-mpegURL" + env.response.headers.add("Access-Control-Allow-Origin", "*") + + manifest = response.body + + if local + manifest = manifest.gsub("https://www.youtube.com", HOST_URL) + manifest = manifest.gsub("index.m3u8", "index.m3u8?local=true") + end + + manifest + end +end diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr new file mode 100644 index 00000000..b4e9e9c8 --- /dev/null +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -0,0 +1,415 @@ +module Invidious::Routes::API::V1::Authenticated + # The notification APIs cannot be extracted yet! + # They require the *local* notifications constant defined in invidious.cr + # + # def self.notifications(env) + # env.response.content_type = "text/event-stream" + + # topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000) + # topics ||= [] of String + + # create_notification_stream(env, topics, connection_channel) + # end + + def self.get_preferences(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + user.preferences.to_json + end + + def self.set_preferences(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + begin + preferences = Preferences.from_json(env.request.body || "{}") + rescue + preferences = user.preferences + end + + PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) + + env.response.status_code = 204 + end + + def self.feed(env) + env.response.content_type = "application/json" + + user = env.get("user").as(User) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + max_results = env.params.query["max_results"]?.try &.to_i? + max_results ||= user.preferences.max_results + max_results ||= CONFIG.default_user_preferences.max_results + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) + + JSON.build do |json| + json.object do + json.field "notifications" do + json.array do + notifications.each do |video| + video.to_json(locale, json) + end + end + end + + json.field "videos" do + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + end + end + end + + def self.get_subscriptions(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + if user.subscriptions.empty? + values = "'{}'" + else + values = "VALUES #{user.subscriptions.map { |id| %(('#{id}')) }.join(",")}" + end + + subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY(#{values})", as: InvidiousChannel) + + JSON.build do |json| + json.array do + subscriptions.each do |subscription| + json.object do + json.field "author", subscription.author + json.field "authorId", subscription.id + end + end + end + end + end + + def self.subscribe_channel(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + ucid = env.params.url["ucid"] + + if !user.subscriptions.includes? ucid + get_channel(ucid, PG_DB, false, false) + PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email) + end + + # For Google accounts, access tokens don't have enough information to + # make a request on the user's behalf, which is why we don't sync with + # YouTube. + + env.response.status_code = 204 + end + + def self.unsubscribe_channel(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + ucid = env.params.url["ucid"] + + PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", ucid, user.email) + + env.response.status_code = 204 + end + + def self.list_playlists(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + user = env.get("user").as(User) + + playlists = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1", user.email, as: InvidiousPlaylist) + + JSON.build do |json| + json.array do + playlists.each do |playlist| + playlist.to_json(0, locale, json) + end + end + end + end + + def self.create_playlist(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150) + if !title + return error_json(400, "Invalid title.") + end + + privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) } + if !privacy + return error_json(400, "Invalid privacy setting.") + end + + if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 + return error_json(400, "User cannot have more than 100 playlists.") + end + + playlist = create_playlist(PG_DB, title, privacy, user) + env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{playlist.id}" + env.response.status_code = 201 + { + "title" => title, + "playlistId" => playlist.id, + }.to_json + end + + def self.update_playlist_attribute(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"] + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email && playlist.privacy.private? + return error_json(404, "Playlist does not exist.") + end + + if playlist.author != user.email + return error_json(403, "Invalid user") + end + + title = env.params.json["title"].try &.as(String).delete("<>").byte_slice(0, 150) || playlist.title + privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) } || playlist.privacy + description = env.params.json["description"]?.try &.as(String).delete("\r") || playlist.description + + if title != playlist.title || + privacy != playlist.privacy || + description != playlist.description + updated = Time.utc + else + updated = playlist.updated + end + + PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid) + env.response.status_code = 204 + end + + def self.delete_playlist(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"] + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email && playlist.privacy.private? + return error_json(404, "Playlist does not exist.") + end + + if playlist.author != user.email + return error_json(403, "Invalid user") + end + + PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid) + PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid) + + env.response.status_code = 204 + end + + def self.insert_video_into_playlist(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"] + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email && playlist.privacy.private? + return error_json(404, "Playlist does not exist.") + end + + if playlist.author != user.email + return error_json(403, "Invalid user") + end + + if playlist.index.size >= 500 + return error_json(400, "Playlist cannot have more than 500 videos") + end + + video_id = env.params.json["videoId"].try &.as(String) + if !video_id + return error_json(403, "Invalid videoId") + end + + begin + video = get_video(video_id, PG_DB) + rescue ex + return error_json(500, ex) + end + + playlist_video = PlaylistVideo.new({ + title: video.title, + id: video.id, + author: video.author, + ucid: video.ucid, + length_seconds: video.length_seconds, + published: video.published, + plid: plid, + live_now: video.live_now, + index: Random::Secure.rand(0_i64..Int64::MAX), + }) + + video_array = playlist_video.to_a + args = arg_array(video_array) + + PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array) + PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, plid) + + env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{plid}/videos/#{playlist_video.index.to_u64.to_s(16).upcase}" + env.response.status_code = 201 + playlist_video.to_json(locale, index: playlist.index.size) + end + + def self.delete_video_in_playlist(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"] + index = env.params.url["index"].to_i64(16) + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email && playlist.privacy.private? + return error_json(404, "Playlist does not exist.") + end + + if playlist.author != user.email + return error_json(403, "Invalid user") + end + + if !playlist.index.includes? index + return error_json(404, "Playlist does not contain index") + end + + PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index) + PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = cardinality(index) - 1, updated = $2 WHERE id = $3", index, Time.utc, plid) + + env.response.status_code = 204 + end + + # Invidious::Routing.patch "/api/v1/auth/playlists/:plid/videos/:index" + # def modify_playlist_at(env) + # TODO + # end + + def self.get_tokens(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + scopes = env.get("scopes").as(Array(String)) + + tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1", user.email, as: {session: String, issued: Time}) + + JSON.build do |json| + json.array do + tokens.each do |token| + json.object do + json.field "session", token[:session] + json.field "issued", token[:issued].to_unix + end + end + end + end + end + + def self.register_token(env) + user = env.get("user").as(User) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + case env.request.headers["Content-Type"]? + when "application/x-www-form-urlencoded" + scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v } + callback_url = env.params.body["callbackUrl"]? + expire = env.params.body["expire"]?.try &.to_i? + when "application/json" + scopes = env.params.json["scopes"].as(Array).map { |v| v.as_s } + callback_url = env.params.json["callbackUrl"]?.try &.as(String) + expire = env.params.json["expire"]?.try &.as(Int64) + else + return error_json(400, "Invalid or missing header 'Content-Type'") + end + + if callback_url && callback_url.empty? + callback_url = nil + end + + if callback_url + callback_url = URI.parse(callback_url) + end + + if sid = env.get?("sid").try &.as(String) + env.response.content_type = "text/html" + + csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, PG_DB, use_nonce: true) + return templated "authorize_token" + else + env.response.content_type = "application/json" + + superset_scopes = env.get("scopes").as(Array(String)) + + authorized_scopes = [] of String + scopes.each do |scope| + if scopes_include_scope(superset_scopes, scope) + authorized_scopes << scope + end + end + + access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY, PG_DB) + + if callback_url + access_token = URI.encode_www_form(access_token) + + if query = callback_url.query + query = HTTP::Params.parse(query.not_nil!) + else + query = HTTP::Params.new + end + + query["token"] = access_token + callback_url.query = query.to_s + + env.redirect callback_url.to_s + else + access_token + end + end + end + + def self.unregister_token(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + env.response.content_type = "application/json" + user = env.get("user").as(User) + scopes = env.get("scopes").as(Array(String)) + + session = env.params.json["session"]?.try &.as(String) + session ||= env.get("session").as(String) + + # Allow tokens to revoke other tokens with correct scope + if session == env.get("session").as(String) + PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) + elsif scopes_include_scope(scopes, "GET:tokens") + PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) + else + return error_json(400, "Cannot revoke session #{session}") + end + + env.response.status_code = 204 + end +end diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr new file mode 100644 index 00000000..da39661c --- /dev/null +++ b/src/invidious/routes/api/v1/channels.cr @@ -0,0 +1,278 @@ +module Invidious::Routes::API::V1::Channels + def self.home(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + sort_by = env.params.query["sort_by"]?.try &.downcase + sort_by ||= "newest" + + begin + channel = get_about_info(ucid, locale) + 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 + return error_json(500, ex) + end + + page = 1 + if channel.auto_generated + videos = [] of SearchVideo + count = 0 + else + begin + count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) + rescue ex + return error_json(500, ex) + end + end + + JSON.build do |json| + # TODO: Refactor into `to_json` for InvidiousChannel + json.object do + json.field "author", channel.author + json.field "authorId", channel.ucid + json.field "authorUrl", channel.author_url + + json.field "authorBanners" do + json.array do + if channel.banner + qualities = { + {width: 2560, height: 424}, + {width: 2120, height: 351}, + {width: 1060, height: 175}, + } + qualities.each do |quality| + json.object do + json.field "url", channel.banner.not_nil!.gsub("=w1060-", "=w#{quality[:width]}-") + json.field "width", quality[:width] + json.field "height", quality[:height] + end + end + + json.object do + json.field "url", channel.banner.not_nil!.split("=w1060-")[0] + json.field "width", 512 + json.field "height", 288 + end + end + end + end + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", channel.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + + json.field "subCount", channel.sub_count + json.field "totalViews", channel.total_views + json.field "joined", channel.joined.to_unix + + json.field "autoGenerated", channel.auto_generated + json.field "isFamilyFriendly", channel.is_family_friendly + json.field "description", html_to_content(channel.description_html) + json.field "descriptionHtml", channel.description_html + + json.field "allowedRegions", channel.allowed_regions + + json.field "latestVideos" do + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + + json.field "relatedChannels" do + json.array do + channel.related_channels.each do |related_channel| + json.object do + json.field "author", related_channel.author + json.field "authorId", related_channel.ucid + json.field "authorUrl", related_channel.author_url + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + end + end + end + end + end + end + end + + def self.latest(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + begin + videos = get_latest_videos(ucid) + rescue ex + return error_json(500, ex) + end + + JSON.build do |json| + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + end + + def self.videos(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + sort_by = env.params.query["sort"]?.try &.downcase + sort_by ||= env.params.query["sort_by"]?.try &.downcase + sort_by ||= "newest" + + begin + channel = get_about_info(ucid, locale) + 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 + return error_json(500, ex) + end + + begin + count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) + rescue ex + return error_json(500, ex) + end + + JSON.build do |json| + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + end + + def self.playlists(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + continuation = env.params.query["continuation"]? + sort_by = env.params.query["sort"]?.try &.downcase || + env.params.query["sort_by"]?.try &.downcase || + "last" + + begin + channel = get_about_info(ucid, locale) + 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 + return error_json(500, ex) + end + + items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) + + JSON.build do |json| + json.object do + json.field "playlists" do + json.array do + items.each do |item| + item.to_json(locale, json) if item.is_a?(SearchPlaylist) + end + end + end + + json.field "continuation", continuation + end + end + end + + def self.community(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + continuation = env.params.query["continuation"]? + # sort_by = env.params.query["sort_by"]?.try &.downcase + + begin + fetch_channel_community(ucid, continuation, locale, format, thin_mode) + rescue ex + return error_json(500, ex) + end + end + + def self.search(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + query = env.params.query["q"]? + query ||= "" + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + count, search_results = channel_search(query, page, ucid) + JSON.build do |json| + json.array do + search_results.each do |item| + item.to_json(locale, json) + end + end + end + end + + # 301 redirect from /api/v1/channels/comments/:ucid + # and /api/v1/channels/:ucid/comments to new /api/v1/channels/:ucid/community and + # corresponding equivalent URL structure of the other one. + def self.channel_comments_redirect(env) + env.response.content_type = "application/json" + ucid = env.params.url["ucid"] + + env.response.headers["Location"] = "/api/v1/channels/#{ucid}/community?#{env.params.query}" + env.response.status_code = 301 + return + end +end diff --git a/src/invidious/routes/api/v1/feeds.cr b/src/invidious/routes/api/v1/feeds.cr new file mode 100644 index 00000000..bb8f661b --- /dev/null +++ b/src/invidious/routes/api/v1/feeds.cr @@ -0,0 +1,45 @@ +module Invidious::Routes::API::V1::Feeds + def self.trending(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + region = env.params.query["region"]? + trending_type = env.params.query["type"]? + + begin + trending, plid = fetch_trending(trending_type, region, locale) + rescue ex + return error_json(500, ex) + end + + videos = JSON.build do |json| + json.array do + trending.each do |video| + video.to_json(locale, json) + end + end + end + + videos + end + + def self.popular(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + if !CONFIG.popular_enabled + error_message = {"error" => "Administrator has disabled this endpoint."}.to_json + haltf env, 400, error_message + end + + JSON.build do |json| + json.array do + popular_videos.each do |video| + video.to_json(locale, json) + end + end + end + end +end diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr new file mode 100644 index 00000000..cf95bd9b --- /dev/null +++ b/src/invidious/routes/api/v1/misc.cr @@ -0,0 +1,136 @@ +module Invidious::Routes::API::V1::Misc + # Stats API endpoint for Invidious + def self.stats(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + env.response.content_type = "application/json" + + if !CONFIG.statistics_enabled + return error_json(400, "Statistics are not enabled.") + end + + Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json + end + + # APIv1 currently uses the same logic for both + # user playlists and Invidious playlists. This means that we can't + # reasonably split them yet. This should be addressed in APIv2 + def self.get_playlist(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + plid = env.params.url["plid"] + + offset = env.params.query["index"]?.try &.to_i? + offset ||= env.params.query["page"]?.try &.to_i?.try { |page| (page - 1) * 100 } + offset ||= 0 + + continuation = env.params.query["continuation"]? + + format = env.params.query["format"]? + format ||= "json" + + if plid.starts_with? "RD" + return env.redirect "/api/v1/mixes/#{plid}" + end + + begin + playlist = get_playlist(PG_DB, plid, locale) + rescue ex : InfoException + return error_json(404, ex) + rescue ex + return error_json(404, "Playlist does not exist.") + end + + user = env.get?("user").try &.as(User) + if !playlist || playlist.privacy.private? && playlist.author != user.try &.email + return error_json(404, "Playlist does not exist.") + end + + response = playlist.to_json(offset, locale, continuation: continuation) + + 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} + + response = { + "playlistHtml" => playlist_html, + "index" => index, + "nextVideo" => next_video, + }.to_json + end + + response + end + + def self.mixes(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + rdid = env.params.url["rdid"] + + continuation = env.params.query["continuation"]? + continuation ||= rdid.lchop("RD")[0, 11] + + format = env.params.query["format"]? + format ||= "json" + + begin + mix = fetch_mix(rdid, continuation, locale: locale) + + if !rdid.ends_with? continuation + mix = fetch_mix(rdid, mix.videos[1].id) + index = mix.videos.index(mix.videos.select { |video| video.id == continuation }[0]?) + end + + mix.videos = mix.videos[index..-1] + rescue ex + return error_json(500, ex) + end + + response = JSON.build do |json| + json.object do + json.field "title", mix.title + json.field "mixId", mix.id + + json.field "videos" do + json.array do + mix.videos.each do |video| + json.object do + json.field "title", video.title + json.field "videoId", video.id + json.field "author", video.author + + json.field "authorId", video.ucid + json.field "authorUrl", "/channel/#{video.ucid}" + + json.field "videoThumbnails" do + json.array do + generate_thumbnails(json, video.id) + end + end + + json.field "index", video.index + json.field "lengthSeconds", video.length_seconds + end + end + end + end + end + end + + if format == "html" + response = JSON.parse(response) + playlist_html = template_mix(response) + next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"] + + response = { + "playlistHtml" => playlist_html, + "nextVideo" => next_video, + }.to_json + end + + response + end +end diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr new file mode 100644 index 00000000..f3a6fa06 --- /dev/null +++ b/src/invidious/routes/api/v1/search.cr @@ -0,0 +1,78 @@ +module Invidious::Routes::API::V1::Search + def self.search(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + region = env.params.query["region"]? + + env.response.content_type = "application/json" + + query = env.params.query["q"]? + query ||= "" + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + sort_by = env.params.query["sort_by"]?.try &.downcase + sort_by ||= "relevance" + + date = env.params.query["date"]?.try &.downcase + date ||= "" + + duration = env.params.query["duration"]?.try &.downcase + duration ||= "" + + features = env.params.query["features"]?.try &.split(",").map { |feature| feature.downcase } + features ||= [] of String + + content_type = env.params.query["type"]?.try &.downcase + content_type ||= "video" + + begin + search_params = produce_search_params(page, sort_by, date, content_type, duration, features) + rescue ex + return error_json(400, ex) + end + + count, search_results = search(query, search_params, region).as(Tuple) + JSON.build do |json| + json.array do + search_results.each do |item| + item.to_json(locale, json) + end + end + end + end + + def self.search_suggestions(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + region = env.params.query["region"]? + + env.response.content_type = "application/json" + + query = env.params.query["q"]? + query ||= "" + + begin + headers = HTTP::Headers{":authority" => "suggestqueries.google.com"} + response = YT_POOL.client &.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback", headers).body + + body = response[35..-2] + body = JSON.parse(body).as_a + suggestions = body[1].as_a[0..-2] + + JSON.build do |json| + json.object do + json.field "query", body[0].as_s + json.field "suggestions" do + json.array do + suggestions.each do |suggestion| + json.string suggestion[0].as_s + end + end + end + end + end + rescue ex + return error_json(500, ex) + end + end +end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr new file mode 100644 index 00000000..575e6fdf --- /dev/null +++ b/src/invidious/routes/api/v1/videos.cr @@ -0,0 +1,363 @@ +module Invidious::Routes::API::V1::Videos + def self.videos(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + region = env.params.query["region"]? + + begin + video = get_video(id, PG_DB, region: region) + 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 + return error_json(500, ex) + end + + video.to_json(locale) + end + + def self.captions(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + region = env.params.query["region"]? + + # See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354 + # It is possible to use `/api/timedtext?type=list&v=#{id}` and + # `/api/timedtext?type=track&v=#{id}&lang=#{lang_code}` directly, + # but this does not provide links for auto-generated captions. + # + # In future this should be investigated as an alternative, since it does not require + # getting video info. + + begin + video = get_video(id, PG_DB, region: region) + 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 + haltf env, 500 + end + + captions = video.captions + + label = env.params.query["label"]? + lang = env.params.query["lang"]? + tlang = env.params.query["tlang"]? + + if !label && !lang + response = JSON.build do |json| + json.object do + json.field "captions" do + json.array do + captions.each do |caption| + json.object do + json.field "label", caption.name + json.field "languageCode", caption.languageCode + json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" + end + end + end + end + end + end + + return response + end + + env.response.content_type = "text/vtt; charset=UTF-8" + + if lang + caption = captions.select { |caption| caption.languageCode == lang } + else + caption = captions.select { |caption| caption.name == label } + end + + if caption.empty? + haltf env, 404 + else + caption = caption[0] + end + + url = URI.parse("#{caption.baseUrl}&tlang=#{tlang}").request_target + + # Auto-generated captions often have cues that aren't aligned properly with the video, + # as well as some other markup that makes it cumbersome, so we try to fix that here + if caption.name.includes? "auto-generated" + caption_xml = YT_POOL.client &.get(url).body + caption_xml = XML.parse(caption_xml) + + webvtt = String.build do |str| + str << <<-END_VTT + WEBVTT + Kind: captions + Language: #{tlang || caption.languageCode} + + + END_VTT + + caption_nodes = caption_xml.xpath_nodes("//transcript/text") + caption_nodes.each_with_index do |node, i| + start_time = node["start"].to_f.seconds + duration = node["dur"]?.try &.to_f.seconds + duration ||= start_time + + if caption_nodes.size > i + 1 + end_time = caption_nodes[i + 1]["start"].to_f.seconds + else + end_time = start_time + duration + end + + start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" + end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" + + text = HTML.unescape(node.content) + text = text.gsub(//, "") + text = text.gsub(/<\/font>/, "") + if md = text.match(/(?.*) : (?.*)/) + text = "#{md["text"]}" + end + + str << <<-END_CUE + #{start_time} --> #{end_time} + #{text} + + + END_CUE + end + end + else + webvtt = YT_POOL.client &.get("#{url}&format=vtt").body + end + + if title = env.params.query["title"]? + # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ + env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" + end + + webvtt + end + + # Fetches YouTube storyboards + # + # Which are sprites containing x * y preview + # thumbnails for individual scenes in a video. + # See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails + def self.storyboards(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + region = env.params.query["region"]? + + begin + video = get_video(id, PG_DB, region: region) + 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 + haltf env, 500 + end + + storyboards = video.storyboards + width = env.params.query["width"]? + height = env.params.query["height"]? + + if !width && !height + response = JSON.build do |json| + json.object do + json.field "storyboards" do + generate_storyboards(json, id, storyboards) + end + end + end + + return response + end + + env.response.content_type = "text/vtt" + + storyboard = storyboards.select { |storyboard| width == "#{storyboard[:width]}" || height == "#{storyboard[:height]}" } + + if storyboard.empty? + haltf env, 404 + else + storyboard = storyboard[0] + end + + String.build do |str| + str << <<-END_VTT + WEBVTT + END_VTT + + start_time = 0.milliseconds + end_time = storyboard[:interval].milliseconds + + storyboard[:storyboard_count].times do |i| + url = storyboard[:url] + authority = /(i\d?).ytimg.com/.match(url).not_nil![1]? + url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "") + url = "#{HOST_URL}/sb/#{authority}/#{url}" + + storyboard[:storyboard_height].times do |j| + storyboard[:storyboard_width].times do |k| + str << <<-END_CUE + #{start_time}.000 --> #{end_time}.000 + #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]} + + + END_CUE + + start_time += storyboard[:interval].milliseconds + end_time += storyboard[:interval].milliseconds + end + end + end + end + end + + def self.annotations(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "text/xml" + + id = env.params.url["id"] + source = env.params.query["source"]? + source ||= "archive" + + if !id.match(/[a-zA-Z0-9_-]{11}/) + haltf env, 400 + end + + annotations = "" + + case source + when "archive" + if CONFIG.cache_annotations && (cached_annotation = PG_DB.query_one?("SELECT * FROM annotations WHERE id = $1", id, as: Annotation)) + annotations = cached_annotation.annotations + else + index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0') + + # IA doesn't handle leading hyphens, + # so we use https://archive.org/details/youtubeannotations_64 + if index == "62" + index = "64" + id = id.sub(/^-/, 'A') + end + + file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml") + + location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}")) + + if !location.headers["Location"]? + env.response.status_code = location.status_code + end + + response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"])) + + if response.body.empty? + haltf env, 404 + end + + if response.status_code != 200 + haltf env, response.status_code + end + + annotations = response.body + + cache_annotation(PG_DB, id, annotations) + end + else # "youtube" + response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") + + if response.status_code != 200 + haltf env, response.status_code + end + + annotations = response.body + end + + etag = sha256(annotations)[0, 16] + if env.request.headers["If-None-Match"]?.try &.== etag + haltf env, 304 + else + env.response.headers["ETag"] = etag + annotations + end + end + + def self.comments(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + region = env.params.query["region"]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + + source = env.params.query["source"]? + source ||= "youtube" + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + action = env.params.query["action"]? + action ||= "action_get_comments" + + continuation = env.params.query["continuation"]? + sort_by = env.params.query["sort_by"]?.try &.downcase + + if source == "youtube" + sort_by ||= "top" + + begin + comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by) + rescue ex + return error_json(500, ex) + end + + return comments + elsif source == "reddit" + sort_by ||= "confidence" + + begin + comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by) + content_html = template_reddit_comments(comments, locale) + + content_html = fill_links(content_html, "https", "www.reddit.com") + content_html = replace_links(content_html) + rescue ex + comments = nil + reddit_thread = nil + content_html = "" + end + + if !reddit_thread || !comments + haltf env, 404 + end + + if format == "json" + reddit_thread = JSON.parse(reddit_thread.to_json).as_h + reddit_thread["comments"] = JSON.parse(comments.to_json) + + return reddit_thread.to_json + else + response = { + "title" => reddit_thread.title, + "permalink" => reddit_thread.permalink, + "contentHtml" => content_html, + } + + return response.to_json + end + end + end +end diff --git a/src/invidious/routes/base_route.cr b/src/invidious/routes/base_route.cr deleted file mode 100644 index 07c6f15b..00000000 --- a/src/invidious/routes/base_route.cr +++ /dev/null @@ -1,2 +0,0 @@ -abstract class Invidious::Routes::BaseRoute -end diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 9876936f..6a32988e 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -1,9 +1,9 @@ -class Invidious::Routes::Channels < Invidious::Routes::BaseRoute - def home(env) +module Invidious::Routes::Channels + def self.home(env) self.videos(env) end - def videos(env) + def self.videos(env) data = self.fetch_basic_information(env) if !data.is_a?(Tuple) return data @@ -34,13 +34,12 @@ class Invidious::Routes::Channels < Invidious::Routes::BaseRoute sort_by ||= "newest" count, items = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - items.reject! &.paid end templated "channel" end - def playlists(env) + def self.playlists(env) data = self.fetch_basic_information(env) if !data.is_a?(Tuple) return data @@ -62,7 +61,7 @@ class Invidious::Routes::Channels < Invidious::Routes::BaseRoute templated "playlists" end - def community(env) + def self.community(env) data = self.fetch_basic_information(env) if !data.is_a?(Tuple) return data @@ -91,7 +90,7 @@ class Invidious::Routes::Channels < Invidious::Routes::BaseRoute templated "community" end - def about(env) + def self.about(env) data = self.fetch_basic_information(env) if !data.is_a?(Tuple) return data @@ -102,7 +101,7 @@ class Invidious::Routes::Channels < Invidious::Routes::BaseRoute end # Redirects brand url channels to a normal /channel/:ucid route - def brand_redirect(env) + def self.brand_redirect(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? # /attribution_link endpoint needs both the `a` and `u` parameter @@ -131,7 +130,7 @@ class Invidious::Routes::Channels < Invidious::Routes::BaseRoute end # Handles redirects for the /profile endpoint - def profile(env) + def self.profile(env) # The /profile endpoint is special. If passed into the resolve_url # endpoint YouTube would return a sign in page instead of an /channel/:ucid # thus we'll add an edge case and handle it here. @@ -146,7 +145,7 @@ class Invidious::Routes::Channels < Invidious::Routes::BaseRoute end end - private def fetch_basic_information(env) + private def self.fetch_basic_information(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? user = env.get? "user" diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 5e1e9431..5fc8a61f 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -1,5 +1,5 @@ -class Invidious::Routes::Embed < Invidious::Routes::BaseRoute - def redirect(env) +module Invidious::Routes::Embed + def self.redirect(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") @@ -23,7 +23,7 @@ class Invidious::Routes::Embed < Invidious::Routes::BaseRoute env.redirect url end - def show(env) + def self.show(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? id = env.params.url["id"] diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr new file mode 100644 index 00000000..6847d8ef --- /dev/null +++ b/src/invidious/routes/feeds.cr @@ -0,0 +1,431 @@ +module Invidious::Routes::Feeds + def self.view_all_playlists_redirect(env) + env.redirect "/feed/playlists" + end + + def self.playlists(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + referer = get_referer(env) + + return env.redirect "/" if user.nil? + + user = user.as(User) + + items_created = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) + items_created.map! do |item| + item.author = "" + item + end + + items_saved = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id NOT LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) + items_saved.map! do |item| + item.author = "" + item + end + + templated "feeds/playlists" + end + + def self.popular(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + if CONFIG.popular_enabled + templated "feeds/popular" + else + message = translate(locale, "The Popular feed has been disabled by the administrator.") + templated "message" + end + end + + def self.trending(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + trending_type = env.params.query["type"]? + trending_type ||= "Default" + + region = env.params.query["region"]? + region ||= "JP" + + begin + trending, plid = fetch_trending(trending_type, region, locale) + rescue ex + return error_template(500, ex) + end + + templated "feeds/trending" + end + + def self.subscriptions(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + token = user.token + + if user.preferences.unseen_only + env.set "show_watched", true + end + + # Refresh account + headers = HTTP::Headers.new + headers["Cookie"] = env.request.headers["Cookie"] + + if !user.password + user, sid = get_user(sid, headers, PG_DB) + end + + max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) + max_results ||= user.preferences.max_results + max_results ||= CONFIG.default_user_preferences.max_results + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) + + # "updated" here is used for delivering new notifications, so if + # we know a user has looked at their feed e.g. in the past 10 minutes, + # they've already seen a video posted 20 minutes ago, and don't need + # to be notified. + PG_DB.exec("UPDATE users SET notifications = $1, updated = $2 WHERE email = $3", [] of String, Time.utc, + user.email) + user.notifications = [] of String + env.set "user", user + + templated "feeds/subscriptions" + end + + def self.history(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + referer = get_referer(env) + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + if !user + return env.redirect referer + end + + user = user.as(User) + + max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) + max_results ||= user.preferences.max_results + max_results ||= CONFIG.default_user_preferences.max_results + + if user.watched[(page - 1) * max_results]? + watched = user.watched.reverse[(page - 1) * max_results, max_results] + end + watched ||= [] of String + + templated "feeds/history" + end + + # RSS feeds + + def self.rss_channel(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.headers["Content-Type"] = "application/atom+xml" + env.response.content_type = "application/atom+xml" + + ucid = env.params.url["ucid"] + + params = HTTP::Params.parse(env.params.query["params"]? || "") + + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + return env.redirect env.request.resource.gsub(ucid, ex.channel_id) + rescue ex + return error_atom(500, ex) + end + + response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}") + rss = XML.parse_html(response.body) + + videos = rss.xpath_nodes("//feed/entry").map do |entry| + video_id = entry.xpath_node("videoid").not_nil!.content + title = entry.xpath_node("title").not_nil!.content + + published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) + updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) + + author = entry.xpath_node("author/name").not_nil!.content + ucid = entry.xpath_node("channelid").not_nil!.content + description_html = entry.xpath_node("group/description").not_nil!.to_s + views = entry.xpath_node("group/community/statistics").not_nil!.["views"].to_i64 + + SearchVideo.new({ + title: title, + id: video_id, + author: author, + ucid: ucid, + published: published, + views: views, + description_html: description_html, + length_seconds: 0, + live_now: false, + paid: false, + premium: false, + premiere_timestamp: nil, + }) + end + + XML.build(indent: " ", encoding: "UTF-8") do |xml| + xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", + "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", + "xml:lang": "en-US") do + xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") + xml.element("id") { xml.text "yt:channel:#{channel.ucid}" } + xml.element("yt:channelId") { xml.text channel.ucid } + xml.element("icon") { xml.text channel.author_thumbnail } + xml.element("title") { xml.text channel.author } + xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}") + + xml.element("author") do + xml.element("name") { xml.text channel.author } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" } + end + + videos.each do |video| + video.to_xml(channel.auto_generated, params, xml) + end + end + end + end + + def self.rss_private(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.headers["Content-Type"] = "application/atom+xml" + env.response.content_type = "application/atom+xml" + + token = env.params.query["token"]? + + if !token + haltf env, status_code: 403 + end + + user = PG_DB.query_one?("SELECT * FROM users WHERE token = $1", token.strip, as: User) + if !user + haltf env, status_code: 403 + end + + max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) + max_results ||= user.preferences.max_results + max_results ||= CONFIG.default_user_preferences.max_results + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + params = HTTP::Params.parse(env.params.query["params"]? || "") + + videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) + + XML.build(indent: " ", encoding: "UTF-8") do |xml| + xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", + "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", + "xml:lang": "en-US") do + xml.element("link", "type": "text/html", rel: "alternate", href: "#{HOST_URL}/feed/subscriptions") + xml.element("link", "type": "application/atom+xml", rel: "self", + href: "#{HOST_URL}#{env.request.resource}") + xml.element("title") { xml.text translate(locale, "Invidious Private Feed for `x`", user.email) } + + (notifications + videos).each do |video| + video.to_xml(locale, params, xml) + end + end + end + end + + def self.rss_playlist(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.headers["Content-Type"] = "application/atom+xml" + env.response.content_type = "application/atom+xml" + + plid = env.params.url["plid"] + + params = HTTP::Params.parse(env.params.query["params"]? || "") + path = env.request.path + + if plid.starts_with? "IV" + if playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + videos = get_playlist_videos(PG_DB, playlist, offset: 0, locale: locale) + + return XML.build(indent: " ", encoding: "UTF-8") do |xml| + xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", + "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", + "xml:lang": "en-US") do + xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") + xml.element("id") { xml.text "iv:playlist:#{plid}" } + xml.element("iv:playlistId") { xml.text plid } + xml.element("title") { xml.text playlist.title } + xml.element("link", rel: "alternate", href: "#{HOST_URL}/playlist?list=#{plid}") + + xml.element("author") do + xml.element("name") { xml.text playlist.author } + end + + videos.each do |video| + video.to_xml(false, xml) + end + end + end + else + haltf env, status_code: 404 + end + end + + response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}") + document = XML.parse(response.body) + + document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node| + node.attributes.each do |attribute| + case attribute.name + when "url", "href" + request_target = URI.parse(node[attribute.name]).request_target + query_string_opt = request_target.starts_with?("/watch?v=") ? "&#{params}" : "" + node[attribute.name] = "#{HOST_URL}#{request_target}#{query_string_opt}" + else nil # Skip + end + end + end + + document = document.to_xml(options: XML::SaveOptions::NO_DECL) + + document.scan(/(?[^<]+)<\/uri>/).each do |match| + content = "#{HOST_URL}#{URI.parse(match["url"]).request_target}" + document = document.gsub(match[0], "#{content}") + end + document + end + + def self.rss_videos(env) + if ucid = env.params.query["channel_id"]? + env.redirect "/feed/channel/#{ucid}" + elsif user = env.params.query["user"]? + env.redirect "/feed/channel/#{user}" + elsif plid = env.params.query["playlist_id"]? + env.redirect "/feed/playlist/#{plid}" + end + end + + # Push notifications via PubSub + + def self.push_notifications_get(env) + verify_token = env.params.url["token"] + + mode = env.params.query["hub.mode"]? + topic = env.params.query["hub.topic"]? + challenge = env.params.query["hub.challenge"]? + + if !mode || !topic || !challenge + haltf env, status_code: 400 + else + mode = mode.not_nil! + topic = topic.not_nil! + challenge = challenge.not_nil! + end + + case verify_token + when .starts_with? "v1" + _, time, nonce, signature = verify_token.split(":") + data = "#{time}:#{nonce}" + when .starts_with? "v2" + time, signature = verify_token.split(":") + data = "#{time}" + else + haltf env, status_code: 400 + end + + # The hub will sometimes check if we're still subscribed after delivery errors, + # so we reply with a 200 as long as the request hasn't expired + if Time.utc.to_unix - time.to_i > 432000 + haltf env, status_code: 400 + end + + if OpenSSL::HMAC.hexdigest(:sha1, HMAC_KEY, data) != signature + haltf env, status_code: 400 + end + + if ucid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["channel_id"]? + PG_DB.exec("UPDATE channels SET subscribed = $1 WHERE id = $2", Time.utc, ucid) + elsif plid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["playlist_id"]? + PG_DB.exec("UPDATE playlists SET subscribed = $1 WHERE id = $2", Time.utc, ucid) + else + haltf env, status_code: 400 + end + + env.response.status_code = 200 + challenge + end + + def self.push_notifications_post(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + token = env.params.url["token"] + body = env.request.body.not_nil!.gets_to_end + signature = env.request.headers["X-Hub-Signature"].lchop("sha1=") + + if signature != OpenSSL::HMAC.hexdigest(:sha1, HMAC_KEY, body) + LOGGER.error("/feed/webhook/#{token} : Invalid signature") + haltf env, status_code: 200 + end + + spawn do + rss = XML.parse_html(body) + rss.xpath_nodes("//feed/entry").each do |entry| + id = entry.xpath_node("videoid").not_nil!.content + author = entry.xpath_node("author/name").not_nil!.content + published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) + updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) + + video = get_video(id, PG_DB, force_refresh: true) + + # Deliver notifications to `/api/v1/auth/notifications` + payload = { + "topic" => video.ucid, + "videoId" => video.id, + "published" => published.to_unix, + }.to_json + PG_DB.exec("NOTIFY notifications, E'#{payload}'") + + video = ChannelVideo.new({ + id: id, + title: video.title, + published: published, + updated: updated, + ucid: video.ucid, + author: author, + length_seconds: video.length_seconds, + live_now: video.live_now, + premiere_timestamp: video.premiere_timestamp, + views: video.views, + }) + + was_insert = PG_DB.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, + updated = $4, ucid = $5, author = $6, length_seconds = $7, + live_now = $8, premiere_timestamp = $9, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) + + PG_DB.exec("UPDATE users SET notifications = array_append(notifications, $1), + feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert + end + end + + env.response.status_code = 200 + end +end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 44fa54d8..a977212d 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -1,5 +1,5 @@ -class Invidious::Routes::Login < Invidious::Routes::BaseRoute - def login_page(env) +module Invidious::Routes::Login + def self.login_page(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? user = env.get? "user" @@ -28,7 +28,7 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute templated "login" end - def login(env) + def self.login(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? referer = get_referer(env, "/feed/subscriptions") @@ -434,6 +434,13 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) user, sid = create_user(sid, email, password) + + if language_header = env.request.headers["Accept-Language"]? + if language = ANG.language_negotiator.best(language_header, LOCALES.keys) + user.preferences.locale = language.header + end + end + user_array = user.to_a user_array[4] = user_array[4].to_json # User preferences @@ -475,7 +482,7 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute end end - def signout(env) + def self.signout(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? user = env.get? "user" diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr index 336f7e33..82c40a95 100644 --- a/src/invidious/routes/misc.cr +++ b/src/invidious/routes/misc.cr @@ -1,5 +1,5 @@ -class Invidious::Routes::Misc < Invidious::Routes::BaseRoute - def home(env) +module Invidious::Routes::Misc + def self.home(env) preferences = env.get("preferences").as(Preferences) locale = LOCALES[preferences.locale]? user = env.get? "user" @@ -17,7 +17,7 @@ class Invidious::Routes::Misc < Invidious::Routes::BaseRoute end when "Playlists" if user - env.redirect "/view_all_playlists" + env.redirect "/feed/playlists" else env.redirect "/feed/popular" end @@ -26,17 +26,17 @@ class Invidious::Routes::Misc < Invidious::Routes::BaseRoute end end - def privacy(env) + def self.privacy(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? templated "privacy" end - def licenses(env) + def self.licenses(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? rendered "licenses" end - def cross_instance_redirect(env) + def self.cross_instance_redirect(env) referer = get_referer(env) if !env.get("preferences").as(Preferences).automatic_instance_redirect diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 1f7fa27d..05a198d8 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -1,30 +1,5 @@ -class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute - def index(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - referer = get_referer(env) - - return env.redirect "/" if user.nil? - - user = user.as(User) - - items_created = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) - items_created.map! do |item| - item.author = "" - item - end - - items_saved = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id NOT LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) - items_saved.map! do |item| - item.author = "" - item - end - - templated "view_all_playlists" - end - - def new(env) +module Invidious::Routes::Playlists + def self.new(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? user = env.get? "user" @@ -40,7 +15,7 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute templated "create_playlist" end - def create(env) + def self.create(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? user = env.get? "user" @@ -78,7 +53,7 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute env.redirect "/playlist?list=#{playlist.id}" end - def subscribe(env) + def self.subscribe(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? user = env.get? "user" @@ -95,7 +70,7 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute env.redirect "/playlist?list=#{playlist.id}" end - def delete_page(env) + def self.delete_page(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? user = env.get? "user" @@ -118,7 +93,7 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute templated "delete_playlist" end - def delete(env) + def self.delete(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? user = env.get? "user" @@ -148,10 +123,10 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid) PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid) - env.redirect "/view_all_playlists" + env.redirect "/feed/playlists" end - def edit(env) + def self.edit(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? user = env.get? "user" @@ -191,7 +166,7 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute templated "edit_playlist" end - def update(env) + def self.update(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? user = env.get? "user" @@ -235,7 +210,7 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute env.redirect "/playlist?list=#{plid}" end - def add_playlist_items_page(env) + def self.add_playlist_items_page(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? user = env.get? "user" @@ -282,7 +257,7 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute templated "add_playlist_items" end - def playlist_ajax(env) + def self.playlist_ajax(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? user = env.get? "user" @@ -409,7 +384,7 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute end end - def show(env) + def self.show(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? user = env.get?("user").try &.as(User) @@ -457,7 +432,7 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute templated "playlist" end - def mix(env) + def self.mix(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? rdid = env.params.query["list"]? diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 21d79218..0f26ec15 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -1,5 +1,5 @@ -class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute - def show(env) +module Invidious::Routes::PreferencesRoute + def self.show(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? referer = get_referer(env) @@ -9,7 +9,7 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute templated "preferences" end - def update(env) + def self.update(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? referer = get_referer(env) @@ -219,7 +219,7 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute env.redirect referer end - def toggle_theme(env) + def self.toggle_theme(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? referer = get_referer(env, unroll: false) diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 513904b8..610d5031 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -1,5 +1,5 @@ -class Invidious::Routes::Search < Invidious::Routes::BaseRoute - def opensearch(env) +module Invidious::Routes::Search + def self.opensearch(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? env.response.content_type = "application/opensearchdescription+xml" @@ -15,7 +15,7 @@ class Invidious::Routes::Search < Invidious::Routes::BaseRoute end end - def results(env) + def self.results(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? query = env.params.query["search_query"]? @@ -34,7 +34,7 @@ class Invidious::Routes::Search < Invidious::Routes::BaseRoute end end - def search(env) + def self.search(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? region = env.params.query["region"]? diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr new file mode 100644 index 00000000..acbf62b4 --- /dev/null +++ b/src/invidious/routes/video_playback.cr @@ -0,0 +1,280 @@ +module Invidious::Routes::VideoPlayback + # /videoplayback + def self.get_video_playback(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + query_params = env.params.query + + fvip = query_params["fvip"]? || "3" + mns = query_params["mn"]?.try &.split(",") + mns ||= [] of String + + if query_params["region"]? + region = query_params["region"] + query_params.delete("region") + end + + if query_params["host"]? && !query_params["host"].empty? + host = "https://#{query_params["host"]}" + query_params.delete("host") + else + host = "https://r#{fvip}---#{mns.pop}.googlevideo.com" + end + + url = "/videoplayback?#{query_params.to_s}" + + headers = HTTP::Headers.new + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + client = make_client(URI.parse(host), region) + response = HTTP::Client::Response.new(500) + error = "" + 5.times do + begin + response = client.head(url, headers) + + if response.headers["Location"]? + location = URI.parse(response.headers["Location"]) + env.response.headers["Access-Control-Allow-Origin"] = "*" + + new_host = "#{location.scheme}://#{location.host}" + if new_host != host + host = new_host + client.close + client = make_client(URI.parse(new_host), region) + end + + url = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" + else + break + end + rescue Socket::Addrinfo::Error + if !mns.empty? + mn = mns.pop + end + fvip = "3" + + host = "https://r#{fvip}---#{mn}.googlevideo.com" + client = make_client(URI.parse(host), region) + rescue ex + error = ex.message + end + end + + if response.status_code >= 400 + env.response.content_type = "text/plain" + haltf env, response.status_code + end + + if url.includes? "&file=seg.ts" + if CONFIG.disabled?("livestreams") + return error_template(403, "Administrator has disabled this endpoint.") + end + + begin + client.get(url, headers) do |response| + 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 location = response.headers["Location"]? + location = URI.parse(location) + location = "#{location.request_target}&host=#{location.host}" + + if region + location += "®ion=#{region}" + end + + return env.redirect location + end + + IO.copy(response.body_io, env.response) + end + rescue ex + end + else + if query_params["title"]? && CONFIG.disabled?("downloads") || + CONFIG.disabled?("dash") + return error_template(403, "Administrator has disabled this endpoint.") + end + + content_length = nil + first_chunk = true + range_start, range_end = parse_range(env.request.headers["Range"]?) + chunk_start = range_start + chunk_end = range_end + + if !chunk_end || chunk_end - chunk_start > HTTP_CHUNK_SIZE + chunk_end = chunk_start + HTTP_CHUNK_SIZE - 1 + end + + # TODO: Record bytes written so we can restart after a chunk fails + while true + if !range_end && content_length + range_end = content_length + end + + if range_end && chunk_start > range_end + break + end + + if range_end && chunk_end > range_end + chunk_end = range_end + end + + headers["Range"] = "bytes=#{chunk_start}-#{chunk_end}" + + begin + client.get(url, headers) do |response| + if first_chunk + if !env.request.headers["Range"]? && response.status_code == 206 + env.response.status_code = 200 + else + env.response.status_code = response.status_code + end + + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) && key.downcase != "content-range" + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if location = response.headers["Location"]? + location = URI.parse(location) + location = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" + + env.redirect location + break + end + + if title = query_params["title"]? + # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ + env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" + end + + if !response.headers.includes_word?("Transfer-Encoding", "chunked") + content_length = response.headers["Content-Range"].split("/")[-1].to_i64 + if env.request.headers["Range"]? + env.response.headers["Content-Range"] = "bytes #{range_start}-#{range_end || (content_length - 1)}/#{content_length}" + env.response.content_length = ((range_end.try &.+ 1) || content_length) - range_start + else + env.response.content_length = content_length + end + end + end + + proxy_file(response, env) + end + rescue ex + if ex.message != "Error reading socket: Connection reset by peer" + break + else + client.close + client = make_client(URI.parse(host), region) + end + end + + chunk_start = chunk_end + 1 + chunk_end += HTTP_CHUNK_SIZE + first_chunk = false + end + end + client.close + end + + # /videoplayback/* + def self.get_video_playback_greedy(env) + path = env.request.path + + path = path.lchop("/videoplayback/") + path = path.rchop("/") + + path = path.gsub(/mime\/\w+\/\w+/) do |mimetype| + mimetype = mimetype.split("/") + mimetype[0] + "/" + mimetype[1] + "%2F" + mimetype[2] + end + + path = path.split("/") + + raw_params = {} of String => Array(String) + path.each_slice(2) do |pair| + key, value = pair + value = URI.decode_www_form(value) + + if raw_params[key]? + raw_params[key] << value + else + raw_params[key] = [value] + end + end + + query_params = HTTP::Params.new(raw_params) + + env.response.headers["Access-Control-Allow-Origin"] = "*" + return env.redirect "/videoplayback?#{query_params}" + end + + # /videoplayback/* && /videoplayback/* + def self.options_video_playback(env) + env.response.headers.delete("Content-Type") + 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 + + # /latest_version + # + # YouTube /videoplayback links expire after 6 hours, + # so we have a mechanism here to redirect to the latest version + def self.latest_version(env) + if env.params.query["download_widget"]? + download_widget = JSON.parse(env.params.query["download_widget"]) + + id = download_widget["id"].as_s + title = download_widget["title"].as_s + + if label = download_widget["label"]? + return env.redirect "/api/v1/captions/#{id}?label=#{label}&title=#{title}" + else + itag = download_widget["itag"].as_s.to_i + local = "true" + end + end + + id ||= env.params.query["id"]? + itag ||= env.params.query["itag"]?.try &.to_i + + region = env.params.query["region"]? + + local ||= env.params.query["local"]? + local ||= "false" + local = local == "true" + + if !id || !itag + haltf env, status_code: 400, response: "TESTING" + end + + video = get_video(id, PG_DB, region: region) + + 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 + + if !url + haltf env, status_code: 404 + end + + url = URI.parse(url).request_target.not_nil! if local + url = "#{url}&title=#{title}" if title + + return env.redirect url + end +end diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index cd8ef910..f07b1358 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -1,5 +1,5 @@ -class Invidious::Routes::Watch < Invidious::Routes::BaseRoute - def handle(env) +module Invidious::Routes::Watch + def self.handle(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? region = env.params.query["region"]? @@ -92,7 +92,7 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute if source == "youtube" begin - comment_html = JSON.parse(fetch_youtube_comments(id, PG_DB, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] + comment_html = JSON.parse(fetch_youtube_comments(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] rescue ex if preferences.comments[1] == "reddit" comments, reddit_thread = fetch_reddit_comments(id) @@ -111,12 +111,12 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute comment_html = replace_links(comment_html) rescue ex if preferences.comments[1] == "youtube" - comment_html = JSON.parse(fetch_youtube_comments(id, PG_DB, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] + comment_html = JSON.parse(fetch_youtube_comments(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] end end end else - comment_html = JSON.parse(fetch_youtube_comments(id, PG_DB, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] + comment_html = JSON.parse(fetch_youtube_comments(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] end comment_html ||= "" @@ -190,7 +190,7 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute templated "watch" end - def redirect(env) + def self.redirect(env) url = "/watch?v=#{env.params.url["id"]}" if env.params.query.size > 0 url += "&#{env.params.query}" diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 82d0028b..e0cddeb5 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -1,15 +1,100 @@ module Invidious::Routing - macro get(path, controller, method = :handle) - get {{ path }} do |env| - controller_instance = {{ controller }}.new - controller_instance.{{ method.id }}(env) - end - end + {% for http_method in {"get", "post", "delete", "options", "patch", "put", "head"} %} - macro post(path, controller, method = :handle) - post {{ path }} do |env| - controller_instance = {{ controller }}.new - controller_instance.{{ method.id }}(env) + macro {{http_method.id}}(path, controller, method = :handle) + {{http_method.id}} \{{ path }} do |env| + \{{ controller }}.\{{ method.id }}(env) + end end - end + + {% end %} +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 + + # 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 end diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 882d21ad..a3fcc7a3 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -10,7 +10,6 @@ struct SearchVideo property description_html : String property length_seconds : Int32 property live_now : Bool - property paid : Bool property premium : Bool property premiere_timestamp : Time? @@ -91,7 +90,6 @@ struct SearchVideo json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) json.field "lengthSeconds", self.length_seconds json.field "liveNow", self.live_now - json.field "paid", self.paid json.field "premium", self.premium json.field "isUpcoming", self.is_upcoming diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index af0ce946..d9c07142 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -526,10 +526,6 @@ struct Video info["microformat"].as_h["playerMicroformatRenderer"].as_h["publishDate"] = JSON::Any.new(other.to_s("%Y-%m-%d")) end - def cookie - info["cookie"]?.try &.as_h.map { |k, v| "#{k}=#{v}" }.join("; ") || "" - end - def allow_ratings r = info["videoDetails"]["allowRatings"]?.try &.as_bool r.nil? ? false : r @@ -765,8 +761,13 @@ struct Video info["microformat"]?.try &.["playerMicroformatRenderer"]["isFamilySafe"]?.try &.as_bool || false end - def is_vr : Bool - info["streamingData"]?.try &.["adaptiveFormats"].as_a[0]?.try &.["projectionType"].as_s == "MESH" ? true : false || false + def is_vr : Bool? + projection_type = info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s + return {"EQUIRECTANGULAR", "MESH"}.includes? projection_type + end + + def projection_type : String? + return info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s end def wilson_score : Float64 @@ -780,10 +781,6 @@ struct Video def reason : String? info["reason"]?.try &.as_s end - - def session_token : String? - info["sessionToken"]?.try &.as_s? - end end struct Caption @@ -827,44 +824,61 @@ def parse_related(r : JSON::Any) : JSON::Any? JSON::Any.new(rv) end -def extract_polymer_config(body) +def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil) params = {} of String => JSON::Any - player_response = body.match(/(window\["ytInitialPlayerResponse"\]|var\sytInitialPlayerResponse)\s*=\s*(?{.*?});\s*var\s*meta/m) - .try { |r| JSON.parse(r["info"]).as_h } - if body.includes?("To continue with your YouTube experience, please fill out the form below.") || - body.includes?("https://www.google.com/sorry/index") - params["reason"] = JSON::Any.new("Could not extract video info. Instance is likely blocked.") - elsif !player_response - params["reason"] = JSON::Any.new("Video unavailable.") - elsif player_response["playabilityStatus"]?.try &.["status"]?.try &.as_s != "OK" - reason = player_response["playabilityStatus"]["errorScreen"]?.try &.["playerErrorMessageRenderer"]?.try &.["subreason"]?.try { |s| s["simpleText"]?.try &.as_s || s["runs"].as_a.map { |r| r["text"] }.join("") } || - player_response["playabilityStatus"]["reason"].as_s + client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region) + if context_screen == "embed" + client_config.client_type = YoutubeAPI::ClientType::WebScreenEmbed + end + + player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) + + if player_response["playabilityStatus"]?.try &.["status"]?.try &.as_s != "OK" + reason = player_response["playabilityStatus"]["errorScreen"]?.try &.["playerErrorMessageRenderer"]?.try &.["subreason"]?.try { |s| + s["simpleText"]?.try &.as_s || s["runs"].as_a.map { |r| r["text"] }.join("") + } || player_response["playabilityStatus"]["reason"].as_s params["reason"] = JSON::Any.new(reason) end - session_token_json_encoded = body.match(/"XSRF_TOKEN":"(?[^"]+)"/).try &.["session_token"]? || "" - params["sessionToken"] = JSON.parse(%({"key": "#{session_token_json_encoded}"}))["key"] - params["shortDescription"] = JSON::Any.new(body.match(/"og:description" content="(?[^"]+)"/).try &.["description"]?) + params["shortDescription"] = player_response.dig?("videoDetails", "shortDescription") || JSON::Any.new(nil) - return params if !player_response + # Don't fetch the next endpoint if the video is unavailable. + if !params["reason"]? + next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) + player_response = player_response.merge(next_response) + end + + # 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 context_screen == "embed" + client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed + else + client_config.client_type = YoutubeAPI::ClientType::Android + end + stream_data = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) + params["streamingData"] = stream_data["streamingData"]? || JSON::Any.new("") + end {"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f| params[f] = player_response[f] if player_response[f]? end - yt_initial_data = extract_initial_data(body) + params["relatedVideos"] = ( + player_response + .dig?("playerOverlays", "playerOverlayRenderer", "endScreen", "watchNextEndScreenRenderer", "results") + .try &.as_a.compact_map { |r| parse_related r } || \ + player_response + .dig?("webWatchNextResponseExtensionData", "relatedVideoArgs") + .try &.as_s.split(",").map { |r| + r = HTTP::Params.parse(r).to_h + JSON::Any.new(Hash.zip(r.keys, r.values.map { |v| JSON::Any.new(v) })) + } + ).try { |a| JSON::Any.new(a) } || JSON::Any.new([] of JSON::Any) - params["relatedVideos"] = yt_initial_data.try &.["playerOverlays"]?.try &.["playerOverlayRenderer"]? - .try &.["endScreen"]?.try &.["watchNextEndScreenRenderer"]?.try &.["results"]?.try &.as_a.compact_map { |r| - parse_related r - }.try { |a| JSON::Any.new(a) } || yt_initial_data.try &.["webWatchNextResponseExtensionData"]?.try &.["relatedVideoArgs"]? - .try &.as_s.split(",").map { |r| - r = HTTP::Params.parse(r).to_h - JSON::Any.new(Hash.zip(r.keys, r.values.map { |v| JSON::Any.new(v) })) - }.try { |a| JSON::Any.new(a) } || JSON::Any.new([] of JSON::Any) - - primary_results = yt_initial_data.try &.["contents"]?.try &.["twoColumnWatchNextResults"]?.try &.["results"]? + primary_results = player_response.try &.["contents"]?.try &.["twoColumnWatchNextResults"]?.try &.["results"]? .try &.["results"]?.try &.["contents"]? sentiment_bar = primary_results.try &.as_a.select { |object| object["videoPrimaryInfoRenderer"]? }[0]? .try &.["videoPrimaryInfoRenderer"]? @@ -924,20 +938,6 @@ def extract_polymer_config(body) params["subCountText"] = JSON::Any.new(author_info.try &.["subscriberCountText"]? .try { |t| t["simpleText"]? || t["runs"]?.try &.[0]?.try &.["text"]? }.try &.as_s.split(" ", 2)[0] || "-") - initial_data = body.match(/ytplayer\.config\s*=\s*(?.*?);ytplayer\.web_player_context_config/) - .try { |r| JSON.parse(r["info"]) }.try &.["args"]["player_response"]? - .try &.as_s?.try &.try { |r| JSON.parse(r).as_h } - - if initial_data - {"playabilityStatus", "streamingData"}.each do |f| - params[f] = initial_data[f] if initial_data[f]? - end - else - {"playabilityStatus", "streamingData"}.each do |f| - params[f] = player_response[f] if player_response[f]? - end - end - params end @@ -968,76 +968,27 @@ def get_video(id, db, refresh = true, region = nil, force_refresh = false) end def fetch_video(id, region) - response = YT_POOL.client(region, &.get("/watch?v=#{id}&gl=JP&hl=en&has_verified=1&bpctr=9999999999")) + info = extract_video_info(video_id: id) - if md = response.headers["location"]?.try &.match(/v=(?[a-zA-Z0-9_-]{11})/) - raise VideoRedirect.new(video_id: md["id"]) - end - - info = extract_polymer_config(response.body) - info["cookie"] = JSON::Any.new(response.cookies.to_h.transform_values { |v| JSON::Any.new(v.value) }) - allowed_regions = info["microformat"]?.try &.["playerMicroformatRenderer"]["availableCountries"]?.try &.as_a.map &.as_s || [] of String + allowed_regions = info + .dig?("microformat", "playerMicroformatRenderer", "availableCountries") + .try &.as_a.map &.as_s || [] of String # Check for region-blocks if info["reason"]?.try &.as_s.includes?("your country") bypass_regions = PROXY_LIST.keys & allowed_regions if !bypass_regions.empty? region = bypass_regions[rand(bypass_regions.size)] - response = YT_POOL.client(region, &.get("/watch?v=#{id}&gl=JP&hl=en&has_verified=1&bpctr=9999999999")) - - region_info = extract_polymer_config(response.body) + region_info = extract_video_info(video_id: id, proxy_region: region) region_info["region"] = JSON::Any.new(region) if region - region_info["cookie"] = JSON::Any.new(response.cookies.to_h.transform_values { |v| JSON::Any.new(v.value) }) info = region_info if !region_info["reason"]? end end - # Try to pull streams from embed URL + # Try to fetch video info using an embedded client if info["reason"]? - required_parameters = { - "video_id" => id, - "eurl" => "https://youtube.googleapis.com/v/#{id}", - "html5" => "1", - "gl" => "JP", - "hl" => "en", - } - if info["reason"].as_s.includes?("inappropriate") - # The html5, c and cver parameters are required in order to extract age-restricted videos - # See https://github.com/yt-dlp/yt-dlp/commit/4e6767b5f2e2523ebd3dd1240584ead53e8c8905 - required_parameters.merge!({ - "c" => "TVHTML5", - "cver" => "6.20180913", - }) - - # In order to actually extract video info without error, the `x-youtube-client-version` - # has to be set to the same version as `cver` above. - additional_headers = HTTP::Headers{"x-youtube-client-version" => "6.20180913"} - else - embed_page = YT_POOL.client &.get("/embed/#{id}").body - sts = embed_page.match(/"sts"\s*:\s*(?\d+)/).try &.["sts"]? || "" - required_parameters["sts"] = sts - additional_headers = HTTP::Headers{} of String => String - end - - embed_info = HTTP::Params.parse(YT_POOL.client &.get("/get_video_info?#{URI::Params.encode(required_parameters)}", - headers: additional_headers).body) - - if embed_info["player_response"]? - player_response = JSON.parse(embed_info["player_response"]) - {"captions", "microformat", "playabilityStatus", "streamingData", "videoDetails", "storyboards"}.each do |f| - info[f] = player_response[f] if player_response[f]? - end - end - - initial_data = JSON.parse(embed_info["watch_next_response"]) if embed_info["watch_next_response"]? - - info["relatedVideos"] = initial_data.try &.["playerOverlays"]?.try &.["playerOverlayRenderer"]? - .try &.["endScreen"]?.try &.["watchNextEndScreenRenderer"]?.try &.["results"]?.try &.as_a.compact_map { |r| - parse_related r - }.try { |a| JSON::Any.new(a) } || embed_info["rvs"]?.try &.split(",").map { |r| - r = HTTP::Params.parse(r).to_h - JSON::Any.new(Hash.zip(r.keys, r.values.map { |v| JSON::Any.new(v) })) - }.try { |a| JSON::Any.new(a) } || JSON::Any.new([] of JSON::Any) + embed_info = extract_video_info(video_id: id, context_screen: "embed") + info = embed_info if !embed_info["reason"]? end raise InfoException.new(info["reason"]?.try &.as_s || "") if !info["videoDetails"]? diff --git a/src/invidious/views/history.ecr b/src/invidious/views/feeds/history.ecr similarity index 100% rename from src/invidious/views/history.ecr rename to src/invidious/views/feeds/history.ecr diff --git a/src/invidious/views/view_all_playlists.ecr b/src/invidious/views/feeds/playlists.ecr similarity index 100% rename from src/invidious/views/view_all_playlists.ecr rename to src/invidious/views/feeds/playlists.ecr diff --git a/src/invidious/views/popular.ecr b/src/invidious/views/feeds/popular.ecr similarity index 100% rename from src/invidious/views/popular.ecr rename to src/invidious/views/feeds/popular.ecr diff --git a/src/invidious/views/subscriptions.ecr b/src/invidious/views/feeds/subscriptions.ecr similarity index 100% rename from src/invidious/views/subscriptions.ecr rename to src/invidious/views/feeds/subscriptions.ecr diff --git a/src/invidious/views/trending.ecr b/src/invidious/views/feeds/trending.ecr similarity index 100% rename from src/invidious/views/trending.ecr rename to src/invidious/views/feeds/trending.ecr diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index b1fee211..12f93a72 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -12,7 +12,7 @@ <% if playlist.is_a? InvidiousPlaylist %> <% if playlist.author == user.try &.email %> - <%= author %> | + <%= author %> | <% else %> <%= author %> | <% end %> diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index d98c3bb5..be021c59 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -312,7 +312,7 @@