iv-orgから5054510までリベース
This commit is contained in:
parent
0cd655f459
commit
11fd419089
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: .
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 && \
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
name: invidious
|
||||
|
||||
image:
|
||||
repository: iv-org/invidious
|
||||
repository: quay.io/invidious/invidious
|
||||
tag: latest
|
||||
pullPolicy: Always
|
||||
|
||||
|
|
|
@ -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": "رابط التعليق على اليوتيوب",
|
||||
|
|
|
@ -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.": "",
|
||||
|
|
|
@ -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ů.",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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.": "Προβολή πολιτικής απορρήτου.",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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.": "",
|
||||
|
|
|
@ -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.": "نمایش سیاست حفظ حریم خصوصی.",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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é.",
|
||||
|
|
|
@ -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.": "להצגת מדיניות הפרטיות.",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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": "今年",
|
||||
|
|
|
@ -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": "기능별"
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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.": "Посмотреть политику конфиденциальности.",
|
||||
|
|
|
@ -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.": "",
|
||||
|
|
|
@ -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.": "",
|
||||
|
|
|
@ -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.": "",
|
||||
|
|
|
@ -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.": "Прикажи извештај о приватности.",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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.": "Переглянути політику приватності.",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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.": "查看隐私政策。",
|
||||
|
|
|
@ -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.": "檢視隱私權政策。",
|
||||
|
|
16
shard.lock
16
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
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
2299
src/invidious.cr
2299
src/invidious.cr
File diff suppressed because it is too large
Load diff
|
@ -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\/(?<ucid>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", "<br>")
|
||||
|
||||
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", "<br>")
|
||||
|
||||
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,
|
||||
|
|
|
@ -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 = %(<a href="/watch?v=#{video_id}">#{text}</a>)
|
||||
end
|
||||
elsif url = run["navigationEndpoint"]["commandMetadata"]?.try &.["webCommandMetadata"]["url"].as_s
|
||||
elsif url = run.dig?("navigationEndpoint", "commandMetadata", "webCommandMetadata", "url").try &.as_s
|
||||
text = %(<a href="#{url}">#{text}</a>)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
224
src/invidious/routes/api/manifest.cr
Normal file
224
src/invidious/routes/api/manifest.cr
Normal file
|
@ -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>[^<]+<\/BaseURL>/) do |baseurl|
|
||||
url = baseurl.lchop("<BaseURL>")
|
||||
url = url.rchop("</BaseURL>")
|
||||
|
||||
if local
|
||||
uri = URI.parse(url)
|
||||
url = "#{uri.request_target}host/#{uri.host}/"
|
||||
end
|
||||
|
||||
"<BaseURL>#{url}</BaseURL>"
|
||||
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(?<fvip>\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
|
415
src/invidious/routes/api/v1/authenticated.cr
Normal file
415
src/invidious/routes/api/v1/authenticated.cr
Normal file
|
@ -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
|
278
src/invidious/routes/api/v1/channels.cr
Normal file
278
src/invidious/routes/api/v1/channels.cr
Normal file
|
@ -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
|
45
src/invidious/routes/api/v1/feeds.cr
Normal file
45
src/invidious/routes/api/v1/feeds.cr
Normal file
|
@ -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
|
136
src/invidious/routes/api/v1/misc.cr
Normal file
136
src/invidious/routes/api/v1/misc.cr
Normal file
|
@ -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
|
78
src/invidious/routes/api/v1/search.cr
Normal file
78
src/invidious/routes/api/v1/search.cr
Normal file
|
@ -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
|
363
src/invidious/routes/api/v1/videos.cr
Normal file
363
src/invidious/routes/api/v1/videos.cr
Normal file
|
@ -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(/<font color="#[a-fA-F0-9]{6}">/, "")
|
||||
text = text.gsub(/<\/font>/, "")
|
||||
if md = text.match(/(?<name>.*) : (?<text>.*)/)
|
||||
text = "<v #{md["name"]}>#{md["text"]}</v>"
|
||||
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
|
|
@ -1,2 +0,0 @@
|
|||
abstract class Invidious::Routes::BaseRoute
|
||||
end
|
|
@ -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"
|
||||
|
|
|
@ -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"]
|
||||
|
||||
|
|
431
src/invidious/routes/feeds.cr
Normal file
431
src/invidious/routes/feeds.cr
Normal file
|
@ -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>(?<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
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]?
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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"]?
|
||||
|
||||
|
|
280
src/invidious/routes/video_playback.cr
Normal file
280
src/invidious/routes/video_playback.cr
Normal file
|
@ -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
|
|
@ -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}"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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*(?<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)
|
||||
|
||||
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*(?<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
|
||||
|
||||
|
@ -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=(?<id>[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*(?<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"]?
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -159,7 +159,7 @@
|
|||
<div class="pure-g">
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<a href="https://github.com/iv-org/invidious">
|
||||
<%= translate(locale, "Released under the AGPLv3 by Omar Roth.") %>
|
||||
<%= translate(locale, "Released under the AGPLv3 on Github.") %>
|
||||
</a><br />
|
||||
<a href="https://git.076.ne.jp/TechnicalSuwako/invidious-mod">
|
||||
編集したソースコード(Edited source code)
|
||||
|
|
|
@ -63,7 +63,8 @@ we're going to need to do it here in order to allow for translations.
|
|||
"params" => params,
|
||||
"preferences" => preferences,
|
||||
"premiere_timestamp" => video.premiere_timestamp.try &.to_unix,
|
||||
"vr" => video.is_vr
|
||||
"vr" => video.is_vr,
|
||||
"projection_type" => video.projection_type
|
||||
}.to_pretty_json
|
||||
%>
|
||||
</script>
|
||||
|
|
Loading…
Reference in a new issue