diff options
28 files changed, 606 insertions, 798 deletions
diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index 756374da..9cc39783 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -30,15 +30,6 @@ jobs: username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_PASSWORD }} - - name: Cache Docker layers - if: github.ref == 'refs/heads/master' - uses: actions/cache@v2 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-multi-buildx-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-multi-buildx - - name: Build and push Docker AMD64 image for Push Event if: github.ref == 'refs/heads/master' uses: docker/build-push-action@v2 @@ -49,8 +40,6 @@ jobs: labels: quay.expires-after=12w push: true tags: quay.io/invidious/invidious:${{ github.sha }},quay.io/invidious/invidious:latest - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new - name: Build and push Docker ARM64 image for Push Event if: github.ref == 'refs/heads/master' @@ -61,12 +50,4 @@ jobs: platforms: linux/arm64/v8 labels: quay.expires-after=12w push: true - tags: quay.io/invidious/invidious:${{ github.sha }}-arm64,quay.io/invidious/invidious:latest-arm64 - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new - - - name: Override old Docker cache - if: github.ref == 'refs/heads/master' - run: | - rm -rf /tmp/.buildx-cache - mv /tmp/.buildx-cache-new /tmp/.buildx-cache + tags: quay.io/invidious/invidious:${{ github.sha }}-arm64,quay.io/invidious/invidious:latest-arm64
\ No newline at end of file diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index afed2240..aa9e2b31 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -13,8 +13,8 @@ jobs: - uses: dessant/lock-threads@v2 with: github-token: ${{ github.token }} - issue-lock-inactive-days: '30' - pr-lock-inactive-days: '30' + issue-lock-inactive-days: '240' + pr-lock-inactive-days: '240' issue-lock-reason: 'resolved' pr-lock-reason: 'resolved' diff --git a/docker/APKBUILD-boringssl b/docker/APKBUILD-boringssl deleted file mode 100644 index 61caa4f1..00000000 --- a/docker/APKBUILD-boringssl +++ /dev/null @@ -1,46 +0,0 @@ -# Based on https://aur.archlinux.org/packages/boringssl-git/ -# Maintainer: Omar Roth <omarroth@protonmail.com> -pkgname=boringssl -pkgver=1.1.0 -pkgrel=0 -pkgdesc="BoringSSL is a fork of OpenSSL that is designed to meet Google's needs" -url="https://boringssl.googlesource.com/boringssl" -arch="all" -license="MIT" -replaces="openssl libressl" -depends="!openssl-libs-static" -makedepends_host="linux-headers" -makedepends="cmake git go perl" -subpackages="$pkgname-static $pkgname-dev $pkgname-doc" -source="251b516.tar.gz::https://github.com/google/boringssl/tarball/251b516" -builddir="$srcdir/google-boringssl-251b516" - -prepare() { - : -} - -build() { - cmake -DCMAKE_BUILD_TYPE=Release . - make ssl crypto -} - -check() { - make all_tests -} - -package() { - for i in *.md ; do - install -Dm644 $i "$pkgdir/usr/share/doc/$pkgname/$i" - done - install -d "$pkgdir/usr/lib" - install -d "$pkgdir/usr/include" - cp -R include/openssl "$pkgdir/usr/include" - - install -Dm755 crypto/libcrypto.a "$pkgdir/usr/lib/libcrypto.a" - install -Dm755 ssl/libssl.a "$pkgdir/usr/lib/libssl.a" -# install -Dm755 decrepit/libdecrepit.a "$pkgdir/usr/lib/libdecrepit.a" -# install -Dm755 libboringssl_gtest.a "$pkgdir/usr/lib/libboringssl_gtest.a" -} -sha512sums=" -b1d42ed188cf0cce89d40061fa05de85b387ee4244f1236ea488a431536a2c6b657b4f03daed0ac9328c7f5c4c9330499283b8a67f1444dcf9ba5e97e1199c4e 251b516.tar.gz -" diff --git a/docker/APKBUILD-lsquic b/docker/APKBUILD-lsquic deleted file mode 100644 index 51630a0e..00000000 --- a/docker/APKBUILD-lsquic +++ /dev/null @@ -1,43 +0,0 @@ -# Maintainer: Omar Roth <omarroth@protonmail.com> -pkgname=lsquic -pkgver=2.18.1 -pkgrel=0 -pkgdesc="LiteSpeed QUIC and HTTP/3 Library" -url="https://github.com/litespeedtech/lsquic" -arch="all" -license="MIT" -depends="boringssl-dev boringssl-static zlib-static libevent-static" -makedepends="cmake git go perl bsd-compat-headers linux-headers" -subpackages="$pkgname-static" -source="v$pkgver.tar.gz::https://github.com/litespeedtech/lsquic/tarball/v2.18.1 -ls-qpack-$pkgver.tar.gz::https://github.com/litespeedtech/ls-qpack/tarball/a8ae6ef -ls-hpack-$pkgver.tar.gz::https://github.com/litespeedtech/ls-hpack/tarball/bd5d589" -builddir="$srcdir/litespeedtech-$pkgname-692a910" - -prepare() { - cp -r -T "$srcdir/litespeedtech-ls-qpack-a8ae6ef" "$builddir/src/liblsquic/ls-qpack" - cp -r -T "$srcdir/litespeedtech-ls-hpack-bd5d589" "$builddir/src/lshpack" -} - -build() { - cmake \ - -DCMAKE_BUILD_TYPE=None \ - -DBORINGSSL_INCLUDE=/usr/include/openssl \ - -DBORINGSSL_LIB_crypto=/usr/lib \ - -DBORINGSSL_LIB_ssl=/usr/lib . - make lsquic -} - -check() { - make tests -} - -package() { - install -d "$pkgdir/usr/lib" - install -Dm755 src/liblsquic/liblsquic.a "$pkgdir/usr/lib/liblsquic.a" -} -sha512sums=" -d015a72f1e88750ecb364768a40f532678f11ded09c6447a2e698b20f43fa499ef143a53f4c92a5938dfece0e39e687dc9df4aea97c618faee0c63da771561c3 v2.18.1.tar.gz -c5629085a3881815fb0b72a321eeba8de093eff9417b8ac7bde1ee1264971be0dca6d61d74799b02ae03a4c629b2a9cf21387deeb814935339a8a2503ea33fee ls-qpack-2.18.1.tar.gz -1b9f7ce4c82dadfca8154229a415b0335a61761eba698f814d4b94195c708003deb5cb89318a1ab78ac8fa88b141bc9df283fb1c6e40b3ba399660feaae353a0 ls-hpack-2.18.1.tar.gz -" diff --git a/docker/Dockerfile b/docker/Dockerfile index 7742ee39..d3d69af6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,34 +1,3 @@ -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.1.1-alpine AS builder RUN apk add --no-cache sqlite-static yaml-static @@ -37,7 +6,7 @@ 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. diff --git a/docker/Dockerfile.arm64 b/docker/Dockerfile.arm64 index 54226b66..84084a06 100644 --- a/docker/Dockerfile.arm64 +++ b/docker/Dockerfile.arm64 @@ -1,34 +1,3 @@ -FROM alpine:3.14 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 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 @@ -37,7 +6,7 @@ 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. diff --git a/locales/ar.json b/locales/ar.json index a61b71df..b5e77517 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": "اِستيراد البيانات وتصديرها", @@ -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/eo.json b/locales/eo.json index e3970159..05359382 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -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 0b9186aa..dfeb69b8 100644 --- a/locales/es.json +++ b/locales/es.json @@ -87,7 +87,7 @@ "light": "claro", "Thin mode: ": "Modo compacto: ", "Miscellaneous preferences": "Preferencias misceláneas", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", + "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? ", @@ -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/lt.json b/locales/lt.json index 89b8223c..94b4556a 100644 --- a/locales/lt.json +++ b/locales/lt.json @@ -149,7 +149,7 @@ "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/pl.json b/locales/pl.json index 2da80747..0f86154e 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", @@ -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/sv-SE.json b/locales/sv-SE.json index 7aaaab7b..42699093 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? ", @@ -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" } @@ -1,5 +1,9 @@ version: 2.0 shards: + athena-negotiation: + git: https://github.com/athena-framework/negotiation.git + version: 0.1.1 + db: git: https://github.com/crystal-lang/crystal-db.git version: 0.10.1 @@ -25,6 +25,9 @@ dependencies: 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 1962ae65..27ebd735 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,7 +373,28 @@ 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() @@ -1183,425 +1214,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 ||= "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, - 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 - -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": "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": "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>(?<url>[^<]+)<\/uri>/).each do |match| - content = "#{HOST_URL}#{URI.parse(match["url"]).request_target}" - document = document.gsub(match[0], "<uri>#{content}</uri>") - 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| diff --git a/src/invidious/helpers/youtube_api.cr b/src/invidious/helpers/youtube_api.cr index 4ed707f6..b3815f6a 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" => client_config.region || "US", # 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/feeds.cr b/src/invidious/routes/feeds.cr new file mode 100644 index 00000000..c88e96cf --- /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 ||= "US" + + 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>(?<url>[^<]+)<\/uri>/).each do |match| + content = "#{HOST_URL}#{URI.parse(match["url"]).request_target}" + document = document.gsub(match[0], "<uri>#{content}</uri>") + 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 21d3fafd..f052d3f4 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -434,6 +434,13 @@ module Invidious::Routes::Login 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 diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr index fa548f53..82c40a95 100644 --- a/src/invidious/routes/misc.cr +++ b/src/invidious/routes/misc.cr @@ -17,7 +17,7 @@ module Invidious::Routes::Misc end when "Playlists" if user - env.redirect "/view_all_playlists" + env.redirect "/feed/playlists" else env.redirect "/feed/popular" end diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index a2166bdd..05a198d8 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -1,29 +1,4 @@ module Invidious::Routes::Playlists - def self.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 self.new(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? @@ -148,7 +123,7 @@ module Invidious::Routes::Playlists 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 self.edit(env) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 79897985..27d85b92 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -301,6 +301,7 @@ struct Video json.field "likeCount", self.likes json.field "dislikeCount", self.dislikes + json.field "paid", self.paid json.field "premium", self.premium json.field "isFamilyFriendly", self.is_family_friendly json.field "allowedRegions", self.allowed_regions @@ -525,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 @@ -692,6 +689,12 @@ struct Video items end + def paid + reason = info["playabilityStatus"]?.try &.["reason"]? + paid = reason == "This video requires payment to watch." ? true : false + paid + end + def premium keywords.includes? "YouTube Red" end @@ -773,10 +776,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 @@ -820,44 +819,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*(?<info>{.*?});\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":"(?<session_token>[^"]+)"/).try &.["session_token"]? || "" - params["sessionToken"] = JSON.parse(%({"key": "#{session_token_json_encoded}"}))["key"] - params["shortDescription"] = JSON::Any.new(body.match(/"og:description" content="(?<description>[^"]+)"/).try &.["description"]?) + params["shortDescription"] = player_response.dig?("videoDetails", "shortDescription") || JSON::Any.new(nil) + + # Don't fetch the next endpoint if the video is unavailable. + if !params["reason"]? + next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) + player_response = player_response.merge(next_response) + end - return params if !player_response + # 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"]? @@ -917,20 +933,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*(?<info>.*?);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 @@ -961,76 +963,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=US&hl=en&has_verified=1&bpctr=9999999999")) - - if md = response.headers["location"]?.try &.match(/v=(?<id>[a-zA-Z0-9_-]{11})/) - raise VideoRedirect.new(video_id: md["id"]) - end + info = extract_video_info(video_id: id) - 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=US&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" => "US", - "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*(?<sts>\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 index 40584979..40584979 100644 --- a/src/invidious/views/history.ecr +++ b/src/invidious/views/feeds/history.ecr diff --git a/src/invidious/views/view_all_playlists.ecr b/src/invidious/views/feeds/playlists.ecr index 868cfeda..868cfeda 100644 --- a/src/invidious/views/view_all_playlists.ecr +++ b/src/invidious/views/feeds/playlists.ecr diff --git a/src/invidious/views/popular.ecr b/src/invidious/views/feeds/popular.ecr index e77f35b9..e77f35b9 100644 --- a/src/invidious/views/popular.ecr +++ b/src/invidious/views/feeds/popular.ecr diff --git a/src/invidious/views/subscriptions.ecr b/src/invidious/views/feeds/subscriptions.ecr index 97184e2b..97184e2b 100644 --- a/src/invidious/views/subscriptions.ecr +++ b/src/invidious/views/feeds/subscriptions.ecr diff --git a/src/invidious/views/trending.ecr b/src/invidious/views/feeds/trending.ecr index a35c4ee3..a35c4ee3 100644 --- a/src/invidious/views/trending.ecr +++ b/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 %> <b> <% if playlist.author == user.try &.email %> - <a href="/view_all_playlists"><%= author %></a> | + <a href="/feed/playlists"><%= author %></a> | <% 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 @@ </div> <div class="pure-control-group"> - <a href="/view_all_playlists"><%= translate(locale, "View all playlists") %></a> + <a href="/feed/playlists"><%= translate(locale, "View all playlists") %></a> </div> <div class="pure-control-group"> |
