iv-orgから5054510までリベース

このコミットが含まれているのは:
テクニカル諏訪子 2021-09-15 19:02:43 +09:00
コミット 11fd419089
83個のファイルの変更2858行の追加2785行の削除

ファイルの表示

@ -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.": "檢視隱私權政策。",

ファイルの表示

@ -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"

ファイル差分が大きすぎるため省略します 差分を読み込み

ファイルの表示

@ -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 ノーマルファイル
ファイルの表示

@ -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 ノーマルファイル
ファイルの表示

@ -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 ノーマルファイル
ファイルの表示

@ -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 ノーマルファイル
ファイルの表示

@ -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 ノーマルファイル
ファイルの表示

@ -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 ノーマルファイル
ファイルの表示

@ -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 ノーマルファイル
ファイルの表示

@ -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 ノーマルファイル
ファイルの表示

@ -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 ノーマルファイル
ファイルの表示

@ -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 ? "&region=#{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 += "&region=#{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 ? "&region=#{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>