diff options
89 files changed, 3395 insertions, 1842 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e10be8a..4aa334c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,10 +38,10 @@ jobs: matrix: stable: [true] crystal: - - 1.2.2 - 1.3.2 - - 1.4.0 - - 1.5.0 + - 1.4.1 + - 1.5.1 + - 1.6.2 include: - crystal: nightly stable: false @@ -52,7 +52,7 @@ jobs: submodules: true - name: Install Crystal - uses: crystal-lang/install-crystal@v1.6.0 + uses: crystal-lang/install-crystal@v1.7.0 with: crystal: ${{ matrix.crystal }} diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index 7e427e6e..86aec94f 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -52,7 +52,7 @@ jobs: username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_PASSWORD }} - - name: Build and push Docker AMD64 image for Push Event + - name: Build and push Docker AMD64 image without QUIC for Push Event if: github.ref == 'refs/heads/master' uses: docker/build-push-action@v3 with: @@ -62,9 +62,11 @@ jobs: labels: quay.expires-after=12w push: true tags: quay.io/invidious/invidious:${{ github.sha }},quay.io/invidious/invidious:latest - build-args: release=1 + build-args: | + "release=1" + "disable_quic=1" - - name: Build and push Docker ARM64 image for Push Event + - name: Build and push Docker ARM64 image without QUIC for Push Event if: github.ref == 'refs/heads/master' uses: docker/build-push-action@v3 with: @@ -74,4 +76,30 @@ jobs: labels: quay.expires-after=12w push: true tags: quay.io/invidious/invidious:${{ github.sha }}-arm64,quay.io/invidious/invidious:latest-arm64 + build-args: | + "release=1" + "disable_quic=1" + + - name: Build and push Docker AMD64 image with QUIC for Push Event + if: github.ref == 'refs/heads/master' + uses: docker/build-push-action@v3 + with: + context: . + file: docker/Dockerfile + platforms: linux/amd64 + labels: quay.expires-after=12w + push: true + tags: quay.io/invidious/invidious:${{ github.sha }}-quic,quay.io/invidious/invidious:latest-quic + build-args: release=1 + + - name: Build and push Docker ARM64 image with QUIC for Push Event + if: github.ref == 'refs/heads/master' + uses: docker/build-push-action@v3 + with: + context: . + file: docker/Dockerfile.arm64 + platforms: linux/arm64/v8 + labels: quay.expires-after=12w + push: true + tags: quay.io/invidious/invidious:${{ github.sha }}-arm64-quic,quay.io/invidious/invidious:latest-arm64-quic build-args: release=1 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ff28d49b..11168aea 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -22,3 +22,5 @@ jobs: stale-issue-label: "stale" stale-pr-label: "stale" ascending: true + # Never mark feature requests/enhancements as stale + exempt-issue-labels: "feature-request,enhancement" @@ -5,7 +5,7 @@ RELEASE := 1 STATIC := 0 -DISABLE_QUIC := 0 +DISABLE_QUIC := 1 NO_DBG_SYMBOLS := 0 @@ -147,7 +147,7 @@ Weblate also allows you to log-in with major SSO providers like Github, Gitlab, - [FreeTube](https://github.com/FreeTubeApp/FreeTube): A libre software YouTube app for privacy. - [CloudTube](https://sr.ht/~cadence/tube/): A JavaScript-rich alternate YouTube player. -- [PeerTubeify](https://gitlab.com/Cha_deL/peertubeify): On YouTube, displays a link to the same video on PeerTube, if it exists. +- [PeerTubeify](https://gitlab.com/Cha_de_L/peertubeify): On YouTube, displays a link to the same video on PeerTube, if it exists. - [MusicPiped](https://github.com/deep-gaurav/MusicPiped): A material design music player that streams music from YouTube. - [HoloPlay](https://github.com/stephane-r/HoloPlay): Funny Android application connecting on Invidious API's with search, playlists and favorites. - [WatchTube](https://github.com/WatchTubeTeam/WatchTube): Powerful YouTube client for Apple Watch. diff --git a/assets/css/default.css b/assets/css/default.css index 9ffff960..ab2b79e6 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -213,7 +213,7 @@ img.thumbnail { } .searchbar input[type="search"]:focus { - margin: 0 0 0.5px 0; + margin: 0; border: 2px solid; border-color: rgba(0,0,0,0); border-bottom-color: #FED; diff --git a/assets/css/player.css b/assets/css/player.css index 8a7cfdab..50c7a748 100644 --- a/assets/css/player.css +++ b/assets/css/player.css @@ -34,7 +34,7 @@ .video-js.player-style-youtube .vjs-control-bar > .vjs-spacer { flex: 1; order: 2; -} +} .video-js.player-style-youtube .vjs-play-progress .vjs-time-tooltip { display: none; @@ -175,11 +175,14 @@ ul.vjs-menu-content::-webkit-scrollbar { .video-js.player-style-invidious .vjs-play-progress { background-color: rgba(0, 182, 240, 1); } -vjs-menu-content + /* Overlay */ .video-js .vjs-overlay { - background-color: rgba(35, 35, 35, 0.75); - color: rgba(255, 255, 255, 1); + background-color: rgba(35, 35, 35, 0.75) !important; +} +.video-js .vjs-overlay * { + color: rgba(255, 255, 255, 1) !important; + text-align: center; } /* ProgressBar marker */ diff --git a/assets/js/player.js b/assets/js/player.js index b75e7134..ee678663 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -259,7 +259,7 @@ function updateCookie(newVolume, newSpeed) { // Set expiration in 2 year var date = new Date(); - date.setTime(date.getTime() + 63115200); + date.setFullYear(date.getFullYear() + 2); var ipRegex = /^((\d+\.){3}\d+|[A-Fa-f0-9]*:[A-Fa-f0-9:]*:[A-Fa-f0-9:]+)$/; var domainUsed = location.hostname; @@ -268,8 +268,10 @@ function updateCookie(newVolume, newSpeed) { if (domainUsed.charAt(0) !== '.' && !ipRegex.test(domainUsed) && domainUsed !== 'localhost') domainUsed = '.' + location.hostname; - document.cookie = 'PREFS=' + cookieData + '; SameSite=Strict; path=/; domain=' + - domainUsed + '; expires=' + date.toGMTString() + ';'; + var secure = location.protocol.startsWith("https") ? " Secure;" : ""; + + document.cookie = 'PREFS=' + cookieData + '; SameSite=Lax; path=/; domain=' + + domainUsed + '; expires=' + date.toGMTString() + ';' + secure; video_data.params.volume = volumeValue; video_data.params.speed = speedValue; diff --git a/config/config.example.yml b/config/config.example.yml index 10734c3a..264a5bea 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -304,10 +304,8 @@ https_only: false ## Number of threads to use when crawling channel videos (during ## subscriptions update). ## -## Notes: -## - Setting this to 0 will disable the channel videos crawl job. -## - This setting is overridden if "-c THREADS" or -## "--channel-threads=THREADS" are passed on the command line. +## Notes: This setting is overridden if either "-c THREADS" or +## "--channel-threads=THREADS" is passed on the command line. ## ## Accepted values: a positive integer ## Default: 1 @@ -335,10 +333,8 @@ full_refresh: false ## ## Number of threads to use when updating RSS feeds. ## -## Notes: -## - Setting this to 0 will disable the channel videos crawl job. -## - This setting is overridden if "-f THREADS" or -## "--feed-threads=THREADS" are passed on the command line. +## Notes: This setting is overridden if either "-f THREADS" or +## "--feed-threads=THREADS" is passed on the command line. ## ## Accepted values: a positive integer ## Default: 1 @@ -361,6 +357,39 @@ feed_threads: 1 #decrypt_polling: false +jobs: + + ## Options for the database cleaning job + clear_expired_items: + + ## Enable/Disable job + ## + ## Accepted values: true, false + ## Default: true + ## + enable: true + + ## Options for the channels updater job + refresh_channels: + + ## Enable/Disable job + ## + ## Accepted values: true, false + ## Default: true + ## + enable: true + + ## Options for the RSS feeds updater job + refresh_feeds: + + ## Enable/Disable job + ## + ## Accepted values: true, false + ## Default: true + ## + enable: true + + # ----------------------------- # Captcha API # ----------------------------- @@ -453,7 +482,13 @@ feed_threads: 1 ## #modified_source_code_url: "" - +## +## Maximum custom playlist length limit. +## +## Accepted values: Integer +## Default: 500 +## +#playlist_length_limit: 500 ######################################### # @@ -859,7 +894,7 @@ default_user_preferences: ## Default: false ## #automatic_instance_redirect: false - + ## ## Show the entire video description by default (when set to 'false', ## only the first few lines of the description are shown and a diff --git a/docker/Dockerfile b/docker/Dockerfile index 1346f6eb..34549df1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,6 +2,7 @@ FROM crystallang/crystal:1.4.1-alpine AS builder RUN apk add --no-cache sqlite-static yaml-static ARG release +ARG disable_quic WORKDIR /invidious COPY ./shard.yml ./shard.yml @@ -23,7 +24,13 @@ COPY ./videojs-dependencies.yml ./videojs-dependencies.yml RUN crystal spec --warnings all \ --link-flags "-lxml2 -llzma" -RUN if [ "${release}" == 1 ] ; then \ +RUN if [[ "${release}" == 1 && "${disable_quic}" == 1 ]] ; then \ + crystal build ./src/invidious.cr \ + --release \ + -Ddisable_quic \ + --static --warnings all \ + --link-flags "-lxml2 -llzma"; \ + elif [[ "${release}" == 1 ]] ; then \ crystal build ./src/invidious.cr \ --release \ --static --warnings all \ @@ -35,7 +42,7 @@ RUN if [ "${release}" == 1 ] ; then \ fi -FROM alpine:latest +FROM alpine:3.16 RUN apk add --no-cache librsvg ttf-opensans WORKDIR /invidious RUN addgroup -g 1000 -S invidious && \ diff --git a/docker/Dockerfile.arm64 b/docker/Dockerfile.arm64 index 35d3fa7b..ef3284b1 100644 --- a/docker/Dockerfile.arm64 +++ b/docker/Dockerfile.arm64 @@ -2,6 +2,7 @@ FROM alpine:3.16 AS builder RUN apk add --no-cache 'crystal=1.4.1-r0' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev ARG release +ARG disable_quic WORKDIR /invidious COPY ./shard.yml ./shard.yml @@ -23,7 +24,13 @@ COPY ./videojs-dependencies.yml ./videojs-dependencies.yml RUN crystal spec --warnings all \ --link-flags "-lxml2 -llzma" -RUN if [ ${release} == 1 ] ; then \ +RUN if [[ "${release}" == 1 && "${disable_quic}" == 1 ]] ; then \ + crystal build ./src/invidious.cr \ + --release \ + -Ddisable_quic \ + --static --warnings all \ + --link-flags "-lxml2 -llzma"; \ + elif [[ "${release}" == 1 ]] ; then \ crystal build ./src/invidious.cr \ --release \ --static --warnings all \ diff --git a/kubernetes/values.yaml b/kubernetes/values.yaml index 2dc4db2c..7f371f72 100644 --- a/kubernetes/values.yaml +++ b/kubernetes/values.yaml @@ -34,8 +34,6 @@ securityContext: # See https://github.com/bitnami/charts/tree/master/bitnami/postgresql postgresql: - image: - registry: quay.io auth: username: kemal password: kemal diff --git a/locales/ar.json b/locales/ar.json index c6ed19ce..fbe88b03 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -368,7 +368,7 @@ "footer_donate_page": "تبرّع", "preferences_region_label": "بلد المحتوى: ", "preferences_quality_dash_label": "جودة فيديو DASH المفضلة: ", - "preferences_quality_option_dash": "DASH (جودة تكييفية)", + "preferences_quality_option_dash": "DASH (الجودة التلقائية)", "preferences_quality_option_hd720": "HD720", "preferences_quality_option_medium": "متوسطة", "preferences_quality_option_small": "صغيرة", @@ -459,5 +459,82 @@ "Spanish (Spain)": "الإسبانية (إسبانيا)", "crash_page_search_issue": "بحثت عن <a href=\"`x`\"> المشكلات الموجودة على GitHub </a>", "search_filters_title": "معامل الفرز", - "search_message_no_results": "لا توجد نتائج." + "search_message_no_results": "لا توجد نتائج.", + "search_message_change_filters_or_query": "حاول توسيع استعلام البحث و / أو تغيير عوامل التصفية.", + "search_filters_date_label": "تاريخ الرفع", + "generic_count_weeks_0": "{{count}} أسبوع", + "generic_count_weeks_1": "{{count}} أسبوع", + "generic_count_weeks_2": "{{count}} أسبوع", + "generic_count_weeks_3": "{{count}} أسبوع", + "generic_count_weeks_4": "{{count}} أسابيع", + "generic_count_weeks_5": "{{count}} أسبوع", + "Popular enabled: ": "تم تمكين الشعبية: ", + "search_filters_duration_option_medium": "متوسط (4-20 دقيقة)", + "search_filters_date_option_none": "أي تاريخ", + "search_filters_type_option_all": "أي نوع", + "search_filters_features_option_vr180": "VR180", + "generic_count_minutes_0": "{{count}} دقيقة", + "generic_count_minutes_1": "{{count}} دقيقة", + "generic_count_minutes_2": "{{count}} دقيقة", + "generic_count_minutes_3": "{{count}} دقيقة", + "generic_count_minutes_4": "{{count}} دقائق", + "generic_count_minutes_5": "{{count}} دقيقة", + "generic_count_hours_0": "{{count}} ساعة", + "generic_count_hours_1": "{{count}} ساعة", + "generic_count_hours_2": "{{count}} ساعة", + "generic_count_hours_3": "{{count}} ساعة", + "generic_count_hours_4": "{{count}} ساعات", + "generic_count_hours_5": "{{count}} ساعة", + "comments_view_x_replies_0": "عرض رد {{count}}", + "comments_view_x_replies_1": "عرض رد {{count}}", + "comments_view_x_replies_2": "عرض رد {{count}}", + "comments_view_x_replies_3": "عرض رد {{count}}", + "comments_view_x_replies_4": "عرض الردود {{count}}", + "comments_view_x_replies_5": "عرض رد {{count}}", + "search_message_use_another_instance": " يمكنك أيضًا البحث عن <a href=\"`x`\"> في مثيل آخر </a>.", + "comments_points_count_0": "{{count}} نقطة", + "comments_points_count_1": "{{count}} نقطة", + "comments_points_count_2": "{{count}} نقطة", + "comments_points_count_3": "{{count}} نقطة", + "comments_points_count_4": "{{count}} نقاط", + "comments_points_count_5": "{{count}} نقطة", + "generic_count_years_0": "{{count}} السنة", + "generic_count_years_1": "{{count}} السنة", + "generic_count_years_2": "{{count}} السنة", + "generic_count_years_3": "{{count}} السنة", + "generic_count_years_4": "{{count}} سنوات", + "generic_count_years_5": "{{count}} السنة", + "tokens_count_0": "الرمز المميز {{count}}", + "tokens_count_1": "الرمز المميز {{count}}", + "tokens_count_2": "الرمز المميز {{count}}", + "tokens_count_3": "الرمز المميز {{count}}", + "tokens_count_4": "الرموز المميزة {{count}}", + "tokens_count_5": "الرمز المميز {{count}}", + "search_filters_apply_button": "تطبيق الفلاتر المحددة", + "search_filters_duration_option_none": "أي مدة", + "subscriptions_unseen_notifs_count_0": "{{count}} إشعار غير مرئي", + "subscriptions_unseen_notifs_count_1": "{{count}} إشعار غير مرئي", + "subscriptions_unseen_notifs_count_2": "{{count}} إشعار غير مرئي", + "subscriptions_unseen_notifs_count_3": "{{count}} إشعار غير مرئي", + "subscriptions_unseen_notifs_count_4": "{{count}} إشعارات غير مرئية", + "subscriptions_unseen_notifs_count_5": "{{count}} إشعار غير مرئي", + "generic_count_days_0": "{{count}} يوم", + "generic_count_days_1": "{{count}} يوم", + "generic_count_days_2": "{{count}} يوم", + "generic_count_days_3": "{{count}} يوم", + "generic_count_days_4": "{{count}} أيام", + "generic_count_days_5": "{{count}} يوم", + "generic_count_months_0": "{{count}} شهر", + "generic_count_months_1": "{{count}} شهر", + "generic_count_months_2": "{{count}} شهر", + "generic_count_months_3": "{{count}} شهر", + "generic_count_months_4": "{{count}} شهور", + "generic_count_months_5": "{{count}} شهر", + "generic_count_seconds_0": "{{count}} ثانية", + "generic_count_seconds_1": "{{count}} ثانية", + "generic_count_seconds_2": "{{count}} ثانية", + "generic_count_seconds_3": "{{count}} ثانية", + "generic_count_seconds_4": "{{count}} ثوانٍ", + "generic_count_seconds_5": "{{count}} ثانية", + "error_video_not_in_playlist": "الفيديو المطلوب غير موجود في قائمة التشغيل هذه. <a href=\"`x`\"> انقر هنا للحصول على الصفحة الرئيسية لقائمة التشغيل. </a>" } diff --git a/locales/cs.json b/locales/cs.json index 97f108d7..7538365a 100644 --- a/locales/cs.json +++ b/locales/cs.json @@ -487,5 +487,6 @@ "search_filters_sort_label": "Řadit dle", "search_filters_sort_option_relevance": "Relevantnost", "search_filters_apply_button": "Použít vybrané filtry", - "Popular enabled: ": "Populární povoleno: " + "Popular enabled: ": "Populární povoleno: ", + "error_video_not_in_playlist": "Požadované video v tomto playlistu neexistuje. <a href=\"`x`\">Klikněte sem pro navštívení domovské stránky playlistu.</a>" } diff --git a/locales/de.json b/locales/de.json index 24b83bb3..3ac32a31 100644 --- a/locales/de.json +++ b/locales/de.json @@ -367,7 +367,7 @@ "adminprefs_modified_source_code_url_label": "URL zum Repositorie des modifizierten Quellcodes", "search_filters_duration_option_short": "Kurz (< 4 Minuten)", "preferences_region_label": "Land der Inhalte: ", - "preferences_quality_option_dash": "DASH (automatische Qualität)", + "preferences_quality_option_dash": "DASH (adaptive Qualität)", "preferences_quality_option_hd720": "HD720", "preferences_quality_option_medium": "Mittel", "preferences_quality_option_small": "Niedrig", @@ -460,5 +460,16 @@ "Chinese (Taiwan)": "Chinesisch (Taiwan)", "Korean (auto-generated)": "Koreanisch (automatisch generiert)", "Portuguese (auto-generated)": "Portugiesisch (automatisch generiert)", - "search_filters_title": "Filtern" + "search_filters_title": "Filtern", + "search_message_change_filters_or_query": "Versuchen Sie, Ihre Suchanfrage zu erweitern und/oder die Filter zu ändern.", + "search_message_use_another_instance": " Sie können auch <a href=\"`x`\">auf einer anderen Instanz suchen</a>.", + "Popular enabled: ": "„Beliebt“-Seite aktiviert: ", + "search_message_no_results": "Keine Ergebnisse gefunden.", + "search_filters_duration_option_medium": "Mittel (4 - 20 Minuten)", + "search_filters_features_option_vr180": "VR180", + "search_filters_type_option_all": "Beliebiger Typ", + "search_filters_apply_button": "Ausgewählte Filter anwenden", + "search_filters_duration_option_none": "Beliebige Länge", + "search_filters_date_label": "Upload-Datum", + "search_filters_date_option_none": "Beliebiges Datum" } diff --git a/locales/el.json b/locales/el.json index 048a520b..d91d64fc 100644 --- a/locales/el.json +++ b/locales/el.json @@ -449,5 +449,6 @@ "videoinfo_invidious_embed_link": "Σύνδεσμος Ενσωμάτωσης", "search_filters_type_option_show": "Μπάρα προόδου διαβάσματος", "preferences_watch_history_label": "Ενεργοποίηση ιστορικού παρακολούθησης: ", - "search_filters_title": "Φίλτρο" + "search_filters_title": "Φίλτρο", + "search_message_no_results": "Δεν" } diff --git a/locales/en-US.json b/locales/en-US.json index 9701a621..5554b928 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -471,5 +471,6 @@ "crash_page_switch_instance": "tried to <a href=\"`x`\">use another instance</a>", "crash_page_read_the_faq": "read the <a href=\"`x`\">Frequently Asked Questions (FAQ)</a>", "crash_page_search_issue": "searched for <a href=\"`x`\">existing issues on GitHub</a>", - "crash_page_report_issue": "If none of the above helped, please <a href=\"`x`\">open a new issue on GitHub</a> (preferably in English) and include the following text in your message (do NOT translate that text):" + "crash_page_report_issue": "If none of the above helped, please <a href=\"`x`\">open a new issue on GitHub</a> (preferably in English) and include the following text in your message (do NOT translate that text):", + "error_video_not_in_playlist": "The requested video doesn't exist in this playlist. <a href=\"`x`\">Click here for the playlist home page.</a>" } diff --git a/locales/eo.json b/locales/eo.json index 40ab5f39..fb5bb69c 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -21,15 +21,15 @@ "No": "Ne", "Import and Export Data": "Importi kaj Eksporti Datumojn", "Import": "Importi", - "Import Invidious data": "Importi datumojn de Invidious", - "Import YouTube subscriptions": "Importi abonojn de JuTubo", + "Import Invidious data": "Importi JSON-datumojn de Invidious", + "Import YouTube subscriptions": "Importi abonojn de YouTube/OPML", "Import FreeTube subscriptions (.db)": "Importi abonojn de FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Importi abonojn de NewPipe (.json)", "Import NewPipe data (.zip)": "Importi datumojn de NewPipe (.zip)", "Export": "Eksporti", "Export subscriptions as OPML": "Eksporti abonojn kiel OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksporti abonojn kiel OPML (por NewPipe kaj FreeTube)", - "Export data as JSON": "Eksporti datumojn kiel JSON", + "Export data as JSON": "Eksporti Invidious-datumojn kiel JSON", "Delete account?": "Ĉu forigi konton?", "History": "Historio", "An alternative front-end to YouTube": "Alternativa fasado al JuTubo", @@ -66,7 +66,7 @@ "preferences_related_videos_label": "Ĉu montri rilatajn filmetojn? ", "preferences_annotations_label": "Ĉu montri prinotojn defaŭlte? ", "preferences_extend_desc_label": "Aŭtomate etendi priskribon de filmeto: ", - "preferences_vr_mode_label": "Interagaj 360-gradaj filmetoj: ", + "preferences_vr_mode_label": "Interagaj 360-gradaj filmoj (postulas WebGL-n): ", "preferences_category_visual": "Vidaj preferoj", "preferences_player_style_label": "Ludila stilo: ", "Dark mode: ": "Malhela reĝimo: ", @@ -75,7 +75,7 @@ "light": "hela", "preferences_thin_mode_label": "Maldika reĝimo: ", "preferences_category_misc": "Aliaj agordoj", - "preferences_automatic_instance_redirect_label": "Aŭtomata alidirektado de instalaĵo (retropaŝo al redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "Aŭtomata alidirektado de nodo (retropaŝo al redirect.invidious.io): ", "preferences_category_subscription": "Abonaj agordoj", "preferences_annotations_subscribed_label": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ", "Redirect homepage to feed: ": "Alidirekti hejmpâgon al fluo: ", @@ -140,7 +140,7 @@ "Show more": "Montri pli", "Show less": "Montri malpli", "Watch on YouTube": "Vidi filmeton en JuTubo", - "Switch Invidious Instance": "Ŝanĝi instalaĵon de Indivious", + "Switch Invidious Instance": "Ŝanĝi nodon de Indivious", "Hide annotations": "Kaŝi prinotojn", "Show annotations": "Montri prinotojn", "Genre: ": "Ĝenro: ", @@ -368,5 +368,109 @@ "footer_donate_page": "Donaci", "preferences_region_label": "Lando de la enhavo: ", "preferences_quality_dash_label": "Preferata DASH-a videkvalito: ", - "search_filters_title": "Filtri" + "search_filters_title": "Filtri", + "preferences_quality_dash_option_best": "Plej bona", + "preferences_quality_dash_option_worst": "Malplej bona", + "Popular enabled: ": "Populara sekcio ebligita: ", + "search_message_no_results": "Neniu rezulto trovita.", + "search_message_use_another_instance": " Vi ankaŭ povas <a href=\"`x`\">serĉi en alia nodo</a>.", + "tokens_count": "{{count}} ĵetono", + "tokens_count_plural": "{{count}} ĵetonoj", + "subscriptions_unseen_notifs_count": "{{count}} nevidita sciigo", + "subscriptions_unseen_notifs_count_plural": "{{count}} neviditaj sciigoj", + "Indonesian (auto-generated)": "Indonezia (aŭtomate generita)", + "Interlingue": "Interlingvo", + "Italian (auto-generated)": "Itala (aŭtomate generita)", + "Korean (auto-generated)": "Korea (aŭtomate generita)", + "Portuguese (Brazil)": "Portugala (Brazilo)", + "Portuguese (auto-generated)": "Portugala (aŭtomate generita)", + "Russian (auto-generated)": "Rusa (aŭtomate generita)", + "Spanish (Spain)": "Hispana (Hispanio)", + "generic_count_years": "{{count}} jaro", + "generic_count_years_plural": "{{count}} jaroj", + "Turkish (auto-generated)": "Turka (aŭtomate generita)", + "Vietnamese (auto-generated)": "Vjetnama (aŭtomate generita)", + "generic_count_hours": "{{count}} horo", + "generic_count_hours_plural": "{{count}} horoj", + "generic_count_minutes": "{{count}} minuto", + "generic_count_minutes_plural": "{{count}} minutoj", + "search_filters_date_label": "Alŝutdato", + "search_filters_date_option_none": "Ajna dato", + "search_filters_duration_option_medium": "Meza (4 - 20 minutoj)", + "search_filters_features_option_three_sixty": "360º", + "search_filters_features_option_vr180": "VR180", + "user_created_playlists": "`x`kreitaj ludlistoj", + "user_saved_playlists": "`x`konservitaj ludlistoj", + "crash_page_switch_instance": "klopodis <a href=\"`x`\">uzi alian nodon</a>", + "crash_page_read_the_faq": "legis la <a href=\"`x`\">oftajn demandojn</a>", + "error_video_not_in_playlist": "La petita video ne ekzistas en ĉi tiu ludlisto. <a href=\"`x`\">Alklaku ĉi tie por iri al la ludlista hejmpaĝo.</a>", + "crash_page_search_issue": "serĉis por <a href=\"`x`\">ekzistantaj problemoj en GitHub</a>", + "generic_count_seconds": "{{count}} sekundo", + "generic_count_seconds_plural": "{{count}} sekundoj", + "preferences_quality_dash_option_144p": "144p", + "comments_view_x_replies": "Vidi {{count}} respondon", + "comments_view_x_replies_plural": "Vidi {{count}} respondojn", + "preferences_quality_dash_option_360p": "360p", + "invidious": "Invidious", + "Chinese (Taiwan)": "Ĉina (Tajvano)", + "English (United Kingdom)": "Angla (Britio)", + "search_filters_features_option_purchased": "Aĉetita", + "Japanese (auto-generated)": "Japana (aŭtomate generita)", + "search_message_change_filters_or_query": "Provu vastigi vian serĉpeton kaj/aŭ ŝanĝi la filtrilojn.", + "preferences_quality_dash_option_1080p": "1080p", + "generic_count_weeks": "{{count}} semajno", + "generic_count_weeks_plural": "{{count}} semajnoj", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_auto": "Aŭtomate", + "preferences_quality_dash_option_2160p": "2160p", + "English (United States)": "Angla (Usono)", + "Chinese": "Ĉina", + "videoinfo_watch_on_youTube": "Vidi en YouTube", + "crash_page_you_found_a_bug": "Ŝajnas, ke vi trovis eraron en Invidious!", + "comments_points_count": "{{count}} poento", + "comments_points_count_plural": "{{count}} poentoj", + "Cantonese (Hong Kong)": "Kantona (Honkongo)", + "preferences_watch_history_label": "Ebligi vidohistorion: ", + "preferences_quality_option_small": "Eta", + "generic_playlists_count": "{{count}} ludlisto", + "generic_playlists_count_plural": "{{count}} ludlistoj", + "videoinfo_youTube_embed_link": "Enigi", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Meza", + "generic_subscriptions_count": "{{count}} abono", + "generic_subscriptions_count_plural": "{{count}} abonoj", + "videoinfo_started_streaming_x_ago": "Komercis elsendi antaŭ `x`", + "download_subtitles": "Subtitoloj - `x` (.vtt)", + "videoinfo_invidious_embed_link": "Enigi Ligilon", + "crash_page_report_issue": "Se neniu el la antaŭaj agoj helpis, bonvolu <a href=\"`x`\">estigi novan problemon en GitHub</a> (prefere angle) kaj inkludi la jenan tekston en via mesaĝo (NE traduku tiun tekston):", + "preferences_quality_option_dash": "DASH (adapta kvalito)", + "Chinese (Hong Kong)": "Ĉina (Honkongo)", + "Chinese (China)": "Ĉina (Ĉinio)", + "Dutch (auto-generated)": "Nederlanda (aŭtomate generita)", + "German (auto-generated)": "Germana (aŭtomate generita)", + "French (auto-generated)": "Franca (aŭtomate generita)", + "Spanish (Mexico)": "Hispana (Meksiko)", + "Spanish (auto-generated)": "Hispana (aŭtomate generita)", + "generic_count_days": "{{count}} jaro", + "generic_count_days_plural": "{{count}} jaroj", + "search_filters_type_option_all": "Ajna speco", + "search_filters_duration_option_none": "Ajna daŭro", + "search_filters_apply_button": "Uzi elektitajn filtrilojn", + "none": "neniu", + "Video unavailable": "Nedisponebla video", + "crash_page_before_reporting": "Antaŭ ol informi pri eraro certigu, ke vi:", + "crash_page_refresh": "klopodis <a href=\"`x`\">reŝarĝi la paĝon</a>", + "generic_views_count": "{{count}} spekto", + "generic_views_count_plural": "{{count}} spektoj", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} videoj", + "generic_subscribers_count": "{{count}} abonanto", + "generic_subscribers_count_plural": "{{count}} abonantoj", + "generic_count_months": "{{count}} monato", + "generic_count_months_plural": "{{count}} monatoj", + "preferences_save_player_pos_label": "Konservi ludadan pozicion: " } diff --git a/locales/es.json b/locales/es.json index 0958a736..8603e9fe 100644 --- a/locales/es.json +++ b/locales/es.json @@ -114,7 +114,7 @@ "Save preferences": "Guardar las preferencias", "Subscription manager": "Gestor de suscripciones", "Token manager": "Gestor de tokens", - "Token": "Token", + "Token": "Ficha", "Import/export": "Importar/Exportar", "unsubscribe": "Desuscribirse", "revoke": "revocar", @@ -355,7 +355,7 @@ "search_filters_features_option_location": "ubicación", "search_filters_features_option_hdr": "hdr", "Current version: ": "Versión actual: ", - "next_steps_error_message": "Después de lo cual deberías intentar: ", + "next_steps_error_message": "Después de lo cual debes intentar: ", "next_steps_error_message_refresh": "Recargar la página", "next_steps_error_message_go_to_youtube": "Ir a YouTube", "search_filters_duration_option_short": "Corto (< 4 minutos)", @@ -467,8 +467,10 @@ "search_filters_duration_option_none": "Cualquier duración", "search_filters_features_option_vr180": "VR180", "search_filters_apply_button": "Aplicar filtros seleccionados", - "tokens_count": "{{count}} token", - "tokens_count_plural": "{{count}} tokens", + "tokens_count": "{{count}} ficha", + "tokens_count_plural": "{{count}} fichas", "search_message_use_another_instance": " También puede <a href=\"`x`\">buscar en otra instancia</a>.", - "search_filters_duration_option_medium": "Medio (4 - 20 minutes)" + "search_filters_duration_option_medium": "Medio (4 - 20 minutes)", + "Popular enabled: ": "¿Habilitar la sección popular? ", + "error_video_not_in_playlist": "El vídeo solicitado no existe en esta lista de reproducción. <a href=\"`x`\">Haga clic aquí para acceder a la página de inicio de la lista de reproducción.</a>" } diff --git a/locales/fr.json b/locales/fr.json index 928a4400..2f384eb1 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -116,7 +116,7 @@ "preferences_default_home_label": "Page d'accueil par défaut : ", "preferences_feed_menu_label": "Préferences des abonnements : ", "preferences_show_nick_label": "Afficher le nom d'utilisateur en haut à droite : ", - "Popular enabled: ": "Page \"populaire\" activée: ", + "Popular enabled: ": "Page \"populaire\" activée : ", "Top enabled: ": "Top activé : ", "CAPTCHA enabled: ": "CAPTCHA activé : ", "Login enabled: ": "Autoriser l'ouverture de sessions utilisateur : ", @@ -471,5 +471,6 @@ "search_filters_type_option_all": "Tous les types", "search_filters_date_label": "Date d'ajout", "search_filters_features_option_vr180": "VR180", - "search_filters_duration_option_none": "Toutes les durées" + "search_filters_duration_option_none": "Toutes les durées", + "error_video_not_in_playlist": "La vidéo demandée n'existe pas dans cette liste de lecture. <a href=\"`x`\">Cliquez ici pour retourner à la liste de lecture.</a>" } diff --git a/locales/hr.json b/locales/hr.json index 54eef7f9..e42cc4f5 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -487,5 +487,6 @@ "search_filters_duration_option_medium": "Srednje (4 – 20 minuta)", "search_filters_apply_button": "Primijeni odabrane filtre", "search_filters_type_option_all": "Bilo koja vrsta", - "Popular enabled: ": "Popularni aktivirani: " + "Popular enabled: ": "Popularni aktivirani: ", + "error_video_not_in_playlist": "Traženi video ne postoji u ovoj zbirci. <a href=\"`x`\">Pritisni ovdje za početnu stranicu zbirke.</a>" } diff --git a/locales/id.json b/locales/id.json index d150cece..a30f0ad4 100644 --- a/locales/id.json +++ b/locales/id.json @@ -126,7 +126,7 @@ "revoke": "cabut", "Subscriptions": "Langganan", "subscriptions_unseen_notifs_count_0": "{{count}} pemberitahuan belum dilihat", - "search": "cari", + "search": "Telusuri", "Log out": "Keluar", "Released under the AGPLv3 on Github.": "Dirilis di bawah AGPLv3 di GitHub.", "Source available here.": "Sumber tersedia di sini.", @@ -447,5 +447,11 @@ "Dutch (auto-generated)": "Belanda (dihasilkan secara otomatis)", "search_filters_date_option_none": "Tanggal berapa pun", "search_filters_duration_option_none": "Durasi berapa pun", - "search_filters_duration_option_medium": "Sedang (4 - 20 menit)" + "search_filters_duration_option_medium": "Sedang (4 - 20 menit)", + "Cantonese (Hong Kong)": "Bahasa Kanton (Hong Kong)", + "crash_page_refresh": "mencoba untuk <a href=\"`x`\">memuat ulang halaman</a>", + "crash_page_switch_instance": "mencoba untuk <a href=\"`x`\">menggunakan peladen lainnya</a>", + "crash_page_read_the_faq": "baca <a href=\"`x`\">Soal Sering Ditanya (SSD/FAQ)</a>", + "crash_page_search_issue": "mencari <a href=\"`x`\">isu yang ada di GitHub</a>", + "crash_page_report_issue": "Jika yang di atas tidak membantu, <a href=\"`x`\">buka isu baru di GitHub</a> (sebaiknya dalam bahasa Inggris) dan sertakan teks berikut dalam pesan Anda (JANGAN terjemahkan teks tersebut):" } diff --git a/locales/it.json b/locales/it.json index ac83ac58..63a8e8d4 100644 --- a/locales/it.json +++ b/locales/it.json @@ -14,7 +14,7 @@ "newest": "più recente", "oldest": "più vecchio", "popular": "Tendenze", - "last": "durare", + "last": "ultimo", "Next page": "Pagina successiva", "Previous page": "Pagina precedente", "Clear watch history?": "Eliminare la cronologia dei video guardati?", @@ -158,7 +158,7 @@ "generic_views_count_plural": "{{count}} visualizzazioni", "Premieres in `x`": "In anteprima in `x`", "Premieres `x`": "In anteprima `x`", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Ciao! Sembra che tu abbia disattivato JavaScript. Clicca qui per visualizzare i commenti. Considera che potrebbe volerci più tempo.", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Ciao, Sembra che tu abbia disattivato JavaScript. Clicca qui per visualizzare i commenti, ma considera che il caricamento potrebbe richiedere più tempo.", "View YouTube comments": "Visualizza i commenti da YouTube", "View more comments on Reddit": "Visualizza più commenti su Reddit", "View `x` comments": { @@ -212,7 +212,7 @@ "Azerbaijani": "Azero", "Bangla": "Bengalese", "Basque": "Basco", - "Belarusian": "Biellorusso", + "Belarusian": "Bielorusso", "Bosnian": "Bosniaco", "Bulgarian": "Bulgaro", "Burmese": "Birmano", @@ -238,10 +238,10 @@ "Haitian Creole": "Creolo haitiano", "Hausa": "Lingua hausa", "Hawaiian": "Hawaiano", - "Hebrew": "Ebreo", + "Hebrew": "Ebraico", "Hindi": "Hindi", "Hmong": "Hmong", - "Hungarian": "Ungarese", + "Hungarian": "Ungherese", "Icelandic": "Islandese", "Igbo": "Igbo", "Indonesian": "Indonesiano", @@ -254,7 +254,7 @@ "Khmer": "Khmer", "Korean": "Coreano", "Kurdish": "Curdo", - "Kyrgyz": "Kirghize", + "Kyrgyz": "Kirghiso", "Lao": "Lao", "Latin": "Latino", "Latvian": "Lettone", @@ -269,7 +269,7 @@ "Marathi": "Marathi", "Mongolian": "Mongolo", "Nepali": "Nepalese", - "Norwegian Bokmål": "Norvegese", + "Norwegian Bokmål": "Norvegese bokmål", "Nyanja": "Nyanja", "Pashto": "Pashtu", "Persian": "Persiano", @@ -278,7 +278,7 @@ "Punjabi": "Punjabi", "Romanian": "Rumeno", "Russian": "Russo", - "Samoan": "Samoan", + "Samoan": "Samoano", "Scottish Gaelic": "Gaelico scozzese", "Serbian": "Serbo", "Shona": "Shona", @@ -293,15 +293,15 @@ "Sundanese": "Sudanese", "Swahili": "Swahili", "Swedish": "Svedese", - "Tajik": "Tajik", + "Tajik": "Tagico", "Tamil": "Tamil", "Telugu": "Telugu", - "Thai": "Thaï", + "Thai": "Thailandese", "Turkish": "Turco", "Ukrainian": "Ucraino", "Urdu": "Urdu", "Uzbek": "Uzbeco", - "Vietnamese": "Vietnamese", + "Vietnamese": "Vietnamita", "Welsh": "Gallese", "Western Frisian": "Frisone occidentale", "Xhosa": "Xhosa", @@ -364,7 +364,7 @@ "search_filters_type_option_channel": "Canale", "search_filters_type_option_playlist": "Playlist", "search_filters_type_option_movie": "Film", - "search_filters_features_option_hd": "AD", + "search_filters_features_option_hd": "HD", "search_filters_features_option_subtitles": "Sottotitoli / CC", "search_filters_features_option_c_commons": "Creative Commons", "search_filters_features_option_three_d": "3D", @@ -383,7 +383,7 @@ "preferences_quality_dash_option_4320p": "4320p", "search_filters_features_option_three_sixty": "360°", "preferences_quality_dash_option_144p": "144p", - "Released under the AGPLv3 on Github.": "Rilasciato su GitHub con licenza AGPLv3.", + "Released under the AGPLv3 on Github.": "Pubblicato su GitHub con licenza AGPLv3.", "preferences_quality_option_medium": "Media", "preferences_quality_option_small": "Limitata", "preferences_quality_dash_option_best": "Migliore", @@ -430,7 +430,7 @@ "comments_view_x_replies_plural": "Vedi {{count}} risposte", "comments_points_count": "{{count}} punto", "comments_points_count_plural": "{{count}} punti", - "Portuguese (auto-generated)": "Portoghese (auto-generato)", + "Portuguese (auto-generated)": "Portoghese (generati automaticamente)", "crash_page_you_found_a_bug": "Sembra che tu abbia trovato un bug in Invidious!", "crash_page_switch_instance": "provato a <a href=\"`x`\">usare un'altra istanza</a>", "crash_page_before_reporting": "Prima di segnalare un bug, assicurati di aver:", @@ -441,7 +441,7 @@ "English (United Kingdom)": "Inglese (Regno Unito)", "Portuguese (Brazil)": "Portoghese (Brasile)", "preferences_watch_history_label": "Attiva cronologia di riproduzione: ", - "French (auto-generated)": "Francese (auto-generato)", + "French (auto-generated)": "Francese (generati automaticamente)", "search_message_use_another_instance": " Puoi anche <a href=\"`x`\">cercare in un'altra istanza</a>.", "search_message_no_results": "Nessun risultato trovato.", "search_message_change_filters_or_query": "Prova ad ampliare la ricerca e/o modificare i filtri.", @@ -451,15 +451,15 @@ "Chinese (China)": "Cinese (Cina)", "Chinese (Hong Kong)": "Cinese (Hong Kong)", "Chinese (Taiwan)": "Cinese (Taiwan)", - "Dutch (auto-generated)": "Olandese (auto-generato)", - "German (auto-generated)": "Tedesco (auto-generato)", - "Indonesian (auto-generated)": "Indonesiano (auto-generato)", + "Dutch (auto-generated)": "Olandese (generati automaticamente)", + "German (auto-generated)": "Tedesco (generati automaticamente)", + "Indonesian (auto-generated)": "Indonesiano (generati automaticamente)", "Interlingue": "Interlingua", - "Italian (auto-generated)": "Italiano (auto-generato)", - "Japanese (auto-generated)": "Giapponese (auto-generato)", - "Korean (auto-generated)": "Coreano (auto-generato)", - "Russian (auto-generated)": "Russo (auto-generato)", - "Spanish (auto-generated)": "Spagnolo (auto-generato)", + "Italian (auto-generated)": "Italiano (generati automaticamente)", + "Japanese (auto-generated)": "Giapponese (generati automaticamente)", + "Korean (auto-generated)": "Coreano (generati automaticamente)", + "Russian (auto-generated)": "Russo (generati automaticamente)", + "Spanish (auto-generated)": "Spagnolo (generati automaticamente)", "Spanish (Mexico)": "Spagnolo (Messico)", "Spanish (Spain)": "Spagnolo (Spagna)", "Turkish (auto-generated)": "Turco (auto-generato)", @@ -471,5 +471,6 @@ "search_filters_duration_option_medium": "Media (4 - 20 minuti)", "search_filters_features_option_vr180": "VR180", "search_filters_apply_button": "Applica filtri selezionati", - "crash_page_refresh": "provato a <a href=\"`x`\">ricaricare la pagina</a>" + "crash_page_refresh": "provato a <a href=\"`x`\">ricaricare la pagina</a>", + "error_video_not_in_playlist": "Il video richiesto non esiste in questa playlist. <a href=\"`x`\">Fai clic qui per la pagina iniziale della playlist.</a>" } diff --git a/locales/ko.json b/locales/ko.json index 12c2b31f..8d79c456 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -12,22 +12,22 @@ "Dark mode: ": "다크 모드: ", "preferences_player_style_label": "플레이어 스타일: ", "preferences_category_visual": "시각 설정", - "preferences_vr_mode_label": "인터랙티브 360도 비디오: ", - "preferences_extend_desc_label": "자동으로 비디오 설명 확장: ", - "preferences_annotations_label": "기본적으로 주석 표시: ", + "preferences_vr_mode_label": "VR 영상 활성화(WebGL 필요): ", + "preferences_extend_desc_label": "자동으로 비디오 설명을 확장: ", + "preferences_annotations_label": "기본으로 주석 표시: ", "preferences_related_videos_label": "관련 동영상 보기: ", "Fallback captions: ": "대체 자막: ", "preferences_captions_label": "기본 자막: ", - "reddit": "Reddit", - "youtube": "YouTube", + "reddit": "레딧", + "youtube": "유튜브", "preferences_comments_label": "기본 댓글: ", "preferences_volume_label": "플레이어 볼륨: ", "preferences_quality_label": "선호하는 비디오 품질: ", "preferences_speed_label": "기본 속도: ", "preferences_local_label": "비디오를 프록시: ", - "preferences_listen_label": "기본적으로 듣기: ", + "preferences_listen_label": "라디오 모드 활성화: ", "preferences_continue_autoplay_label": "다음 동영상 자동재생 ", - "preferences_continue_label": "기본적으로 다음 재생: ", + "preferences_continue_label": "다음 동영상으로 이동: ", "preferences_autoplay_label": "자동재생: ", "preferences_video_loop_label": "항상 반복: ", "preferences_category_player": "플레이어 설정", @@ -46,8 +46,8 @@ "Log in/register": "로그인/회원가입", "Log in": "로그인", "source": "출처", - "JavaScript license information": "JavaScript 라이선스 정보", - "An alternative front-end to YouTube": "YouTube의 대안 프론트엔드", + "JavaScript license information": "자바스크립트 라이센스 정보", + "An alternative front-end to YouTube": "유튜브의 프론트엔드 대안", "History": "역사", "Delete account?": "계정을 삭제 하시겠습니까?", "Export data as JSON": "데이터를 JSON으로 내보내기", @@ -57,27 +57,27 @@ "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 YouTube subscriptions": "유튜브 구독 가져오기", + "Import Invidious data": "인비디어스 JSON 데이터 가져오기", "Import": "가져오기", "Import and Export Data": "데이터 가져오기 및 내보내기", "No": "아니요", "Yes": "예", "Authorize token for `x`?": "`x` 에 대한 토큰을 승인하시겠습니까?", "Authorize token?": "토큰을 승인하시겠습니까?", - "Cannot change password for Google accounts": "Google 계정의 비밀번호를 변경할 수 없습니다", + "Cannot change password for Google accounts": "구글 계정의 비밀번호를 변경할 수 없습니다", "New passwords must match": "새 비밀번호는 일치해야 합니다", "New password": "새 비밀번호", "Clear watch history?": "재생 기록을 삭제 하시겠습니까?", "Previous page": "이전 페이지", "Next page": "다음 페이지", "last": "마지막", - "Shared `x` ago": "`x` 전에 공유", + "Shared `x` ago": "`x` 전", "popular": "인기", "oldest": "오래된순", "newest": "최신순", - "View playlist on YouTube": "YouTube에서 재생목록 보기", - "View channel on YouTube": "YouTube에서 채널 보기", + "View playlist on YouTube": "유튜브에서 재생목록 보기", + "View channel on YouTube": "유튜브에서 채널 보기", "Subscribe": "구독", "Unsubscribe": "구독 취소", "LIVE": "실시간", @@ -91,7 +91,7 @@ "Japanese": "일본어", "Greek": "그리스어", "German": "독일어", - "Chinese (Traditional)": "중국어 (정자)", + "Chinese (Traditional)": "중국어 (정체자)", "Chinese (Simplified)": "중국어 (간체자)", "French": "프랑스어", "Finnish": "핀란드어", @@ -116,11 +116,11 @@ "Show replies": "댓글 보기", "Hide replies": "댓글 숨기기", "Incorrect password": "잘못된 비밀번호", - "License: ": "라이선스: ", + "License: ": "라이센스: ", "Genre: ": "장르: ", "Editing playlist `x`": "재생목록 `x` 수정하기", "Playlist privacy": "재생목록 공개 범위", - "Watch on YouTube": "YouTube에서 보기", + "Watch on YouTube": "유튜브에서 보기", "Show less": "간략히", "Show more": "더보기", "Title": "제목", @@ -129,13 +129,13 @@ "Delete playlist": "재생목록 삭제", "Delete playlist `x`?": "재생목록 `x` 를 삭제 하시겠습니까?", "Updated `x` ago": "`x` 전에 업데이트됨", - "Released under the AGPLv3 on Github.": "GitHub에 AGPLv3 으로 배포됩니다.", + "Released under the AGPLv3 on Github.": "깃허브에 AGPLv3 으로 배포됩니다.", "View all playlists": "모든 재생목록 보기", "Private": "비공개", "Unlisted": "목록에 없음", "Public": "공개", "View privacy policy.": "개인정보 처리방침 보기.", - "View JavaScript license information.": "JavaScript 라이센스 정보 보기.", + "View JavaScript license information.": "자바스크립트 라이센스 정보 보기.", "Source available here.": "소스는 여기에서 사용할 수 있습니다.", "Log out": "로그아웃", "search": "검색", @@ -183,9 +183,9 @@ "Russian": "러시아어", "Romanian": "루마니아어", "Punjabi": "펀자브어", - "Portuguese": "포르투갈어(포어)", + "Portuguese": "포르투갈어", "Polish": "폴란드어", - "Persian": "페르시아어(파사어)", + "Persian": "페르시아어", "Pashto": "파슈토어", "Nyanja": "체와어", "Norwegian Bokmål": "보크몰", @@ -202,7 +202,7 @@ "search_filters_features_option_hdr": "HDR", "Current version: ": "현재 버전: ", "next_steps_error_message_refresh": "새로 고침", - "next_steps_error_message_go_to_youtube": "YouTube로 가기", + "next_steps_error_message_go_to_youtube": "유튜브로 가기", "search_filters_features_option_subtitles": "자막", "`x` marked it with a ❤": "`x`님의 ❤", "Download as: ": "다음으로 다운로드: ", @@ -225,7 +225,7 @@ "Kazakh": "카자흐어", "Kannada": "칸나다어", "Javanese": "자바어", - "Italian": "이탈리아어(이태리어)", + "Italian": "이탈리아어", "Irish": "아일랜드어", "Indonesian": "인도네시아어", "Igbo": "이보어", @@ -245,18 +245,18 @@ "Could not create mix.": "믹스를 생성할 수 없습니다.", "`x` ago": "`x` 전", "comments_view_x_replies_0": "답글 {{count}}개 보기", - "View Reddit comments": "Reddit의 댓글 보기", + "View Reddit comments": "레딧 댓글 보기", "Engagement: ": "약속: ", "Wilson score: ": "Wilson Score: ", - "Family friendly? ": "가족 친화적입니까? ", + "Family friendly? ": "전연령 영상입니까? ", "Quota exceeded, try again in a few hours": "한도량을 초과했습니다. 몇 시간 후에 다시 시도하세요", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 개의 댓글 보기", - "": "`x` 개의 댓글 보기" + "([^.,0-9]|^)1([^.,0-9]|$)": "`x`개의 댓글 보기", + "": "`x`개의 댓글 보기" }, "Haitian Creole": "아이티 크레올어", "Gujarati": "구자라트어", - "Esperanto": "에스페란토(에스페란토어)", + "Esperanto": "에스페란토", "Georgian": "조지아어", "Galician": "갈리시아어", "Filipino": "타갈로그어(필리핀어)", @@ -273,16 +273,16 @@ "Bosnian": "보스니아어", "Belarusian": "벨라루스어", "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "로그인할 수 없습니다. 이중 인증(Authenticator 또는 SMS)이 켜져 있는지 확인하세요.", - "View more comments on Reddit": "Reddit에서 더 많은 댓글 보기", - "View YouTube comments": "YouTube 댓글 보기", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "JavaScript가 꺼져 있는 것 같습니다! 댓글을 보려면 여기를 클릭하세요. 댓글을 로드하는 데 시간이 조금 더 걸릴 수 있습니다.", - "Shared `x`": "공유된 `x`", + "View more comments on Reddit": "레딧에서 더 많은 댓글 보기", + "View YouTube comments": "유튜브 댓글 보기", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "자바스크립트가 꺼져 있는 것 같습니다! 댓글을 보려면 여기를 클릭하세요. 댓글을 로드하는 데 시간이 조금 더 걸릴 수 있습니다.", + "Shared `x`": "`x` 업로드", "Whitelisted regions: ": "차단되지 않은 지역: ", "search_filters_sort_option_views": "조회수", "Please log in": "로그인하세요", "Password cannot be longer than 55 characters": "비밀번호는 55자 이하여야 합니다", "Password cannot be empty": "비밀번호는 비워둘 수 없습니다", - "Please sign in using 'Log in with Google'": "'Google로 로그인'을 사용하여 로그인하세요", + "Please sign in using 'Log in with Google'": "'구글로 로그인'을 사용하여 로그인하세요", "Wrong username or password": "잘못된 사용자 이름 또는 비밀번호", "Password is a required field": "비밀번호는 필수 필드입니다", "User ID is a required field": "사용자 ID는 필수 필드입니다", @@ -312,13 +312,13 @@ "Fallback comments: ": "대체 댓글: ", "Swahili": "스와힐리어", "Sundanese": "순다어", - "generic_count_years_0": "{{count}} 년", - "generic_count_months_0": "{{count}} 월", - "generic_count_weeks_0": "{{count}} 주", - "generic_count_days_0": "{{count}} 일", - "generic_count_hours_0": "{{count}} 시", - "generic_count_minutes_0": "{{count}} 분", - "generic_count_seconds_0": "{{count}} 초", + "generic_count_years_0": "{{count}}년", + "generic_count_months_0": "{{count}}개월", + "generic_count_weeks_0": "{{count}}주", + "generic_count_days_0": "{{count}}일", + "generic_count_hours_0": "{{count}}시간", + "generic_count_minutes_0": "{{count}}분", + "generic_count_seconds_0": "{{count}}초", "Zulu": "줄루어", "Yoruba": "요루바어", "Yiddish": "이디시어", @@ -337,9 +337,9 @@ "Swedish": "스웨덴어", "Spanish (Latin America)": "스페인어 (라틴 아메리카)", "comments_points_count_0": "{{count}} 포인트", - "Invidious Private Feed for `x`": "`x` 에 대한 Invidious 비공개 피드", + "Invidious Private Feed for `x`": "`x` 에 대한 인비디어스 비공개 피드", "Premieres `x`": "최초 공개 `x`", - "Premieres in `x`": "`x` 에 최초 공개", + "Premieres in `x`": "`x` 후 최초 공개", "next_steps_error_message": "다음 방법을 시도해 보세요: ", "search_filters_features_option_c_commons": "크리에이티브 커먼즈", "search_filters_duration_label": "길이", @@ -352,7 +352,7 @@ "Video mode": "비디오 모드", "Audio mode": "오디오 모드", "permalink": "퍼머링크", - "YouTube comment permalink": "YouTube 댓글 퍼머링크", + "YouTube comment permalink": "유튜브 댓글 퍼머링크", "(edited)": "(수정됨)", "%A %B %-d, %Y": "%A %B %-d, %Y", "Movies": "영화", @@ -374,12 +374,87 @@ "search_filters_date_option_hour": "지난 1시간", "search_filters_sort_label": "정렬기준", "search_filters_features_label": "기능별", - "search_filters_duration_option_short": "4분 미만", - "search_filters_duration_option_long": "20분 초과", + "search_filters_duration_option_short": "짧음 (4분 미만)", + "search_filters_duration_option_long": "김 (20분 초과)", "footer_documentation": "문서", "footer_source_code": "소스 코드", "footer_original_source_code": "원본 소스 코드", "footer_modfied_source_code": "수정된 소스 코드", "adminprefs_modified_source_code_url_label": "수정된 소스 코드 저장소의 URL", - "search_filters_title": "필터" + "search_filters_title": "필터", + "preferences_quality_dash_option_4320p": "4320p", + "Popular enabled: ": "인기 급상승 활성화: ", + "Dutch (auto-generated)": "네덜란드어 (자동 생성됨)", + "Chinese (Hong Kong)": "중국어 (홍콩)", + "Chinese (Taiwan)": "중국어 (대만)", + "German (auto-generated)": "독일어 (자동 생성됨)", + "Interlingue": "Interlingue", + "search_filters_date_label": "업로드 날짜", + "search_filters_date_option_none": "모든 날짜", + "search_filters_duration_option_none": "모든 기간", + "search_filters_features_option_three_sixty": "360°", + "search_filters_features_option_purchased": "구입한 항목", + "search_filters_apply_button": "선택한 필터 적용하기", + "preferences_quality_dash_option_240p": "240p", + "preferences_region_label": "국가: ", + "preferences_quality_dash_option_1440p": "1440p", + "French (auto-generated)": "프랑스어 (자동 생성됨)", + "Indonesian (auto-generated)": "인도네시아어 (자동 생성됨)", + "Turkish (auto-generated)": "터키어 (자동 생성됨)", + "Vietnamese (auto-generated)": "베트남어 (자동 생성됨)", + "preferences_quality_dash_option_2160p": "2160p", + "Italian (auto-generated)": "이탈리아어 (자동 생성됨)", + "preferences_quality_option_medium": "보통", + "preferences_quality_dash_option_720p": "720p", + "search_filters_duration_option_medium": "중간 (4 - 20분)", + "preferences_quality_dash_option_best": "최고", + "Portuguese (auto-generated)": "포르투갈어 (자동 생성됨)", + "Spanish (Spain)": "스페인어 (스페인)", + "preferences_quality_dash_label": "선호하는 DASH 비디오 품질: ", + "preferences_quality_option_hd720": "HD720", + "Spanish (auto-generated)": "스페인어 (자동 생성됨)", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_worst": "최저", + "preferences_watch_history_label": "시청 기록 활성화: ", + "invidious": "인비디어스", + "preferences_quality_option_small": "낮음", + "preferences_quality_dash_option_auto": "자동", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_144p": "144p", + "English (United Kingdom)": "영어 (영국)", + "search_filters_features_option_vr180": "VR180", + "Cantonese (Hong Kong)": "광동어 (홍콩)", + "Portuguese (Brazil)": "포르투갈어 (브라질)", + "search_message_no_results": "결과가 없습니다.", + "search_message_change_filters_or_query": "필터를 변경하시거나 검색어를 넓게 시도해보세요.", + "search_message_use_another_instance": " 당신은 <a href=\"`x`\">다른 인스턴스에서 검색</a>할 수도 있습니다.", + "English (United States)": "영어 (미국)", + "Chinese": "중국어", + "Chinese (China)": "중국어 (중국)", + "Japanese (auto-generated)": "일본어 (자동 생성됨)", + "Korean (auto-generated)": "한국어 (자동 생성됨)", + "Russian (auto-generated)": "러시아어 (자동 생성됨)", + "Spanish (Mexico)": "스페인어 (멕시코)", + "search_filters_type_option_all": "모든 유형", + "footer_donate_page": "기부하기", + "preferences_quality_option_dash": "DASH (다양한 화질)", + "preferences_quality_dash_option_360p": "360p", + "preferences_save_player_pos_label": "이어서 보기 활성화: ", + "none": "없음", + "videoinfo_started_streaming_x_ago": "`x` 전에 스트리밍을 시작했습니다", + "crash_page_you_found_a_bug": "Invidious에서 버그를 찾은 것 같습니다!", + "download_subtitles": "자막 - `x`(.vtt)", + "user_saved_playlists": "`x`개의 저장된 재생목록", + "crash_page_before_reporting": "버그를 보고하기 전에 다음 사항이 있는지 확인합니다:", + "crash_page_search_issue": "<a href=\"`x`\">깃허브에서 기존 이슈</a>를 검색했습니다", + "Video unavailable": "비디오를 사용할 수 없음", + "crash_page_refresh": "<a href=\"`x`\">페이지를 새로고침</a>하려고 했습니다", + "videoinfo_watch_on_youTube": "유튜브에서 보기", + "crash_page_switch_instance": "<a href=\"`x`\">다른 인스턴스를 사용</a>하려고 했습니다", + "crash_page_read_the_faq": "<a href=\"`x`\">자주 묻는 질문(FAQ)</a> 읽기", + "user_created_playlists": "`x`개의 생성된 재생목록", + "crash_page_report_issue": "위의 방법 중 어느 것도 도움이 되지 않았다면, <a href=\"`x`\">깃허브에서 새 이슈를 열고</a>(가능하면 영어로) 메시지에 다음 텍스트를 포함하세요(해당 텍스트를 번역하지 마십시오):", + "videoinfo_youTube_embed_link": "임베드", + "videoinfo_invidious_embed_link": "임베드 링크", + "error_video_not_in_playlist": "요청한 동영상이 이 재생목록에 없습니다. <a href=\"`x`\">재생목록 목록을 보려면 여기를 클릭하십시오.</a>" } diff --git a/locales/lt.json b/locales/lt.json index 607b3705..35ababee 100644 --- a/locales/lt.json +++ b/locales/lt.json @@ -21,15 +21,15 @@ "No": "Ne", "Import and Export Data": "Importuoti ir eksportuoti duomenis", "Import": "Importuoti", - "Import Invidious data": "Importuoti Invidious duomenis", - "Import YouTube subscriptions": "Importuoti YouTube prenumeratas", + "Import Invidious data": "Importuoti Invidious JSON duomenis", + "Import YouTube subscriptions": "Importuoti YouTube/OPML prenumeratas", "Import FreeTube subscriptions (.db)": "Importuoti FreeTube prenumeratas (.db)", "Import NewPipe subscriptions (.json)": "Importuoti NewPipe prenumeratas (.json)", "Import NewPipe data (.zip)": "Importuoti NewPipe duomenis (.zip)", "Export": "Eksportuoti", "Export subscriptions as OPML": "Eksportuoti prenumeratas kaip OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksportuoti prenumeratas kaip OPML (skirta NewPipe & FreeTube)", - "Export data as JSON": "Eksportuoti duomenis kaip JSON", + "Export data as JSON": "Eksportuoti Invidious duomenis kaip JSON", "Delete account?": "Ištrinti paskyrą?", "History": "Istorija", "An alternative front-end to YouTube": "Alternatyvus YouTube žiūrėjimo būdas", @@ -66,7 +66,7 @@ "preferences_related_videos_label": "Rodyti susijusius vaizdo įrašus: ", "preferences_annotations_label": "Rodyti anotacijas pagal nutylėjimą: ", "preferences_extend_desc_label": "Automatiškai išplėsti vaizdo įrašo aprašymą: ", - "preferences_vr_mode_label": "Interaktyvūs 360 laipsnių vaizdo įrašai: ", + "preferences_vr_mode_label": "Interaktyvūs 360 laipsnių vaizdo įrašai (reikalingas WebGL): ", "preferences_category_visual": "Vizualinės nuostatos", "preferences_player_style_label": "Vaizdo grotuvo stilius: ", "Dark mode: ": "Tamsus rėžimas: ", @@ -153,7 +153,7 @@ "Shared `x`": "Pasidalino `x`", "Premieres in `x`": "Premjera už `x`", "Premieres `x`": "Premjera`x`", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Sveiki! Atrodo, kad turite išjungę \"JavaScript\". Spauskite čia norėdami peržiūrėti komentarus, turėkite omenyje, kad jų įkėlimas gali užtrukti.", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Sveiki! Panašu, kad turite išjungę „JavaScript“. Spustelėkite čia norėdami peržiūrėti komentarus, atminkite, kad jų įkėlimas gali užtrukti šiek tiek ilgiau.", "View YouTube comments": "Žiūrėti YouTube komentarus", "View more comments on Reddit": "Žiūrėti daugiau komentarų Reddit", "View `x` comments": { @@ -371,5 +371,122 @@ "preferences_quality_dash_option_best": "Geriausia", "preferences_quality_dash_option_worst": "Blogiausia", "preferences_quality_dash_option_auto": "Automatinis", - "search_filters_title": "Filtras" + "search_filters_title": "Filtras", + "generic_videos_count_0": "{{count}} vaizdo įrašas", + "generic_videos_count_1": "{{count}} vaizdo įrašai", + "generic_videos_count_2": "{{count}} vaizdo įrašų", + "generic_subscribers_count_0": "{{count}} prenumeratorius", + "generic_subscribers_count_1": "{{count}} prenumeratoriai", + "generic_subscribers_count_2": "{{count}} prenumeratorių", + "generic_subscriptions_count_0": "{{count}} prenumerata", + "generic_subscriptions_count_1": "{{count}} prenumeratos", + "generic_subscriptions_count_2": "{{count}} prenumeratų", + "preferences_watch_history_label": "Įgalinti žiūrėjimo istoriją: ", + "preferences_quality_dash_option_1080p": "1080p", + "invidious": "Invidious", + "preferences_quality_dash_option_720p": "720p", + "generic_playlists_count_0": "{{count}} grojaraštis", + "generic_playlists_count_1": "{{count}} grojaraščiai", + "generic_playlists_count_2": "{{count}} grojaraščių", + "preferences_quality_option_medium": "Vidutinė", + "preferences_quality_option_small": "Maža", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_144p": "144p", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_option_dash": "DASH (prisitaikanti kokybė)", + "generic_views_count_0": "{{count}} peržiūra", + "generic_views_count_1": "{{count}} peržiūros", + "generic_views_count_2": "{{count}} peržiūrų", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_240p": "240p", + "none": "nėra", + "search_filters_type_option_all": "Bet koks tipas", + "videoinfo_started_streaming_x_ago": "Pradėjo transliuoti prieš `x`", + "crash_page_switch_instance": "pabandėte <a href=\"`x`\">naudoti kitą perdavimo šaltinį</a>", + "search_filters_duration_option_none": "Bet kokia trukmė", + "search_filters_duration_option_medium": "Vidutinio ilgumo (4 - 20 minučių)", + "search_filters_features_option_vr180": "VR180", + "crash_page_before_reporting": "Prieš pranešdami apie klaidą įsitikinkite, kad:", + "crash_page_read_the_faq": "perskaitėte <a href=\"`x`\">Dažniausiai užduodamus klausimus (DUK)</a>", + "crash_page_search_issue": "ieškojote <a href=\"`x`\"> esamų problemų GitHub</a>", + "error_video_not_in_playlist": "Prašomo vaizdo įrašo šiame grojaraštyje nėra. <a href=\"`x`\">Spustelėkite čia, kad pamatytumėte grojaraščio pagrindinį puslapį.</a>", + "crash_page_report_issue": "Jei nė vienas iš pirmiau pateiktų būdų nepadėjo, prašome <a href=\"`x`\">atidaryti naują problemą GitHub</a> (pageidautina anglų kalba) ir į savo pranešimą įtraukti šį tekstą (NEVERSKITE šio teksto):", + "subscriptions_unseen_notifs_count_0": "{{count}} nematytas pranešimas", + "subscriptions_unseen_notifs_count_1": "{{count}} nematyti pranešimai", + "subscriptions_unseen_notifs_count_2": "{{count}} nematytų pranešimų", + "Vietnamese (auto-generated)": "Vietnamiečių kalba (automatiškai sugeneruota)", + "Dutch (auto-generated)": "Olandų kalba (automatiškai sugeneruota)", + "generic_count_weeks_0": "{{count}} savaitę", + "generic_count_weeks_1": "{{count}} savaitės", + "generic_count_weeks_2": "{{count}} savaičių", + "Interlingue": "Interlingue", + "Italian (auto-generated)": "Italų kalba (automatiškai sugeneruota)", + "Japanese (auto-generated)": "Japonų kalba (automatiškai sugeneruota)", + "Korean (auto-generated)": "Korėjiečių kalba (automatiškai sugeneruota)", + "generic_count_months_0": "{{count}} mėnesį", + "generic_count_months_1": "{{count}} mėnesius", + "generic_count_months_2": "{{count}} mėnesių", + "generic_count_days_0": "{{count}} dieną", + "generic_count_days_1": "{{count}} dienas", + "generic_count_days_2": "{{count}} dienų", + "generic_count_hours_0": "{{count}} valandą", + "generic_count_hours_1": "{{count}} valandas", + "generic_count_hours_2": "{{count}} valandų", + "generic_count_seconds_0": "{{count}} sekundę", + "generic_count_seconds_1": "{{count}} sekundes", + "generic_count_seconds_2": "{{count}} sekundžių", + "generic_count_minutes_0": "{{count}} minutę", + "generic_count_minutes_1": "{{count}} minutes", + "generic_count_minutes_2": "{{count}} minučių", + "generic_count_years_0": "{{count}} metus", + "generic_count_years_1": "{{count}} metus", + "generic_count_years_2": "{{count}} metų", + "Popular enabled: ": "Populiarūs įgalinti: ", + "Portuguese (auto-generated)": "Portugalų kalba (automatiškai sugeneruota)", + "videoinfo_watch_on_youTube": "Žiaurėti Youtube", + "Chinese (China)": "Kinų kalba (Kinija)", + "crash_page_you_found_a_bug": "Atrodo, kad radote \"Invidious\" klaidą!", + "search_filters_features_option_three_sixty": "360°", + "English (United Kingdom)": "Anglų kalba (Jungtinė Karalystė)", + "Chinese (Hong Kong)": "Kinų kalba (Honkongas)", + "search_message_change_filters_or_query": "Pabandykite išplėsti paieškos užklausą ir (arba) pakeisti filtrus.", + "English (United States)": "Anglų kalba (Jungtinės Amerikos Valstijos)", + "Chinese (Taiwan)": "Kinų kalba (Taivanas)", + "search_message_use_another_instance": " Taip pat galite <a href=\"`x`\">ieškoti kitame perdavimo šaltinyje</a>.", + "tokens_count_0": "{{count}} žetonas", + "tokens_count_1": "{{count}} žetonai", + "tokens_count_2": "{{count}} žetonų", + "search_message_no_results": "Rezultatų nerasta.", + "comments_view_x_replies_0": "Žiūrėti {{count}} atsakymą", + "comments_view_x_replies_1": "Žiūrėti {{count}} atsakymus", + "comments_view_x_replies_2": "Žiūrėti {{count}} atsakymų", + "comments_points_count_0": "{{count}} taškas", + "comments_points_count_1": "{{count}} taškai", + "comments_points_count_2": "{{count}} taškų", + "Cantonese (Hong Kong)": "Kantono kalba (Honkongas)", + "Chinese": "Kinų", + "French (auto-generated)": "Prancūzų kalba (automatiškai sugeneruota)", + "German (auto-generated)": "Vokiečių kalba (automatiškai sugeneruota)", + "Indonesian (auto-generated)": "Indoneziečių kalba (automatiškai sugeneruota)", + "Portuguese (Brazil)": "Portugalų kalba (Brazilija)", + "Russian (auto-generated)": "Rusų kalba (automatiškai sugeneruota)", + "Spanish (Mexico)": "Ispanų kalba (Meksika)", + "Spanish (auto-generated)": "Ispanų kalba (automatiškai sugeneruota)", + "Spanish (Spain)": "Ispanų kalba (Ispanija)", + "Turkish (auto-generated)": "Turkų kalba (automatiškai sugeneruota)", + "search_filters_date_label": "Įkėlimo data", + "search_filters_date_option_none": "Bet kokia data", + "search_filters_features_option_purchased": "Įsigyta", + "search_filters_apply_button": "Taikyti pasirinktus filtrus", + "download_subtitles": "Subtitrai - `x` (.vtt)", + "user_created_playlists": "`x` sukurti grojaraščiai", + "user_saved_playlists": "`x` išsaugoti grojaraščiai", + "Video unavailable": "Vaizdo įrašas nepasiekiamas", + "preferences_save_player_pos_label": "Išsaugoti atkūrimo padėtį: ", + "videoinfo_youTube_embed_link": "Įterpti", + "videoinfo_invidious_embed_link": "Įterpti nuorodą", + "crash_page_refresh": "pabandėte <a href=\"`x`\">atnaujinti puslapį</a>" } diff --git a/locales/nb-NO.json b/locales/nb-NO.json index 77c688d5..f4c2021b 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -461,15 +461,16 @@ "Dutch (auto-generated)": "Nederlandsk (laget automatisk)", "Turkish (auto-generated)": "Tyrkisk (laget automatisk)", "search_filters_title": "Filtrer", - "Popular enabled: ": "Populære påskrudd: ", + "Popular enabled: ": "Populære aktiv: ", "search_message_change_filters_or_query": "Prøv ett mindre snevert søk og/eller endre filterne.", "search_filters_duration_option_medium": "Middels (4–20 minutter)", "search_message_no_results": "Resultatløst.", "search_filters_type_option_all": "Alle typer", - "search_filters_duration_option_none": "Uvilkårlig varighet", + "search_filters_duration_option_none": "Enhver varighet", "search_message_use_another_instance": " Du kan også <a href=\"`x`\">søke på en annen instans</a>.", "search_filters_date_label": "Opplastningsdato", "search_filters_apply_button": "Bruk valgte filtre", "search_filters_date_option_none": "Siden begynnelsen", - "search_filters_features_option_vr180": "VR180" + "search_filters_features_option_vr180": "VR180", + "error_video_not_in_playlist": "Forespurt video finnes ikke i denne spillelisten. <a href=\"`x`\">Trykk her for spillelistens hjemmeside.</a>" } diff --git a/locales/pl.json b/locales/pl.json index 37f951a3..f1a07490 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -54,7 +54,7 @@ "preferences_continue_label": "Domyślnie odtwarzaj następny: ", "preferences_continue_autoplay_label": "Odtwórz następny film: ", "preferences_listen_label": "Tryb dźwiękowy: ", - "preferences_local_label": "Filmy przez proxy? ", + "preferences_local_label": "Wideo przez proxy? ", "preferences_speed_label": "Domyślna prędkość: ", "preferences_quality_label": "Preferowana jakość filmów: ", "preferences_volume_label": "Głośność odtwarzacza: ", @@ -112,7 +112,7 @@ "Registration enabled: ": "Rejestracja włączona? ", "Report statistics: ": "Raportować statystyki? ", "Save preferences": "Zapisz preferencje", - "Subscription manager": "Manager subskrybcji", + "Subscription manager": "Menedżer subskrypcji", "Token manager": "Menedżer tokenów", "Token": "Token", "Import/export": "Import/Eksport", @@ -283,7 +283,7 @@ "Somali": "somalijski", "Southern Sotho": "sotho południowy", "Spanish": "hiszpański", - "Spanish (Latin America)": "hiszpański (ameryka łacińska)", + "Spanish (Latin America)": "hiszpański (Ameryka Łacińska)", "Sundanese": "sundajski", "Swahili": "suahili", "Swedish": "szwedzki", @@ -329,32 +329,32 @@ "Community": "Społeczność", "search_filters_sort_option_relevance": "Trafność", "search_filters_sort_option_rating": "Ocena", - "search_filters_sort_option_date": "data", + "search_filters_sort_option_date": "Data przesłania", "search_filters_sort_option_views": "Liczba wyświetleń", "search_filters_type_label": "Typ", "search_filters_duration_label": "Długość", "search_filters_features_label": "Funkcje", - "search_filters_sort_label": "sortuj", - "search_filters_date_option_hour": "godzina", - "search_filters_date_option_today": "dzisiaj", - "search_filters_date_option_week": "tydzień", - "search_filters_date_option_month": "miesiąc", - "search_filters_date_option_year": "rok", - "search_filters_type_option_video": "Film", - "search_filters_type_option_channel": "kanał", - "search_filters_type_option_playlist": "playlista", - "search_filters_type_option_movie": "film", - "search_filters_type_option_show": "pokaż", - "search_filters_features_option_hd": "hd", - "search_filters_features_option_subtitles": "napisy", - "search_filters_features_option_c_commons": "creative_commons", - "search_filters_features_option_three_d": "3d", + "search_filters_sort_label": "Sortuj wg", + "search_filters_date_option_hour": "Ostatnia godzina", + "search_filters_date_option_today": "Dzisiaj", + "search_filters_date_option_week": "W tym tygodniu", + "search_filters_date_option_month": "W tym miesiącu", + "search_filters_date_option_year": "W tym roku", + "search_filters_type_option_video": "Wideo", + "search_filters_type_option_channel": "Kanał", + "search_filters_type_option_playlist": "Playlista", + "search_filters_type_option_movie": "Film", + "search_filters_type_option_show": "Pokaż", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Napisy/CC", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", "search_filters_features_option_live": "Na żywo", - "search_filters_features_option_four_k": "4k", + "search_filters_features_option_four_k": "4K", "search_filters_features_option_location": "Lokalizacja", - "search_filters_features_option_hdr": "hdr", + "search_filters_features_option_hdr": "HDR", "Current version: ": "Aktualna wersja: ", - "next_steps_error_message": "Po czym powinien*ś spróbować: ", + "next_steps_error_message": "Po czym należy spróbować: ", "next_steps_error_message_refresh": "Odśwież", "next_steps_error_message_go_to_youtube": "Przejdź do YouTube", "invidious": "Invidious", @@ -397,11 +397,11 @@ "generic_count_seconds_0": "{{count}} sekunda", "generic_count_seconds_1": "{{count}} sekundy", "generic_count_seconds_2": "{{count}} sekund", - "crash_page_you_found_a_bug": "Wygląda na to że udało ci się znaleźć błąd w Invidious!", + "crash_page_you_found_a_bug": "Wygląda na to, że udało ci się znaleźć błąd w Invidious!", "crash_page_refresh": "próbowano <a href=\"`x`\">odświeżyć stronę</a>", - "crash_page_switch_instance": "spróbowano <a href=\"`x`\"> użyć innej instancji</a>", - "crash_page_read_the_faq": "przeczytaj <a href=\"`x`\"> Często Zadawane Pytania (FAQ)</a>", - "crash_page_search_issue": "próbowano poszukać <a href=\"`x`\"> istniejących zgłoszeń na GitHub'ie</a>", + "crash_page_switch_instance": "próbowano <a href=\"`x`\">użyć innej instancji</a>", + "crash_page_read_the_faq": "przeczytaj <a href=\"`x`\">Najczęściej zadawane pytania (FAQ)</a>", + "crash_page_search_issue": "próbowano poszukać <a href=\"`x`\">istniejących zgłoszeń na GitHubie</a>", "preferences_quality_dash_option_1440p": "1440p", "preferences_quality_dash_option_720p": "720p", "preferences_quality_dash_option_144p": "144p", @@ -418,12 +418,12 @@ "generic_count_years_0": "{{count}} rok", "generic_count_years_1": "{{count}} lata", "generic_count_years_2": "{{count}} lat", - "crash_page_before_reporting": "Przed zgłoszeniem błędu, upewnij się że masz:", - "crash_page_report_issue": "Jeżeli nic z powyższych opcji nie pomogło, proszę <a href=\"`x`\"> otworzyć nowe zgłoszenie na GitHub'ie</a> (najlepiej po Angielsku) i dodać poniższy tekst w twojej wiadomości (NIE tłumacz tego tekstu):", + "crash_page_before_reporting": "Przed zgłoszeniem błędu, upewnij się, że masz:", + "crash_page_report_issue": "Jeżeli nic z powyższych opcji nie pomogło, proszę <a href=\"`x`\">otworzyć nowe zgłoszenie na GitHubie</a> (najlepiej po angielsku) i dodać poniższy tekst w twojej wiadomości (NIE tłumacz tego tekstu):", "preferences_quality_dash_option_auto": "Automatyczna", "preferences_quality_dash_option_best": "Najlepsza", "preferences_quality_dash_option_worst": "Najgorsza", - "preferences_quality_option_dash": "DASH (jakość adaptywna)", + "preferences_quality_option_dash": "DASH (jakość adaptacyjna)", "preferences_quality_option_hd720": "HD720", "preferences_quality_option_medium": "Średnia", "preferences_quality_option_small": "Mała", @@ -445,19 +445,19 @@ "preferences_save_player_pos_label": "Zapisz pozycję odtwarzania: ", "preferences_region_label": "Region zawartości: ", "Released under the AGPLv3 on Github.": "Wydany na licencji AGPLv3 na GitHub.", - "search_filters_duration_option_short": "Krótkie (< 4 minutes)", - "search_filters_duration_option_long": "Długie (> 20 minutes)", + "search_filters_duration_option_short": "Krótka (< 4 minut)", + "search_filters_duration_option_long": "Długa (> 20 minut)", "footer_documentation": "Dokumentacja", "footer_source_code": "Kod źródłowy", - "footer_modfied_source_code": "Zmodyfikowany Kod źródłowy", + "footer_modfied_source_code": "Zmodyfikowany kod źródłowy", "footer_original_source_code": "Oryginalny kod źródłowy", - "adminprefs_modified_source_code_url_label": "Adres URL do repozytorium z zmodyfikowanym kodem źródłowym", + "adminprefs_modified_source_code_url_label": "Adres URL do repozytorium ze zmodyfikowanym kodem źródłowym", "English (United Kingdom)": "angielski (Wielka Brytania)", "English (United States)": "angielski (Stany Zjednoczone)", - "Cantonese (Hong Kong)": "kantoński (Hong Kong)", + "Cantonese (Hong Kong)": "kantoński (Hongkong)", "Chinese": "chiński", "Chinese (China)": "chiński (Chiny)", - "Chinese (Hong Kong)": "chiński (Hong Kong)", + "Chinese (Hong Kong)": "chiński (Hongkong)", "Chinese (Taiwan)": "chiński (Tajwan)", "Dutch (auto-generated)": "niderlandzki (wygenerowany automatycznie)", "French (auto-generated)": "francuski (wygenerowany automatycznie)", @@ -475,5 +475,18 @@ "Russian (auto-generated)": "rosyjski (wygenerowany automatycznie)", "Portuguese (auto-generated)": "portugalski (wygenerowany automatycznie)", "Portuguese (Brazil)": "portugalski (Brazylia)", - "search_filters_title": "Filtr" + "search_filters_title": "Filtr", + "error_video_not_in_playlist": "Żądany film nie istnieje na tej playliście. <a href=\"`x`\">Kliknij tutaj, aby przejść do strony głównej playlisty.</a>", + "Popular enabled: ": "Popularne włączone: ", + "search_message_no_results": "Nie znaleziono wyników.", + "preferences_watch_history_label": "Włącz historię oglądania: ", + "search_filters_apply_button": "Zastosuj wybrane filtry", + "search_message_change_filters_or_query": "Spróbuj poszerzyć zapytanie i/lub zmienić filtry.", + "search_filters_date_label": "Data przesłania", + "search_filters_features_option_vr180": "VR180", + "search_filters_date_option_none": "Dowolna data", + "search_message_use_another_instance": " Możesz także <a href=\"`x`\">wyszukać w innej instancji</a>.", + "search_filters_type_option_all": "Dowolny typ", + "search_filters_duration_option_none": "Dowolna długość", + "search_filters_duration_option_medium": "Średnia (4-20 minut)" } diff --git a/locales/pt-PT.json b/locales/pt-PT.json index b00ebc72..5313915b 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -408,5 +408,59 @@ "preferences_quality_dash_option_1080p": "1080p", "preferences_quality_dash_option_480p": "480p", "preferences_quality_dash_option_360p": "360p", - "preferences_quality_dash_option_240p": "240p" + "preferences_quality_dash_option_240p": "240p", + "Video unavailable": "Vídeo indisponível", + "Russian (auto-generated)": "Russo (geradas automaticamente)", + "comments_view_x_replies": "Ver {{count}} resposta", + "comments_view_x_replies_plural": "Ver {{count}} respostas", + "comments_points_count": "{{count}} ponto", + "comments_points_count_plural": "{{count}} pontos", + "English (United Kingdom)": "Inglês (Reino Unido)", + "Chinese (Hong Kong)": "Chinês (Hong Kong)", + "Chinese (Taiwan)": "Chinês (Taiwan)", + "Dutch (auto-generated)": "Holandês (geradas automaticamente)", + "French (auto-generated)": "Francês (geradas automaticamente)", + "German (auto-generated)": "Alemão (geradas automaticamente)", + "Indonesian (auto-generated)": "Indonésio (geradas automaticamente)", + "Interlingue": "Interlingue", + "Italian (auto-generated)": "Italiano (geradas automaticamente)", + "Japanese (auto-generated)": "Japonês (geradas automaticamente)", + "Korean (auto-generated)": "Coreano (geradas automaticamente)", + "Portuguese (auto-generated)": "Português (geradas automaticamente)", + "Portuguese (Brazil)": "Português (Brasil)", + "Spanish (Spain)": "Espanhol (Espanha)", + "Vietnamese (auto-generated)": "Vietnamita (geradas automaticamente)", + "search_filters_type_option_all": "Qualquer tipo", + "search_filters_duration_option_none": "Qualquer duração", + "search_filters_duration_option_short": "Curto (< 4 minutos)", + "search_filters_duration_option_medium": "Médio (4 - 20 minutos)", + "search_filters_duration_option_long": "Longo (> 20 minutos)", + "search_filters_features_option_purchased": "Comprado", + "search_filters_apply_button": "Aplicar filtros selecionados", + "videoinfo_watch_on_youTube": "Ver no YouTube", + "videoinfo_youTube_embed_link": "Embutir", + "adminprefs_modified_source_code_url_label": "URL do repositório do código-fonte modificado", + "videoinfo_invidious_embed_link": "Ligação embutida", + "none": "nenhum", + "videoinfo_started_streaming_x_ago": "Entrou em direto há `x`", + "download_subtitles": "Legendas - `x` (.vtt)", + "user_created_playlists": "`x` listas de reprodução criadas", + "user_saved_playlists": "`x` listas de reprodução guardadas", + "preferences_save_player_pos_label": "Guardar posição de reprodução: ", + "Turkish (auto-generated)": "Turco (geradas automaticamente)", + "Cantonese (Hong Kong)": "Cantonês (Hong Kong)", + "Chinese (China)": "Chinês (China)", + "Spanish (auto-generated)": "Espanhol (geradas automaticamente)", + "Spanish (Mexico)": "Espanhol (México)", + "English (United States)": "Inglês (Estados Unidos)", + "footer_donate_page": "Doar", + "footer_documentation": "Documentação", + "footer_source_code": "Código-fonte", + "footer_original_source_code": "Código-fonte original", + "footer_modfied_source_code": "Código-fonte modificado", + "Chinese": "Chinês", + "search_filters_date_label": "Data de carregamento", + "search_filters_date_option_none": "Qualquer data", + "search_filters_features_option_three_sixty": "360°", + "search_filters_features_option_vr180": "VR180" } diff --git a/locales/pt.json b/locales/pt.json index 654cfdeb..b550bc87 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -471,5 +471,6 @@ "search_filters_date_option_none": "Qualquer data", "search_filters_type_option_all": "Qualquer tipo", "search_filters_duration_option_none": "Qualquer duração", - "Popular enabled: ": "Página \"popular\" ativada: " + "Popular enabled: ": "Página \"popular\" ativada: ", + "error_video_not_in_playlist": "O vídeo pedido não existe nesta lista de reprodução. <a href=\"`x`\">Clique aqui para a página inicial da lista de reprodução.</a>" } diff --git a/locales/ru.json b/locales/ru.json index 4680e350..93c9cbec 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -102,13 +102,13 @@ "Manage tokens": "Управление токенами", "Watch history": "История просмотров", "Delete account": "Удалить аккаунт", - "preferences_category_admin": "Администраторские настройки", + "preferences_category_admin": "Настройки администратора", "preferences_default_home_label": "Главная страница по умолчанию: ", "preferences_feed_menu_label": "Меню ленты видео: ", "preferences_show_nick_label": "Показать ник вверху: ", "Top enabled: ": "Включить топ видео? ", "CAPTCHA enabled: ": "Включить капчу? ", - "Login enabled: ": "Включить авторизацию? ", + "Login enabled: ": "Включить авторизацию: ", "Registration enabled: ": "Включить регистрацию? ", "Report statistics: ": "Сообщать статистику? ", "Save preferences": "Сохранить настройки", @@ -195,7 +195,7 @@ "Hidden field \"token\" is a required field": "Необходимо заполнить скрытое поле «токен»", "Erroneous challenge": "Неправильный ответ в «challenge»", "Erroneous token": "Неправильный токен", - "No such user": "Недопустимое имя пользователя", + "No such user": "Пользователь не найден", "Token is expired, please try again": "Срок действия токена истёк, попробуйте позже", "English": "Английский", "English (auto-generated)": "Английский (созданы автоматически)", @@ -487,5 +487,6 @@ "search_message_change_filters_or_query": "Попробуйте расширить поисковый запрос или изменить фильтры.", "search_filters_duration_option_medium": "Средние (4 - 20 минут)", "search_filters_apply_button": "Применить фильтры", - "Popular enabled: ": "Популярное включено: " + "Popular enabled: ": "Популярное включено: ", + "error_video_not_in_playlist": "Запрошенного видео нет в этом плейлисте. <a href=\"`x`\">Нажмите тут, чтобы вернуться к странице плейлиста.</a>" } diff --git a/locales/si.json b/locales/si.json new file mode 100644 index 00000000..69501343 --- /dev/null +++ b/locales/si.json @@ -0,0 +1,126 @@ +{ + "generic_views_count": "බැලීම් {{count}}", + "generic_views_count_plural": "බැලීම් {{count}}", + "generic_videos_count": "{{count}} වීඩියෝව", + "generic_videos_count_plural": "වීඩියෝ {{count}}", + "generic_subscribers_count": "ග්රාහකයන් {{count}}", + "generic_subscribers_count_plural": "ග්රාහකයන් {{count}}", + "generic_subscriptions_count": "දායකත්ව {{count}}", + "generic_subscriptions_count_plural": "දායකත්ව {{count}}", + "Shared `x` ago": "`x` පෙර බෙදා ගන්නා ලදී", + "Unsubscribe": "දායක නොවන්න", + "View playlist on YouTube": "YouTube හි ධාවන ලැයිස්තුව බලන්න", + "newest": "අලුත්ම", + "oldest": "පැරණිතම", + "popular": "ජනප්රිය", + "last": "අවසන්", + "Cannot change password for Google accounts": "Google ගිණුම් සඳහා මුරපදය වෙනස් කළ නොහැක", + "Authorize token?": "ටෝකනය අනුමත කරනවා ද?", + "Authorize token for `x`?": "`x` සඳහා ටෝකනය අනුමත කරනවා ද?", + "Yes": "ඔව්", + "Import and Export Data": "දත්ත ආනයනය සහ අපනයනය කිරීම", + "Import": "ආනයන", + "Import Invidious data": "Invidious JSON දත්ත ආයාත කරන්න", + "Import FreeTube subscriptions (.db)": "FreeTube දායකත්වයන් (.db) ආයාත කරන්න", + "Import NewPipe subscriptions (.json)": "NewPipe දායකත්වයන් (.json) ආයාත කරන්න", + "Import NewPipe data (.zip)": "NewPipe දත්ත (.zip) ආයාත කරන්න", + "Export": "අපනයන", + "Export data as JSON": "Invidious දත්ත JSON ලෙස අපනයනය කරන්න", + "Delete account?": "ගිණුම මකාදමනවා ද?", + "History": "ඉතිහාසය", + "An alternative front-end to YouTube": "YouTube සඳහා විකල්ප ඉදිරිපස අන්තයක්", + "source": "මූලාශ්රය", + "Log in/register": "පුරන්න/ලියාපදිංචිවන්න", + "Log in with Google": "Google සමඟ පුරන්න", + "Password": "මුරපදය", + "Time (h:mm:ss):": "වේලාව (h:mm:ss):", + "Sign In": "පුරන්න", + "Preferences": "මනාපයන්", + "preferences_category_player": "වීඩියෝ ධාවක මනාපයන්", + "preferences_video_loop_label": "නැවත නැවතත්: ", + "preferences_autoplay_label": "ස්වයංක්රීය වාදනය: ", + "preferences_continue_label": "මීලඟට වාදනය කරන්න: ", + "preferences_continue_autoplay_label": "මීළඟ වීඩියෝව ස්වයංක්රීයව ධාවනය කරන්න: ", + "preferences_local_label": "Proxy වීඩියෝ: ", + "preferences_watch_history_label": "නැරඹුම් ඉතිහාසය සබල කරන්න: ", + "preferences_speed_label": "පෙරනිමි වේගය: ", + "preferences_quality_option_dash": "DASH (අනුවර්තිත ගුණත්වය)", + "preferences_quality_option_medium": "මධ්යස්ථ", + "preferences_quality_dash_label": "කැමති DASH වීඩියෝ ගුණත්වය: ", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_144p": "144p", + "preferences_volume_label": "ධාවකයේ හඬ: ", + "preferences_comments_label": "පෙරනිමි අදහස්: ", + "youtube": "YouTube", + "reddit": "Reddit", + "invidious": "Invidious", + "preferences_captions_label": "පෙරනිමි උපසිරැසි: ", + "preferences_related_videos_label": "අදාළ වීඩියෝ පෙන්වන්න: ", + "preferences_annotations_label": "අනුසටහන් පෙන්වන්න: ", + "preferences_vr_mode_label": "අන්තර්ක්රියාකාරී අංශක 360 වීඩියෝ (WebGL අවශ්යයි): ", + "preferences_region_label": "අන්තර්ගත රට: ", + "preferences_player_style_label": "වීඩියෝ ධාවක විලාසය: ", + "Dark mode: ": "අඳුරු මාදිලිය: ", + "preferences_dark_mode_label": "තේමාව: ", + "light": "ආලෝකමත්", + "generic_playlists_count": "{{count}} ධාවන ලැයිස්තුව", + "generic_playlists_count_plural": "ධාවන ලැයිස්තු {{count}}", + "LIVE": "සජීව", + "Subscribe": "දායක වන්න", + "View channel on YouTube": "YouTube හි නාලිකාව බලන්න", + "Next page": "ඊළඟ පිටුව", + "Previous page": "පෙර පිටුව", + "Clear watch history?": "නැරඹුම් ඉතිහාසය මකාදමනවා ද?", + "No": "නැත", + "Log in": "පුරන්න", + "New password": "නව මුරපදය", + "Import YouTube subscriptions": "YouTube/OPML දායකත්වයන් ආයාත කරන්න", + "Register": "ලියාපදිංචිවන්න", + "New passwords must match": "නව මුරපද ගැලපිය යුතුය", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML ලෙස දායකත්වයන් අපනයනය කරන්න (NewPipe සහ FreeTube සඳහා)", + "Export subscriptions as OPML": "දායකත්වයන් OPML ලෙස අපනයනය කරන්න", + "JavaScript license information": "JavaScript බලපත්ර තොරතුරු", + "User ID": "පරිශීලක කේතය", + "Text CAPTCHA": "CAPTCHA පෙල", + "Image CAPTCHA": "CAPTCHA රූපය", + "Google verification code": "Google සත්යාපන කේතය", + "E-mail": "විද්යුත් තැපෑල", + "preferences_quality_label": "කැමති වීඩියෝ ගුණත්වය: ", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_auto": "ස්වයංක්රීය", + "preferences_quality_option_small": "කුඩා", + "preferences_quality_dash_option_best": "උසස්", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_240p": "240p", + "preferences_extend_desc_label": "වීඩියෝ විස්තරය ස්වයංක්රීයව දිගහරින්න: ", + "preferences_category_visual": "දෘශ්ය මනාපයන්", + "dark": "අඳුරු", + "preferences_category_misc": "විවිධ මනාප", + "preferences_category_subscription": "දායකත්ව මනාප", + "Redirect homepage to feed: ": "මුල් පිටුව පෝෂණය වෙත හරවා යවන්න: ", + "preferences_max_results_label": "සංග්රහයේ පෙන්වන වීඩියෝ ගණන: ", + "preferences_sort_label": "වීඩියෝ වර්ග කරන්න: ", + "alphabetically": "අකාරාදී ලෙස", + "alphabetically - reverse": "අකාරාදී - ආපසු", + "channel name": "නාලිකාවේ නම", + "Only show latest video from channel: ": "නාලිකාවේ නවතම වීඩියෝව පමණක් පෙන්වන්න: ", + "preferences_unseen_only_label": "නොබැලූ පමණක් පෙන්වන්න: ", + "Enable web notifications": "වෙබ් දැනුම්දීම් සබල කරන්න", + "Import/export data": "දත්ත ආනයනය / අපනයනය", + "Change password": "මුරපදය වෙනස් කරන්න", + "Manage subscriptions": "දායකත්ව කළමනාකරණය", + "Manage tokens": "ටෝකන කළමනාකරණය", + "Watch history": "නැරඹුම් ඉතිහාසය", + "Save preferences": "මනාප සුරකින්න", + "Token": "ටෝකනය", + "View privacy policy.": "රහස්යතා ප්රතිපත්තිය බලන්න.", + "Only show latest unwatched video from channel: ": "නාලිකාවේ නවතම නැරඹන නොලද වීඩියෝව පමණක් පෙන්වන්න: ", + "preferences_category_data": "දත්ත මනාප", + "Clear watch history": "නැරඹුම් ඉතිහාසය මකාදැමීම", + "Subscriptions": "දායකත්ව" +} diff --git a/locales/sl.json b/locales/sl.json index 288f8da5..5994ca1a 100644 --- a/locales/sl.json +++ b/locales/sl.json @@ -503,5 +503,6 @@ "crash_page_before_reporting": "Preden prijaviš napako, se prepričaj, da si:", "crash_page_search_issue": "preiskal/a <a href=\"`x`\">obstoječe težave na GitHubu</a>", "crash_page_report_issue": "Če nič od navedenega ni pomagalo, prosim <a href=\"`x`\">odpri novo težavo v GitHubu</a> (po možnosti v angleščini) in v svoje sporočilo vključi naslednje besedilo (tega besedila NE prevajaj):", - "Popular enabled: ": "Priljubljeni omogočeni: " + "Popular enabled: ": "Priljubljeni omogočeni: ", + "error_video_not_in_playlist": "Zahtevani videoposnetek ne obstaja na tem seznamu predvajanja. <a href=\"`x`\">Klikni tukaj za domačo stran seznama predvajanja.</a>" } diff --git a/locales/tr.json b/locales/tr.json index bd499746..77aacb40 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -471,5 +471,6 @@ "search_filters_features_option_vr180": "VR180", "search_filters_title": "Filtreler", "search_message_change_filters_or_query": "Arama sorgunuzu genişletmeyi ve/veya filtreleri değiştirmeyi deneyin.", - "Popular enabled: ": "Popüler etkin: " + "Popular enabled: ": "Popüler etkin: ", + "error_video_not_in_playlist": "İstenen video bu oynatma listesinde yok. <a href=\"`x`\">Oynatma listesi ana sayfası için buraya tıklayın.</a>" } diff --git a/locales/uk.json b/locales/uk.json index 0cc14579..b6994c56 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -487,5 +487,6 @@ "search_filters_sort_option_relevance": "Відповідні", "search_filters_sort_option_rating": "Рейтингові", "search_filters_sort_option_views": "Популярні", - "Popular enabled: ": "Популярне ввімкнено: " + "Popular enabled: ": "Популярне ввімкнено: ", + "error_video_not_in_playlist": "Запитуваного відео в цьому списку відтворення не існує. <a href=\"`x`\">Клацніть тут, щоб переглянути домашню сторінку списку відтворення.</a>" } diff --git a/locales/vi.json b/locales/vi.json index 709013a2..07fcf52f 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -177,7 +177,7 @@ "Not a playlist.": "Không phải danh sách phát.", "Playlist does not exist.": "Danh sách phát không tồn tại.", "Could not pull trending pages.": "Không thể kéo các trang thịnh hành.", - "Hidden field \"challenge\" is a required field": "Trường ẩn \"challenge\" là trường bắt buộc", + "Hidden field \"challenge\" is a required field": "Trường ẩn \"challenge\" là trường bắt buộc", "Hidden field \"token\" is a required field": "Trường ẩn \"token\" là trường bắt buộc", "Erroneous challenge": "Thử thách sai", "Erroneous token": "Mã thông báo bị lỗi", @@ -341,5 +341,10 @@ "search_filters_features_option_location": "vị trí", "search_filters_features_option_hdr": "hdr", "Current version: ": "Phiên bản hiện tại: ", - "search_filters_title": "bộ lọc" + "search_filters_title": "bộ lọc", + "generic_playlists_count": "{{count}} danh sách phát", + "generic_views_count": "{{count}} lượt xem", + "View `x` comments": { + "": "Xem `x` bình luận" + } } diff --git a/locales/zh-CN.json b/locales/zh-CN.json index ff48e101..7e749dc9 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -455,5 +455,6 @@ "search_filters_duration_option_none": "任意时长", "search_filters_type_option_all": "任意类型", "search_filters_features_option_vr180": "VR180", - "Popular enabled: ": "已启用流行度: " + "Popular enabled: ": "已启用流行度: ", + "error_video_not_in_playlist": "此播放列表中不存在请求的视频。 <a href=\"`x`\">单击析出查看播放列表主页。</a>" } diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 90614e48..54933701 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -455,5 +455,6 @@ "search_filters_date_label": "上傳日期", "search_filters_type_option_all": "任何類型", "search_filters_date_option_none": "任何日期", - "Popular enabled: ": "已啟用人氣: " + "Popular enabled: ": "已啟用人氣: ", + "error_video_not_in_playlist": "此播放清單不存在請求的影片。<a href=\"`x`\">點擊此處檢視播放清單首頁。</a>" } diff --git a/mocks b/mocks -Subproject c401dd9203434b561022242c24b0c200d72284c +Subproject dfd53ea6ceb3cbcbbce6004f6ce60b330ad0f9b diff --git a/spec/invidious/helpers_spec.cr b/spec/invidious/helpers_spec.cr index 5ecebef3..ab361770 100644 --- a/spec/invidious/helpers_spec.cr +++ b/spec/invidious/helpers_spec.cr @@ -5,13 +5,13 @@ CONFIG = Config.from_yaml(File.open("config/config.example.yml")) Spectator.describe "Helper" do describe "#produce_channel_videos_url" do it "correctly produces url for requesting page `x` of a channel's videos" do - expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw")).to eq("/browse_ajax?continuation=4qmFsgI8EhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4&gl=US&hl=en") + # expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw")).to eq("/browse_ajax?continuation=4qmFsgI8EhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4&gl=US&hl=en") + # + # expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", sort_by: "popular")).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4R0FFPQ%3D%3D&gl=US&hl=en") - expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", sort_by: "popular")).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4R0FFPQ%3D%3D&gl=US&hl=en") + # expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", page: 20)).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUE9PQ%3D%3D&gl=US&hl=en") - expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", page: 20)).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUE9PQ%3D%3D&gl=US&hl=en") - - expect(produce_channel_videos_url(ucid: "UC-9-kyTW8ZkZNDHQJ6FgpwQ", page: 20, sort_by: "popular")).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQy05LWt5VFc4WmtaTkRIUUo2Rmdwd1EaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUJnQg%3D%3D&gl=US&hl=en") + # expect(produce_channel_videos_url(ucid: "UC-9-kyTW8ZkZNDHQJ6FgpwQ", page: 20, sort_by: "popular")).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQy05LWt5VFc4WmtaTkRIUUo2Rmdwd1EaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUJnQg%3D%3D&gl=US&hl=en") end end diff --git a/spec/invidious/videos/regular_videos_extract_spec.cr b/spec/invidious/videos/regular_videos_extract_spec.cr new file mode 100644 index 00000000..132b37a3 --- /dev/null +++ b/spec/invidious/videos/regular_videos_extract_spec.cr @@ -0,0 +1,168 @@ +require "../../parsers_helper.cr" + +Spectator.describe "parse_video_info" do + it "parses a regular video" do + # Enable mock + _player = load_mock("video/regular_mrbeast.player") + _next = load_mock("video/regular_mrbeast.next") + + raw_data = _player.merge!(_next) + info = parse_video_info("2isYuQZMbdU", raw_data) + + # Some basic verifications + expect(typeof(info)).to eq(Hash(String, JSON::Any)) + + expect(info["videoType"].as_s).to eq("Video") + + # Basic video infos + + expect(info["title"].as_s).to eq("I Gave My 100,000,000th Subscriber An Island") + expect(info["views"].as_i).to eq(32_846_329) + expect(info["likes"].as_i).to eq(2_611_650) + + # For some reason the video length from VideoDetails and the + # one from microformat differs by 1s... + expect(info["lengthSeconds"].as_i).to be_between(930_i64, 931_i64) + + expect(info["published"].as_s).to eq("2022-08-04T00:00:00Z") + + # Extra video infos + + expect(info["allowedRegions"].as_a).to_not be_empty + expect(info["allowedRegions"].as_a.size).to eq(249) + + expect(info["allowedRegions"].as_a).to contain( + "AD", "BA", "BB", "BW", "BY", "EG", "GG", "HN", "NP", "NR", "TR", + "TT", "TV", "TW", "TZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", + "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW" + ) + + expect(info["keywords"].as_a).to be_empty + + expect(info["allowRatings"].as_bool).to be_true + expect(info["isFamilyFriendly"].as_bool).to be_true + expect(info["isListed"].as_bool).to be_true + expect(info["isUpcoming"].as_bool).to be_false + + # Related videos + + expect(info["relatedVideos"].as_a.size).to eq(19) + + expect(info["relatedVideos"][0]["id"]).to eq("tVWWp1PqDus") + expect(info["relatedVideos"][0]["title"]).to eq("100 Girls Vs 100 Boys For $500,000") + expect(info["relatedVideos"][0]["author"]).to eq("MrBeast") + expect(info["relatedVideos"][0]["ucid"]).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA") + expect(info["relatedVideos"][0]["view_count"]).to eq("49702799") + expect(info["relatedVideos"][0]["short_view_count"]).to eq("49M") + expect(info["relatedVideos"][0]["author_verified"]).to eq("true") + + # Description + + description = "🚀Launch a store on Shopify, I’ll buy from 100 random stores that do ▸ " + + expect(info["description"].as_s).to start_with(description) + expect(info["shortDescription"].as_s).to start_with(description) + expect(info["descriptionHtml"].as_s).to start_with(description) + + # Video metadata + + expect(info["genre"].as_s).to eq("Entertainment") + expect(info["genreUcid"].as_s).to be_empty + expect(info["license"].as_s).to be_empty + + # Author infos + + expect(info["author"].as_s).to eq("MrBeast") + expect(info["ucid"].as_s).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA") + + expect(info["authorThumbnail"].as_s).to eq( + "https://yt3.ggpht.com/ytc/AMLnZu84dsnlYtuUFBMC8imQs0IUcTKA9khWAmUOgQZltw=s48-c-k-c0x00ffffff-no-rj" + ) + + expect(info["authorVerified"].as_bool).to be_true + expect(info["subCountText"].as_s).to eq("101M") + end + + it "parses a regular video with no descrition/comments" do + # Enable mock + _player = load_mock("video/regular_no-description.player") + _next = load_mock("video/regular_no-description.next") + + raw_data = _player.merge!(_next) + info = parse_video_info("iuevw6218F0", raw_data) + + # Some basic verifications + expect(typeof(info)).to eq(Hash(String, JSON::Any)) + + expect(info["videoType"].as_s).to eq("Video") + + # Basic video infos + + expect(info["title"].as_s).to eq("Chris Rea - Auberge") + expect(info["views"].as_i).to eq(10_356_197) + expect(info["likes"].as_i).to eq(0) + expect(info["lengthSeconds"].as_i).to eq(283_i64) + expect(info["published"].as_s).to eq("2012-05-21T00:00:00Z") + + # Extra video infos + + expect(info["allowedRegions"].as_a).to_not be_empty + expect(info["allowedRegions"].as_a.size).to eq(249) + + expect(info["allowedRegions"].as_a).to contain( + "AD", "BA", "BB", "BW", "BY", "EG", "GG", "HN", "NP", "NR", "TR", + "TT", "TV", "TW", "TZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", + "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW" + ) + + expect(info["keywords"].as_a).to_not be_empty + expect(info["keywords"].as_a.size).to eq(4) + + expect(info["keywords"].as_a).to contain_exactly( + "Chris", + "Rea", + "Auberge", + "1991" + ).in_any_order + + expect(info["allowRatings"].as_bool).to be_true + expect(info["isFamilyFriendly"].as_bool).to be_true + expect(info["isListed"].as_bool).to be_true + expect(info["isUpcoming"].as_bool).to be_false + + # Related videos + + expect(info["relatedVideos"].as_a.size).to eq(19) + + expect(info["relatedVideos"][0]["id"]).to eq("0bkrY_V0yZg") + expect(info["relatedVideos"][0]["title"]).to eq( + "Chris Rea Best Songs Collection - Chris Rea Greatest Hits Full Album 2022" + ) + expect(info["relatedVideos"][0]["author"]).to eq("Rock Ultimate") + expect(info["relatedVideos"][0]["ucid"]).to eq("UCekSc2A19di9koUIpj8gxlQ") + expect(info["relatedVideos"][0]["view_count"]).to eq("1992412") + expect(info["relatedVideos"][0]["short_view_count"]).to eq("1.9M") + expect(info["relatedVideos"][0]["author_verified"]).to eq("false") + + # Description + + expect(info["description"].as_s).to eq(" ") + expect(info["shortDescription"].as_s).to be_empty + expect(info["descriptionHtml"].as_s).to eq("<p></p>") + + # Video metadata + + expect(info["genre"].as_s).to eq("Music") + expect(info["genreUcid"].as_s).to be_empty + expect(info["license"].as_s).to be_empty + + # Author infos + + expect(info["author"].as_s).to eq("ChrisReaOfficial") + expect(info["ucid"].as_s).to eq("UC_5q6nWPbD30-y6oiWF_oNA") + + expect(info["authorThumbnail"].as_s).to be_empty + expect(info["authorVerified"].as_bool).to be_false + expect(info["subCountText"].as_s).to eq("-") + end +end diff --git a/spec/invidious/videos/scheduled_live_extract_spec.cr b/spec/invidious/videos/scheduled_live_extract_spec.cr index 6e531bbd..ff5aacd5 100644 --- a/spec/invidious/videos/scheduled_live_extract_spec.cr +++ b/spec/invidious/videos/scheduled_live_extract_spec.cr @@ -1,6 +1,6 @@ require "../../parsers_helper.cr" -Spectator.describe Invidious::Hashtag do +Spectator.describe "parse_video_info" do it "parses scheduled livestreams data (test 1)" do # Enable mock _player = load_mock("video/scheduled_live_nintendo.player") @@ -12,26 +12,50 @@ Spectator.describe Invidious::Hashtag do # Some basic verifications expect(typeof(info)).to eq(Hash(String, JSON::Any)) - expect(info["shortDescription"].as_s).to eq( - "Tune in on 6/22 at 7 a.m. PT for a livestreamed Xenoblade Chronicles 3 Direct presentation featuring roughly 20 minutes of information about the upcoming RPG adventure for Nintendo Switch." - ) - expect(info["descriptionHtml"].as_s).to eq( - "Tune in on 6/22 at 7 a.m. PT for a livestreamed Xenoblade Chronicles 3 Direct presentation featuring roughly 20 minutes of information about the upcoming RPG adventure for Nintendo Switch." - ) + expect(info["videoType"].as_s).to eq("Scheduled") + + # Basic video infos + expect(info["title"].as_s).to eq("Xenoblade Chronicles 3 Nintendo Direct") + expect(info["views"].as_i).to eq(160) expect(info["likes"].as_i).to eq(2_283) + expect(info["lengthSeconds"].as_i).to eq(0_i64) + expect(info["published"].as_s).to eq("2022-06-22T14:00:00Z") # Unix 1655906400 - expect(info["genre"].as_s).to eq("Gaming") - expect(info["genreUrl"].raw).to be_nil - expect(info["genreUcid"].as_s).to be_empty - expect(info["license"].as_s).to be_empty + # Extra video infos - expect(info["authorThumbnail"].as_s).to eq( - "https://yt3.ggpht.com/ytc/AKedOLTt4vtjREUUNdHlyu9c4gtJjG90M9jQheRlLKy44A=s48-c-k-c0x00ffffff-no-rj" + expect(info["allowedRegions"].as_a).to_not be_empty + expect(info["allowedRegions"].as_a.size).to eq(249) + + expect(info["allowedRegions"].as_a).to contain( + "AD", "BA", "BB", "BW", "BY", "EG", "GG", "HN", "NP", "NR", "TR", + "TT", "TV", "TW", "TZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", + "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW" ) - expect(info["authorVerified"].as_bool).to be_true - expect(info["subCountText"].as_s).to eq("8.5M") + expect(info["keywords"].as_a).to_not be_empty + expect(info["keywords"].as_a.size).to eq(11) + + expect(info["keywords"].as_a).to contain_exactly( + "nintendo", + "game", + "gameplay", + "fun", + "video game", + "action", + "adventure", + "rpg", + "play", + "switch", + "nintendo switch" + ).in_any_order + + expect(info["allowRatings"].as_bool).to be_true + expect(info["isFamilyFriendly"].as_bool).to be_true + expect(info["isListed"].as_bool).to be_true + expect(info["isUpcoming"].as_bool).to be_true + + # Related videos expect(info["relatedVideos"].as_a.size).to eq(20) @@ -50,6 +74,32 @@ Spectator.describe Invidious::Hashtag do expect(info["relatedVideos"][16]["view_count"].as_s).to eq("53510") expect(info["relatedVideos"][16]["short_view_count"].as_s).to eq("53K") expect(info["relatedVideos"][16]["author_verified"].as_s).to eq("true") + + # Description + + description = "Tune in on 6/22 at 7 a.m. PT for a livestreamed Xenoblade Chronicles 3 Direct presentation featuring roughly 20 minutes of information about the upcoming RPG adventure for Nintendo Switch." + + expect(info["description"].as_s).to eq(description) + expect(info["shortDescription"].as_s).to eq(description) + expect(info["descriptionHtml"].as_s).to eq(description) + + # Video metadata + + expect(info["genre"].as_s).to eq("Gaming") + expect(info["genreUcid"].as_s).to be_empty + expect(info["license"].as_s).to be_empty + + # Author infos + + expect(info["author"].as_s).to eq("Nintendo") + expect(info["ucid"].as_s).to eq("UCGIY_O-8vW4rfX98KlMkvRg") + + expect(info["authorThumbnail"].as_s).to eq( + "https://yt3.ggpht.com/ytc/AKedOLTt4vtjREUUNdHlyu9c4gtJjG90M9jQheRlLKy44A=s48-c-k-c0x00ffffff-no-rj" + ) + + expect(info["authorVerified"].as_bool).to be_true + expect(info["subCountText"].as_s).to eq("8.5M") end it "parses scheduled livestreams data (test 2)" do @@ -63,34 +113,63 @@ Spectator.describe Invidious::Hashtag do # Some basic verifications expect(typeof(info)).to eq(Hash(String, JSON::Any)) - expect(info["shortDescription"].as_s).to start_with( - <<-TXT - PBD Podcast Episode 171. In this episode, Patrick Bet-David is joined by Dr. Patrick Moore and Adam Sosnick. - - Join the channel to get exclusive access to perks: https://bit.ly/3Q9rSQL - TXT - ) - expect(info["descriptionHtml"].as_s).to start_with( - <<-TXT - PBD Podcast Episode 171. In this episode, Patrick Bet-David is joined by Dr. Patrick Moore and Adam Sosnick. + expect(info["videoType"].as_s).to eq("Scheduled") - Join the channel to get exclusive access to perks: <a href="https://bit.ly/3Q9rSQL">bit.ly/3Q9rSQL</a> - TXT - ) + # Basic video infos + expect(info["title"].as_s).to eq("The Truth About Greenpeace w/ Dr. Patrick Moore | PBD Podcast | Ep. 171") + expect(info["views"].as_i).to eq(24) expect(info["likes"].as_i).to eq(22) + expect(info["lengthSeconds"].as_i).to eq(0_i64) + expect(info["published"].as_s).to eq("2022-07-14T13:00:00Z") # Unix 1657803600 - expect(info["genre"].as_s).to eq("Entertainment") - expect(info["genreUrl"].raw).to be_nil - expect(info["genreUcid"].as_s).to be_empty - expect(info["license"].as_s).to be_empty + # Extra video infos - expect(info["authorThumbnail"].as_s).to eq( - "https://yt3.ggpht.com/61ArDiQshJrvSXcGLhpFfIO3hlMabe2fksitcf6oGob0Mdr5gztdkXxRljICUodL4iuTSrtxW4A=s48-c-k-c0x00ffffff-no-rj" + expect(info["allowedRegions"].as_a).to_not be_empty + expect(info["allowedRegions"].as_a.size).to eq(249) + + expect(info["allowedRegions"].as_a).to contain( + "AD", "AR", "BA", "BT", "CZ", "FO", "GL", "IO", "KE", "KH", "LS", + "LT", "MP", "NO", "PR", "RO", "SE", "SK", "SS", "SX", "SZ", "ZW" ) - expect(info["authorVerified"].as_bool).to be_false - expect(info["subCountText"].as_s).to eq("227K") + expect(info["keywords"].as_a).to_not be_empty + expect(info["keywords"].as_a.size).to eq(25) + + expect(info["keywords"].as_a).to contain_exactly( + "Patrick Bet-David", + "Valeutainment", + "The BetDavid Podcast", + "The BetDavid Show", + "Betdavid", + "PBD", + "BetDavid show", + "Betdavid podcast", + "podcast betdavid", + "podcast patrick", + "patrick bet david podcast", + "Valuetainment podcast", + "Entrepreneurs", + "Entrepreneurship", + "Entrepreneur Motivation", + "Entrepreneur Advice", + "Startup Entrepreneurs", + "valuetainment", + "patrick bet david", + "PBD podcast", + "Betdavid show", + "Betdavid Podcast", + "Podcast Betdavid", + "Show Betdavid", + "PBDPodcast" + ).in_any_order + + expect(info["allowRatings"].as_bool).to be_true + expect(info["isFamilyFriendly"].as_bool).to be_true + expect(info["isListed"].as_bool).to be_true + expect(info["isUpcoming"].as_bool).to be_true + + # Related videos expect(info["relatedVideos"].as_a.size).to eq(20) @@ -109,5 +188,41 @@ Spectator.describe Invidious::Hashtag do expect(info["relatedVideos"][9]["view_count"]).to eq("26432") expect(info["relatedVideos"][9]["short_view_count"]).to eq("26K") expect(info["relatedVideos"][9]["author_verified"]).to eq("true") + + # Description + + description_start_text = <<-TXT + PBD Podcast Episode 171. In this episode, Patrick Bet-David is joined by Dr. Patrick Moore and Adam Sosnick. + + Join the channel to get exclusive access to perks: https://bit.ly/3Q9rSQL + TXT + + expect(info["description"].as_s).to start_with(description_start_text) + expect(info["shortDescription"].as_s).to start_with(description_start_text) + + expect(info["descriptionHtml"].as_s).to start_with( + <<-TXT + PBD Podcast Episode 171. In this episode, Patrick Bet-David is joined by Dr. Patrick Moore and Adam Sosnick. + + Join the channel to get exclusive access to perks: <a href="https://bit.ly/3Q9rSQL">bit.ly/3Q9rSQL</a> + TXT + ) + + # Video metadata + + expect(info["genre"].as_s).to eq("Entertainment") + expect(info["genreUcid"].as_s).to be_empty + expect(info["license"].as_s).to be_empty + + # Author infos + + expect(info["author"].as_s).to eq("PBD Podcast") + expect(info["ucid"].as_s).to eq("UCGX7nGXpz-CmO_Arg-cgJ7A") + + expect(info["authorThumbnail"].as_s).to eq( + "https://yt3.ggpht.com/61ArDiQshJrvSXcGLhpFfIO3hlMabe2fksitcf6oGob0Mdr5gztdkXxRljICUodL4iuTSrtxW4A=s48-c-k-c0x00ffffff-no-rj" + ) + expect(info["authorVerified"].as_bool).to be_false + expect(info["subCountText"].as_s).to eq("227K") end end diff --git a/spec/parsers_helper.cr b/spec/parsers_helper.cr index e9154875..bf05f9ec 100644 --- a/spec/parsers_helper.cr +++ b/spec/parsers_helper.cr @@ -12,6 +12,7 @@ require "../src/invidious/helpers/logger" require "../src/invidious/helpers/utils" require "../src/invidious/videos" +require "../src/invidious/videos/*" require "../src/invidious/comments" require "../src/invidious/helpers/serialized_yt_data" diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 6c492e2f..f8bfa718 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -5,6 +5,7 @@ require "protodec/utils" require "yaml" require "../src/invidious/helpers/*" require "../src/invidious/channels/*" +require "../src/invidious/videos/caption" require "../src/invidious/videos" require "../src/invidious/comments" require "../src/invidious/playlists" diff --git a/src/ext/kemal_static_file_handler.cr b/src/ext/kemal_static_file_handler.cr index 6ef2d74c..eb068aeb 100644 --- a/src/ext/kemal_static_file_handler.cr +++ b/src/ext/kemal_static_file_handler.cr @@ -111,7 +111,7 @@ module Kemal if @fallthrough call_next(context) else - context.response.status_code = 405 + context.response.status = HTTP::Status::METHOD_NOT_ALLOWED context.response.headers.add("Allow", "GET, HEAD") end return @@ -124,7 +124,7 @@ module Kemal # File path cannot contains '\0' (NUL) because all filesystem I know # don't accept '\0' character as file name. if request_path.includes? '\0' - context.response.status_code = 400 + context.response.status = HTTP::Status::BAD_REQUEST return end @@ -143,13 +143,15 @@ module Kemal add_cache_headers(context.response.headers, last_modified) if cache_request?(context, last_modified) - context.response.status_code = 304 + context.response.status = HTTP::Status::NOT_MODIFIED return end send_file(context, file_path, file[:data], file[:filestat]) else - is_dir = Dir.exists? file_path + file_info = File.info?(file_path) + is_dir = file_info.try &.directory? || false + is_file = file_info.try &.file? || false if request_path != expanded_path redirect_to context, expanded_path @@ -157,19 +159,21 @@ module Kemal redirect_to context, expanded_path + '/' end - if Dir.exists?(file_path) + return call_next(context) if file_info.nil? + + if is_dir if config.is_a?(Hash) && config["dir_listing"] == true context.response.content_type = "text/html" directory_listing(context.response, request_path, file_path) else call_next(context) end - elsif File.exists?(file_path) - last_modified = modification_time(file_path) + elsif is_file + last_modified = file_info.modification_time add_cache_headers(context.response.headers, last_modified) if cache_request?(context, last_modified) - context.response.status_code = 304 + context.response.status = HTTP::Status::NOT_MODIFIED return end @@ -177,14 +181,12 @@ module Kemal data = Bytes.new(size) File.open(file_path, &.read(data)) - filestat = File.info(file_path) - - @cached_files[file_path] = {data: data, filestat: filestat} - send_file(context, file_path, data, filestat) + @cached_files[file_path] = {data: data, filestat: file_info} + send_file(context, file_path, data, file_info) else send_file(context, file_path) end - else + else # Not a normal file (FIFO/device/socket) call_next(context) end end diff --git a/src/invidious.cr b/src/invidious.cr index 070b4d18..2874cc71 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -37,6 +37,9 @@ require "./invidious/database/migrations/*" require "./invidious/helpers/*" require "./invidious/yt_backend/*" require "./invidious/frontend/*" +require "./invidious/videos/*" + +require "./invidious/jsonify/**" require "./invidious/*" require "./invidious/channels/*" @@ -172,311 +175,27 @@ end CONNECTION_CHANNEL = Channel({Bool, Channel(PQ::Notification)}).new(32) Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL, CONFIG.database_url) +Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new + Invidious::Jobs.start_all def popular_videos Invidious::Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get end -before_all do |env| - preferences = Preferences.from_json("{}") - - begin - if prefs_cookie = env.request.cookies["PREFS"]? - preferences = Preferences.from_json(URI.decode_www_form(prefs_cookie.value)) - else - if language_header = env.request.headers["Accept-Language"]? - if language = ANG.language_negotiator.best(language_header, LOCALES.keys) - preferences.locale = language.header - end - end - end - rescue - preferences = Preferences.from_json("{}") - end - - env.set "preferences", preferences - env.response.headers["X-XSS-Protection"] = "1; mode=block" - env.response.headers["X-Content-Type-Options"] = "nosniff" - - # Allow media resources to be loaded from google servers - # TODO: check if *.youtube.com can be removed - if CONFIG.disabled?("local") || !preferences.local - extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443" - else - extra_media_csp = "" - end - - # Only allow the pages at /embed/* to be embedded - if env.request.resource.starts_with?("/embed") - frame_ancestors = "'self' http: https:" - else - frame_ancestors = "'none'" - end - - # TODO: Remove style-src's 'unsafe-inline', requires to remove all - # inline styles (<style> [..] </style>, style=" [..] ") - env.response.headers["Content-Security-Policy"] = { - "default-src 'none'", - "script-src 'self'", - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data:", - "font-src 'self' data:", - "connect-src 'self'", - "manifest-src 'self'", - "media-src 'self' blob:" + extra_media_csp, - "child-src 'self' blob:", - "frame-src 'self'", - "frame-ancestors " + frame_ancestors, - }.join("; ") - - env.response.headers["Referrer-Policy"] = "same-origin" - - # Ask the chrom*-based browsers to disable FLoC - # See: https://blog.runcloud.io/google-floc/ - env.response.headers["Permissions-Policy"] = "interest-cohort=()" - - if (Kemal.config.ssl || CONFIG.https_only) && CONFIG.hsts - env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload" - end - - next if { - "/sb/", - "/vi/", - "/s_p/", - "/yts/", - "/ggpht/", - "/api/manifest/", - "/videoplayback", - "/latest_version", - "/download", - }.any? { |r| env.request.resource.starts_with? r } - - if env.request.cookies.has_key? "SID" - sid = env.request.cookies["SID"].value - - if sid.starts_with? "v1:" - raise "Cannot use token as SID" - end - - # Invidious users only have SID - if !env.request.cookies.has_key? "SSID" - if email = Invidious::Database::SessionIDs.select_email(sid) - user = Invidious::Database::Users.select!(email: email) - csrf_token = generate_response(sid, { - ":authorize_token", - ":playlist_ajax", - ":signout", - ":subscription_ajax", - ":token_ajax", - ":watch_ajax", - }, HMAC_KEY, 1.week) - - preferences = user.preferences - env.set "preferences", preferences - - env.set "sid", sid - env.set "csrf_token", csrf_token - env.set "user", user - end - else - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - begin - user, sid = get_user(sid, headers, false) - csrf_token = generate_response(sid, { - ":authorize_token", - ":playlist_ajax", - ":signout", - ":subscription_ajax", - ":token_ajax", - ":watch_ajax", - }, HMAC_KEY, 1.week) - - preferences = user.preferences - env.set "preferences", preferences - - env.set "sid", sid - env.set "csrf_token", csrf_token - env.set "user", user - rescue ex - end - end - end - - dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s - thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode.to_s - thin_mode = thin_mode == "true" - locale = env.params.query["hl"]? || preferences.locale - - preferences.dark_mode = dark_mode - preferences.thin_mode = thin_mode - preferences.locale = locale - env.set "preferences", preferences - - current_page = env.request.path - if env.request.query - query = HTTP::Params.parse(env.request.query.not_nil!) - - if query["referer"]? - query["referer"] = get_referer(env, "/") - end - - current_page += "?#{query}" - end +# Routing - env.set "current_page", URI.encode_www_form(current_page) +before_all do |env| + Invidious::Routes::BeforeAll.handle(env) end -{% unless flag?(:api_only) %} - Invidious::Routing.get "/", Invidious::Routes::Misc, :home - Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy - Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses - - Invidious::Routing.get "/channel/:ucid", Invidious::Routes::Channels, :home - Invidious::Routing.get "/channel/:ucid/home", Invidious::Routes::Channels, :home - Invidious::Routing.get "/channel/:ucid/videos", Invidious::Routes::Channels, :videos - Invidious::Routing.get "/channel/:ucid/playlists", Invidious::Routes::Channels, :playlists - Invidious::Routing.get "/channel/:ucid/community", Invidious::Routes::Channels, :community - Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about - Invidious::Routing.get "/channel/:ucid/live", Invidious::Routes::Channels, :live - Invidious::Routing.get "/user/:user/live", Invidious::Routes::Channels, :live - Invidious::Routing.get "/c/:user/live", Invidious::Routes::Channels, :live - - ["", "/videos", "/playlists", "/community", "/about"].each do |path| - # /c/LinusTechTips - Invidious::Routing.get "/c/:user#{path}", Invidious::Routes::Channels, :brand_redirect - # /user/linustechtips | Not always the same as /c/ - Invidious::Routing.get "/user/:user#{path}", Invidious::Routes::Channels, :brand_redirect - # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow - Invidious::Routing.get "/attribution_link#{path}", Invidious::Routes::Channels, :brand_redirect - # /profile?user=linustechtips - Invidious::Routing.get "/profile/#{path}", Invidious::Routes::Channels, :profile - end - - Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle - Invidious::Routing.post "/watch_ajax", Invidious::Routes::Watch, :mark_watched - Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect - Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect - Invidious::Routing.get "/clip/:clip", Invidious::Routes::Watch, :clip - Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect - Invidious::Routing.get "/v/:id", Invidious::Routes::Watch, :redirect - Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect - Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_redirect - - Invidious::Routing.post "/download", Invidious::Routes::Watch, :download - - Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect - Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show - - Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new - Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create - Invidious::Routing.get "/subscribe_playlist", Invidious::Routes::Playlists, :subscribe - Invidious::Routing.get "/delete_playlist", Invidious::Routes::Playlists, :delete_page - Invidious::Routing.post "/delete_playlist", Invidious::Routes::Playlists, :delete - Invidious::Routing.get "/edit_playlist", Invidious::Routes::Playlists, :edit - Invidious::Routing.post "/edit_playlist", Invidious::Routes::Playlists, :update - Invidious::Routing.get "/add_playlist_items", Invidious::Routes::Playlists, :add_playlist_items_page - Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax - Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show - Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix - Invidious::Routing.get "/watch_videos", Invidious::Routes::Playlists, :watch_videos - - Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch - Invidious::Routing.get "/results", Invidious::Routes::Search, :results - Invidious::Routing.get "/search", Invidious::Routes::Search, :search - Invidious::Routing.get "/hashtag/:hashtag", Invidious::Routes::Search, :hashtag - - # User routes - define_user_routes() - - # Feeds - Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect - Invidious::Routing.get "/feed/playlists", Invidious::Routes::Feeds, :playlists - Invidious::Routing.get "/feed/popular", Invidious::Routes::Feeds, :popular - Invidious::Routing.get "/feed/trending", Invidious::Routes::Feeds, :trending - Invidious::Routing.get "/feed/subscriptions", Invidious::Routes::Feeds, :subscriptions - Invidious::Routing.get "/feed/history", Invidious::Routes::Feeds, :history - - # RSS Feeds - Invidious::Routing.get "/feed/channel/:ucid", Invidious::Routes::Feeds, :rss_channel - Invidious::Routing.get "/feed/private", Invidious::Routes::Feeds, :rss_private - Invidious::Routing.get "/feed/playlist/:plid", Invidious::Routes::Feeds, :rss_playlist - Invidious::Routing.get "/feeds/videos.xml", Invidious::Routes::Feeds, :rss_videos - - # Support push notifications via PubSubHubbub - Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get - Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post - - Invidious::Routing.get "/modify_notifications", Invidious::Routes::Notifications, :modify - - Invidious::Routing.post "/subscription_ajax", Invidious::Routes::Subscriptions, :toggle_subscription - Invidious::Routing.get "/subscription_manager", Invidious::Routes::Subscriptions, :subscription_manager -{% end %} - -Invidious::Routing.get "/ggpht/*", Invidious::Routes::Images, :ggpht -Invidious::Routing.options "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :options_storyboard -Invidious::Routing.get "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :get_storyboard -Invidious::Routing.get "/s_p/:id/:name", Invidious::Routes::Images, :s_p_image -Invidious::Routing.get "/yts/img/:name", Invidious::Routes::Images, :yts_image -Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails - -# API routes (macro) -define_v1_api_routes() - -# Video playback (macros) -define_api_manifest_routes() -define_video_playback_routes() +Invidious::Routing.register_all error 404 do |env| - if md = env.request.path.match(/^\/(?<id>([a-zA-Z0-9_-]{11})|(\w+))$/) - item = md["id"] - - # Check if item is branding URL e.g. https://youtube.com/gaming - response = YT_POOL.client &.get("/#{item}") - - if response.status_code == 301 - response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target) - end - - if response.body.empty? - env.response.headers["Location"] = "/" - halt env, status_code: 302 - end - - html = XML.parse_html(response.body) - ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] - - if ucid - env.response.headers["Location"] = "/channel/#{ucid}" - halt env, status_code: 302 - end - - params = [] of String - env.params.query.each do |k, v| - params << "#{k}=#{v}" - end - params = params.join("&") - - url = "/watch?v=#{item}" - if !params.empty? - url += "&#{params}" - end - - # Check if item is video ID - if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404 - env.response.headers["Location"] = url - halt env, status_code: 302 - end - end - - env.response.headers["Location"] = "/" - halt env, status_code: 302 + Invidious::Routes::ErrorRoutes.error_404(env) end error 500 do |env, ex| - locale = env.get("preferences").as(Preferences).locale error_template(500, ex) end @@ -484,6 +203,8 @@ static_headers do |response| response.headers.add("Cache-Control", "max-age=2629800") end +# Init Kemal + public_folder "assets" Kemal.config.powered_by_header = false diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index f60ee7af..4c442959 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -130,8 +130,9 @@ def get_about_info(ucid, locale) : AboutChannel tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map(&.["tabRenderer"]["title"].as_s.downcase) end - sub_count = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s? - .try { |text| short_text_to_number(text.split(" ")[0]) } || 0 + sub_count = initdata + .dig?("header", "c4TabbedHeaderRenderer", "subscriberCountText", "simpleText").try &.as_s? + .try { |text| short_text_to_number(text.split(" ")[0]).to_i32 } || 0 AboutChannel.new( ucid: ucid, diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index e0459cc3..e3d3d9ee 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -29,7 +29,7 @@ struct ChannelVideo json.field "title", self.title json.field "videoId", self.id json.field "videoThumbnails" do - generate_thumbnails(json, self.id) + Invidious::JSONify::APIv1.thumbnails(json, self.id) end json.field "lengthSeconds", self.length_seconds diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 2a2c74aa..8e300288 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -138,7 +138,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) json.field "title", video_title json.field "videoId", video_id json.field "videoThumbnails" do - generate_thumbnails(json, video_id) + Invidious::JSONify::APIv1.thumbnails(json, video_id) end json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s) diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index 48453bb7..b495e597 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -1,53 +1,48 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:base64" => { - "2:string" => "videos", - "6:varint" => 2_i64, - "7:varint" => 1_i64, - "12:varint" => 1_i64, - "13:string" => "", - "23:varint" => 0_i64, - }, + object_inner_2 = { + "2:0:embedded" => { + "1:0:varint" => 0_i64, }, + "5:varint" => 50_i64, + "6:varint" => 1_i64, + "7:varint" => (page * 30).to_i64, + "9:varint" => 1_i64, + "10:varint" => 0_i64, } - if !v2 - if auto_generated - seed = Time.unix(1525757349) - until seed >= Time.utc - seed += 1.month - end - timestamp = seed - (page - 1).months - - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x36_i64 - object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{timestamp.to_unix}" - else - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64 - object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{page}" - end - else - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64 + object_inner_2_encoded = object_inner_2 + .try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } - object["80226972:embedded"]["3:base64"].as(Hash)["61:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({ - "1:string" => Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({ - "1:varint" => 30_i64 * (page - 1), - }))), - }))) - end + object_inner_1 = { + "110:embedded" => { + "3:embedded" => { + "15:embedded" => { + "1:embedded" => { + "1:string" => object_inner_2_encoded, + "2:string" => "00000000-0000-0000-0000-000000000000", + }, + "3:varint" => 1_i64, + }, + }, + }, + } - case sort_by - when "newest" - when "popular" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x01_i64 - when "oldest" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x02_i64 - else nil # Ignore - end + object_inner_1_encoded = object_inner_1 + .try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } - object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"]))) - object["80226972:embedded"].delete("3:base64") + object = { + "80226972:embedded" => { + "2:string" => ucid, + "3:string" => object_inner_1_encoded, + "35:string" => "browse-feed#{ucid}videos102", + }, + } continuation = object.try { |i| Protodec::Any.cast_json(i) } .try { |i| Protodec::Any.from_json(i) } @@ -67,10 +62,11 @@ end def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest") videos = [] of SearchVideo - 2.times do |i| - initial_data = get_channel_videos_response(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by) - videos.concat extract_videos(initial_data, author, ucid) - end + # 2.times do |i| + # initial_data = get_channel_videos_response(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by) + initial_data = get_channel_videos_response(ucid, 1, auto_generated: auto_generated, sort_by: sort_by) + videos = extract_videos(initial_data, author, ucid) + # end return videos.size, videos end diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 5112ad3d..d691ca36 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -201,15 +201,6 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b end if node_replies && !response["commentRepliesContinuation"]? - if node_replies["moreText"]? - reply_count = (node_replies["moreText"]["simpleText"]? || node_replies["moreText"]["runs"]?.try &.[0]?.try &.["text"]?) - .try &.as_s.gsub(/\D/, "").to_i? || 1 - elsif node_replies["viewReplies"]? - reply_count = node_replies["viewReplies"]["buttonRenderer"]["text"]?.try &.["runs"][1]?.try &.["text"]?.try &.as_s.to_i? || 1 - else - reply_count = 1 - end - if node_replies["continuations"]? continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s elsif node_replies["contents"]? @@ -219,7 +210,7 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b json.field "replies" do json.object do - json.field "replyCount", reply_count + json.field "replyCount", node_comment["replyCount"]? || 1 json.field "continuation", continuation end end diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 786b65df..c9bf43a4 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -78,6 +78,10 @@ class Config property decrypt_polling : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel property full_refresh : Bool = false + + # Jobs config structure. See jobs.cr and jobs/base_job.cr + property jobs = Invidious::Jobs::JobsConfig.new + # Used to tell Invidious it is behind a proxy, so links to resources should be https:// property https_only : Bool? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions @@ -131,6 +135,9 @@ class Config # API URL for Anti-Captcha property captcha_api_url : String = "https://api.anti-captcha.com" + # Playlist length limit + property playlist_length_limit : Int32 = 500 + def disabled?(option) case disabled = CONFIG.disable_proxy when Bool diff --git a/src/invidious/database/nonces.cr b/src/invidious/database/nonces.cr index 469fcbd8..b87c81ec 100644 --- a/src/invidious/database/nonces.cr +++ b/src/invidious/database/nonces.cr @@ -4,7 +4,7 @@ module Invidious::Database::Nonces extend self # ------------------- - # Insert + # Insert / Delete # ------------------- def insert(nonce : String, expire : Time) @@ -17,6 +17,15 @@ module Invidious::Database::Nonces PG_DB.exec(request, nonce, expire) end + def delete_expired + request = <<-SQL + DELETE FROM nonces * + WHERE expire < now() + SQL + + PG_DB.exec(request) + end + # ------------------- # Update # ------------------- diff --git a/src/invidious/database/videos.cr b/src/invidious/database/videos.cr index e1fa01c3..695f5b33 100644 --- a/src/invidious/database/videos.cr +++ b/src/invidious/database/videos.cr @@ -22,6 +22,15 @@ module Invidious::Database::Videos PG_DB.exec(request, id) end + def delete_expired + request = <<-SQL + DELETE FROM videos * + WHERE updated < (now() - interval '6 hours') + SQL + + PG_DB.exec(request) + end + def update(video : Video) request = <<-SQL UPDATE videos diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr index 05be73a6..425c08da 100644 --- a/src/invidious/exceptions.cr +++ b/src/invidious/exceptions.cr @@ -30,3 +30,6 @@ end # Exception threw when an element is not found. class NotFoundException < InfoException end + +class VideoNotAvailableException < Exception +end diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr index 80b67641..a9b00860 100644 --- a/src/invidious/frontend/watch_page.cr +++ b/src/invidious/frontend/watch_page.cr @@ -7,7 +7,7 @@ module Invidious::Frontend::WatchPage getter full_videos : Array(Hash(String, JSON::Any)) getter video_streams : Array(Hash(String, JSON::Any)) getter audio_streams : Array(Hash(String, JSON::Any)) - getter captions : Array(Caption) + getter captions : Array(Invidious::Videos::Caption) def initialize( @full_videos, @@ -50,7 +50,7 @@ module Invidious::Frontend::WatchPage video_assets.full_videos.each do |option| mimetype = option["mimeType"].as_s.split(";")[0] - height = itag_to_metadata?(option["itag"]).try &.["height"]? + height = Invidious::Videos::Formats.itag_to_metadata?(option["itag"]).try &.["height"]? value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index fd86594c..a9ed1f64 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -1,8 +1,7 @@ -# "bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh) [Incomplete] -# "eu" => load_locale("eu"), # Basque [Incomplete] -# "sk" => load_locale("sk"), # Slovak [Incomplete] LOCALES_LIST = { "ar" => "العربية", # Arabic + "bn" => "বাংলা", # Bengali + "ca" => "Català", # Catalan "cs" => "Čeština", # Czech "da" => "Dansk", # Danish "de" => "Deutsch", # German @@ -11,6 +10,7 @@ LOCALES_LIST = { "eo" => "Esperanto", # Esperanto "es" => "Español", # Spanish "et" => "Eesti keel", # Estonian + "eu" => "Euskara", # Basque "fa" => "فارسی", # Persian "fi" => "Suomi", # Finnish "fr" => "Français", # French @@ -32,6 +32,8 @@ LOCALES_LIST = { "pt-PT" => "Português de Portugal", # Portuguese (Portugal) "ro" => "Română", # Romanian "ru" => "Русский", # Russian + "si" => "සිංහල", # Sinhala + "sk" => "Slovenčina", # Slovak "sl" => "Slovenščina", # Slovenian "sq" => "Shqip", # Albanian "sr" => "Srpski (latinica)", # Serbian (Latin) diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 3918bd13..c52e2a0d 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -76,7 +76,7 @@ struct SearchVideo json.field "authorUrl", "/channel/#{self.ucid}" json.field "videoThumbnails" do - generate_thumbnails(json, self.id) + Invidious::JSONify::APIv1.thumbnails(json, self.id) end json.field "description", html_to_content(self.description_html) @@ -155,7 +155,7 @@ struct SearchPlaylist json.field "lengthSeconds", video.length_seconds json.field "videoThumbnails" do - generate_thumbnails(json, video.id) + Invidious::JSONify::APIv1.thumbnails(json, video.id) end end end diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 8ae5034a..ed0cca38 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -161,21 +161,19 @@ def number_with_separator(number) number.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse end -def short_text_to_number(short_text : String) : Int32 - case short_text - when .ends_with? "M" - number = short_text.rstrip(" mM").to_f - number *= 1000000 - when .ends_with? "K" - number = short_text.rstrip(" kK").to_f - number *= 1000 - else - number = short_text.rstrip(" ") +def short_text_to_number(short_text : String) : Int64 + matches = /(?<number>\d+(\.\d+)?)\s?(?<suffix>[mMkKbB])?/.match(short_text) + number = matches.try &.["number"].to_f || 0.0 + + case matches.try &.["suffix"].downcase + when "k" then number *= 1_000 + when "m" then number *= 1_000_000 + when "b" then number *= 1_000_000_000 end - number = number.to_i - - return number + return number.to_i64 +rescue ex + return 0_i64 end def number_to_short_text(number) diff --git a/src/invidious/jobs.cr b/src/invidious/jobs.cr index ec0cad64..524a3624 100644 --- a/src/invidious/jobs.cr +++ b/src/invidious/jobs.cr @@ -1,12 +1,39 @@ module Invidious::Jobs JOBS = [] of BaseJob + # Automatically generate a structure that wraps the various + # jobs' configs, so that the follwing YAML config can be used: + # + # jobs: + # job_name: + # enabled: true + # some_property: "value" + # + macro finished + struct JobsConfig + include YAML::Serializable + + {% for sc in BaseJob.subclasses %} + # Voodoo macro to transform `Some::Module::CustomJob` to `custom` + {% class_name = sc.id.split("::").last.id.gsub(/Job$/, "").underscore %} + + getter {{ class_name }} = {{ sc.name }}::Config.new + {% end %} + + def initialize + end + end + end + def self.register(job : BaseJob) JOBS << job end def self.start_all JOBS.each do |job| + # Don't run the main rountine if the job is disabled by config + next if job.disabled? + spawn { job.begin } end end diff --git a/src/invidious/jobs/base_job.cr b/src/invidious/jobs/base_job.cr index 47e75864..f90f0bfe 100644 --- a/src/invidious/jobs/base_job.cr +++ b/src/invidious/jobs/base_job.cr @@ -1,3 +1,33 @@ abstract class Invidious::Jobs::BaseJob abstract def begin + + # When this base job class is inherited, make sure to define + # a basic "Config" structure, that contains the "enable" property, + # and to create the associated instance property. + # + macro inherited + macro finished + # This config structure can be expanded as required. + struct Config + include YAML::Serializable + + property enable = true + + def initialize + end + end + + property cfg = Config.new + + # Return true if job is enabled by config + protected def enabled? : Bool + return (@cfg.enable == true) + end + + # Return true if job is disabled by config + protected def disabled? : Bool + return (@cfg.enable == false) + end + end + end end diff --git a/src/invidious/jobs/clear_expired_items_job.cr b/src/invidious/jobs/clear_expired_items_job.cr new file mode 100644 index 00000000..17191aac --- /dev/null +++ b/src/invidious/jobs/clear_expired_items_job.cr @@ -0,0 +1,27 @@ +class Invidious::Jobs::ClearExpiredItemsJob < Invidious::Jobs::BaseJob + # Remove items (videos, nonces, etc..) whose cache is outdated every hour. + # Removes the need for a cron job. + def begin + loop do + failed = false + + LOGGER.info("jobs: running ClearExpiredItems job") + + begin + Invidious::Database::Videos.delete_expired + Invidious::Database::Nonces.delete_expired + rescue DB::Error + failed = true + end + + # Retry earlier than scheduled on DB error + if failed + LOGGER.info("jobs: ClearExpiredItems failed. Retrying in 10 minutes.") + sleep 10.minutes + else + LOGGER.info("jobs: ClearExpiredItems done.") + sleep 1.hour + end + end + end +end diff --git a/src/invidious/jsonify/api_v1/common.cr b/src/invidious/jsonify/api_v1/common.cr new file mode 100644 index 00000000..64b06465 --- /dev/null +++ b/src/invidious/jsonify/api_v1/common.cr @@ -0,0 +1,18 @@ +require "json" + +module Invidious::JSONify::APIv1 + extend self + + def thumbnails(json : JSON::Builder, id : String) + json.array do + build_thumbnails(id).each do |thumbnail| + json.object do + json.field "quality", thumbnail[:name] + json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg" + json.field "width", thumbnail[:width] + json.field "height", thumbnail[:height] + end + end + end + end +end diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr new file mode 100644 index 00000000..642789aa --- /dev/null +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -0,0 +1,251 @@ +require "json" + +module Invidious::JSONify::APIv1 + extend self + + def video(video : Video, json : JSON::Builder, *, locale : String?) + json.object do + json.field "type", video.video_type + + json.field "title", video.title + json.field "videoId", video.id + + json.field "error", video.info["reason"] if video.info["reason"]? + + json.field "videoThumbnails" do + self.thumbnails(json, video.id) + end + json.field "storyboards" do + self.storyboards(json, video.id, video.storyboards) + end + + json.field "description", video.description + json.field "descriptionHtml", video.description_html + json.field "published", video.published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale)) + json.field "keywords", video.keywords + + json.field "viewCount", video.views + json.field "likeCount", video.likes + json.field "dislikeCount", 0_i64 + + json.field "paid", video.paid + json.field "premium", video.premium + json.field "isFamilyFriendly", video.is_family_friendly + json.field "allowedRegions", video.allowed_regions + json.field "genre", video.genre + json.field "genreUrl", video.genre_url + + json.field "author", video.author + json.field "authorId", video.ucid + json.field "authorUrl", "/channel/#{video.ucid}" + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", video.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + + json.field "subCountText", video.sub_count_text + + json.field "lengthSeconds", video.length_seconds + json.field "allowRatings", video.allow_ratings + json.field "rating", 0_i64 + json.field "isListed", video.is_listed + json.field "liveNow", video.live_now + json.field "isUpcoming", video.is_upcoming + + if video.premiere_timestamp + json.field "premiereTimestamp", video.premiere_timestamp.try &.to_unix + end + + if hlsvp = video.hls_manifest_url + hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", HOST_URL) + json.field "hlsUrl", hlsvp + end + + json.field "dashUrl", "#{HOST_URL}/api/manifest/dash/id/#{video.id}" + + json.field "adaptiveFormats" do + json.array do + video.adaptive_fmts.each do |fmt| + json.object do + # Only available on regular videos, not livestreams/OTF streams + if init_range = fmt["initRange"]? + json.field "init", "#{init_range["start"]}-#{init_range["end"]}" + end + if index_range = fmt["indexRange"]? + json.field "index", "#{index_range["start"]}-#{index_range["end"]}" + end + + # Not available on MPEG-4 Timed Text (`text/mp4`) streams (livestreams only) + json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]? + + json.field "url", fmt["url"] + json.field "itag", fmt["itag"].as_i.to_s + json.field "type", fmt["mimeType"] + json.field "clen", fmt["contentLength"]? || "-1" + + # Last modified is a unix timestamp with µS, with the dot omitted. + # E.g: 1638056732(.)141582 + # + # On livestreams, it's not present, so always fall back to the + # current unix timestamp (up to mS precision) for compatibility. + last_modified = fmt["lastModified"]? + last_modified ||= "#{Time.utc.to_unix_ms.to_s}000" + json.field "lmt", last_modified + + json.field "projectionType", fmt["projectionType"] + + if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) + fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 + json.field "fps", fps + json.field "container", fmt_info["ext"] + json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] + + if fmt_info["height"]? + json.field "resolution", "#{fmt_info["height"]}p" + + quality_label = "#{fmt_info["height"]}p" + if fps > 30 + quality_label += "60" + end + json.field "qualityLabel", quality_label + + if fmt_info["width"]? + json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}" + end + end + end + + # Livestream chunk infos + json.field "targetDurationSec", fmt["targetDurationSec"].as_i if fmt.has_key?("targetDurationSec") + json.field "maxDvrDurationSec", fmt["maxDvrDurationSec"].as_i if fmt.has_key?("maxDvrDurationSec") + + # Audio-related data + json.field "audioQuality", fmt["audioQuality"] if fmt.has_key?("audioQuality") + json.field "audioSampleRate", fmt["audioSampleRate"].as_s.to_i if fmt.has_key?("audioSampleRate") + json.field "audioChannels", fmt["audioChannels"] if fmt.has_key?("audioChannels") + + # Extra misc stuff + json.field "colorInfo", fmt["colorInfo"] if fmt.has_key?("colorInfo") + json.field "captionTrack", fmt["captionTrack"] if fmt.has_key?("captionTrack") + end + end + end + end + + json.field "formatStreams" do + json.array do + video.fmt_stream.each do |fmt| + json.object do + json.field "url", fmt["url"] + json.field "itag", fmt["itag"].as_i.to_s + json.field "type", fmt["mimeType"] + json.field "quality", fmt["quality"] + + fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) + if fmt_info + fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 + json.field "fps", fps + json.field "container", fmt_info["ext"] + json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] + + if fmt_info["height"]? + json.field "resolution", "#{fmt_info["height"]}p" + + quality_label = "#{fmt_info["height"]}p" + if fps > 30 + quality_label += "60" + end + json.field "qualityLabel", quality_label + + if fmt_info["width"]? + json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}" + end + end + end + end + end + end + end + + json.field "captions" do + json.array do + video.captions.each do |caption| + json.object do + json.field "label", caption.name + json.field "language_code", caption.language_code + json.field "url", "/api/v1/captions/#{video.id}?label=#{URI.encode_www_form(caption.name)}" + end + end + end + end + + json.field "recommendedVideos" do + json.array do + video.related_videos.each do |rv| + if rv["id"]? + json.object do + json.field "videoId", rv["id"] + json.field "title", rv["title"] + json.field "videoThumbnails" do + self.thumbnails(json, rv["id"]) + end + + json.field "author", rv["author"] + json.field "authorUrl", "/channel/#{rv["ucid"]?}" + json.field "authorId", rv["ucid"]? + if rv["author_thumbnail"]? + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-") + json.field "width", quality + json.field "height", quality + end + end + end + end + end + + json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i + json.field "viewCountText", rv["short_view_count"]? + json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64 + end + end + end + end + end + end + end + + def storyboards(json, id, storyboards) + json.array do + storyboards.each do |storyboard| + json.object do + json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}" + json.field "templateUrl", storyboard[:url] + json.field "width", storyboard[:width] + json.field "height", storyboard[:height] + json.field "count", storyboard[:count] + json.field "interval", storyboard[:interval] + json.field "storyboardWidth", storyboard[:storyboard_width] + json.field "storyboardHeight", storyboard[:storyboard_height] + json.field "storyboardCount", storyboard[:storyboard_count] + end + end + end + end +end diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index c4eb7507..57f1f53e 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -56,7 +56,7 @@ struct PlaylistVideo json.field "authorUrl", "/channel/#{self.ucid}" json.field "videoThumbnails" do - generate_thumbnails(json, self.id) + Invidious::JSONify::APIv1.thumbnails(json, self.id) end if index diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index bfb8a377..ae65f10d 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -14,8 +14,6 @@ module Invidious::Routes::API::Manifest begin video = get_video(id, region: region) - rescue ex : VideoRedirect - return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex : NotFoundException haltf env, status_code: 404 rescue ex diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index 1f5ad8ef..421355bb 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -226,8 +226,8 @@ module Invidious::Routes::API::V1::Authenticated return error_json(403, "Invalid user") end - if playlist.index.size >= 500 - return error_json(400, "Playlist cannot have more than 500 videos") + if playlist.index.size >= CONFIG.playlist_length_limit + return error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") end video_id = env.params.json["videoId"].try &.as(String) diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 844fedb8..43d360e6 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -124,7 +124,7 @@ module Invidious::Routes::API::V1::Misc json.field "videoThumbnails" do json.array do - generate_thumbnails(json, video.id) + Invidious::JSONify::APIv1.thumbnails(json, video.id) end end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 1b7b4fa7..a6b2eb4e 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -9,9 +9,6 @@ module Invidious::Routes::API::V1::Videos begin video = get_video(id, 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 : NotFoundException return error_json(404, ex) rescue ex @@ -41,9 +38,6 @@ module Invidious::Routes::API::V1::Videos begin video = get_video(id, 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 : NotFoundException haltf env, 404 rescue ex @@ -168,9 +162,6 @@ module Invidious::Routes::API::V1::Videos begin video = get_video(id, 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 : NotFoundException haltf env, 404 rescue ex @@ -185,7 +176,7 @@ module Invidious::Routes::API::V1::Videos response = JSON.build do |json| json.object do json.field "storyboards" do - generate_storyboards(json, id, storyboards) + Invidious::JSONify::APIv1.storyboards(json, id, storyboards) end end end diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr new file mode 100644 index 00000000..8e2a253f --- /dev/null +++ b/src/invidious/routes/before_all.cr @@ -0,0 +1,152 @@ +module Invidious::Routes::BeforeAll + def self.handle(env) + preferences = Preferences.from_json("{}") + + begin + if prefs_cookie = env.request.cookies["PREFS"]? + preferences = Preferences.from_json(URI.decode_www_form(prefs_cookie.value)) + else + if language_header = env.request.headers["Accept-Language"]? + if language = ANG.language_negotiator.best(language_header, LOCALES.keys) + preferences.locale = language.header + end + end + end + rescue + preferences = Preferences.from_json("{}") + end + + env.set "preferences", preferences + env.response.headers["X-XSS-Protection"] = "1; mode=block" + env.response.headers["X-Content-Type-Options"] = "nosniff" + + # Allow media resources to be loaded from google servers + # TODO: check if *.youtube.com can be removed + if CONFIG.disabled?("local") || !preferences.local + extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443" + else + extra_media_csp = "" + end + + # Only allow the pages at /embed/* to be embedded + if env.request.resource.starts_with?("/embed") + frame_ancestors = "'self' http: https:" + else + frame_ancestors = "'none'" + end + + # TODO: Remove style-src's 'unsafe-inline', requires to remove all + # inline styles (<style> [..] </style>, style=" [..] ") + env.response.headers["Content-Security-Policy"] = { + "default-src 'none'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self' data:", + "connect-src 'self'", + "manifest-src 'self'", + "media-src 'self' blob:" + extra_media_csp, + "child-src 'self' blob:", + "frame-src 'self'", + "frame-ancestors " + frame_ancestors, + }.join("; ") + + env.response.headers["Referrer-Policy"] = "same-origin" + + # Ask the chrom*-based browsers to disable FLoC + # See: https://blog.runcloud.io/google-floc/ + env.response.headers["Permissions-Policy"] = "interest-cohort=()" + + if (Kemal.config.ssl || CONFIG.https_only) && CONFIG.hsts + env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload" + end + + return if { + "/sb/", + "/vi/", + "/s_p/", + "/yts/", + "/ggpht/", + "/api/manifest/", + "/videoplayback", + "/latest_version", + "/download", + }.any? { |r| env.request.resource.starts_with? r } + + if env.request.cookies.has_key? "SID" + sid = env.request.cookies["SID"].value + + if sid.starts_with? "v1:" + raise "Cannot use token as SID" + end + + # Invidious users only have SID + if !env.request.cookies.has_key? "SSID" + if email = Invidious::Database::SessionIDs.select_email(sid) + user = Invidious::Database::Users.select!(email: email) + csrf_token = generate_response(sid, { + ":authorize_token", + ":playlist_ajax", + ":signout", + ":subscription_ajax", + ":token_ajax", + ":watch_ajax", + }, HMAC_KEY, 1.week) + + preferences = user.preferences + env.set "preferences", preferences + + env.set "sid", sid + env.set "csrf_token", csrf_token + env.set "user", user + end + else + headers = HTTP::Headers.new + headers["Cookie"] = env.request.headers["Cookie"] + + begin + user, sid = get_user(sid, headers, false) + csrf_token = generate_response(sid, { + ":authorize_token", + ":playlist_ajax", + ":signout", + ":subscription_ajax", + ":token_ajax", + ":watch_ajax", + }, HMAC_KEY, 1.week) + + preferences = user.preferences + env.set "preferences", preferences + + env.set "sid", sid + env.set "csrf_token", csrf_token + env.set "user", user + rescue ex + end + end + end + + dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s + thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode.to_s + thin_mode = thin_mode == "true" + locale = env.params.query["hl"]? || preferences.locale + + preferences.dark_mode = dark_mode + preferences.thin_mode = thin_mode + preferences.locale = locale + env.set "preferences", preferences + + current_page = env.request.path + if env.request.query + query = HTTP::Params.parse(env.request.query.not_nil!) + + if query["referer"]? + query["referer"] = get_referer(env, "/") + end + + current_page += "?#{query}" + end + + env.set "current_page", URI.encode_www_form(current_page) + end +end diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 84da9993..289d87c9 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -2,11 +2,16 @@ module Invidious::Routes::Embed def self.redirect(env) + locale = env.get("preferences").as(Preferences).locale if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") begin playlist = get_playlist(plid) offset = env.params.query["index"]?.try &.to_i? || 0 videos = get_playlist_videos(playlist, offset: offset) + if videos.empty? + url = "/playlist?list=#{plid}" + raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url)) + end rescue ex : NotFoundException return error_template(404, ex) rescue ex @@ -26,6 +31,7 @@ module Invidious::Routes::Embed end def self.show(env) + locale = env.get("preferences").as(Preferences).locale id = env.params.url["id"] plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") @@ -62,6 +68,10 @@ module Invidious::Routes::Embed playlist = get_playlist(plid) offset = env.params.query["index"]?.try &.to_i? || 0 videos = get_playlist_videos(playlist, offset: offset) + if videos.empty? + url = "/playlist?list=#{plid}" + raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url)) + end rescue ex : NotFoundException return error_template(404, ex) rescue ex @@ -121,8 +131,6 @@ module Invidious::Routes::Embed begin video = get_video(id, region: params.region) - rescue ex : VideoRedirect - return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex : NotFoundException return error_template(404, ex) rescue ex diff --git a/src/invidious/routes/errors.cr b/src/invidious/routes/errors.cr new file mode 100644 index 00000000..b138b562 --- /dev/null +++ b/src/invidious/routes/errors.cr @@ -0,0 +1,47 @@ +module Invidious::Routes::ErrorRoutes + def self.error_404(env) + if md = env.request.path.match(/^\/(?<id>([a-zA-Z0-9_-]{11})|(\w+))$/) + item = md["id"] + + # Check if item is branding URL e.g. https://youtube.com/gaming + response = YT_POOL.client &.get("/#{item}") + + if response.status_code == 301 + response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target) + end + + if response.body.empty? + env.response.headers["Location"] = "/" + haltf env, status_code: 302 + end + + html = XML.parse_html(response.body) + ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] + + if ucid + env.response.headers["Location"] = "/channel/#{ucid}" + haltf env, status_code: 302 + end + + params = [] of String + env.params.query.each do |k, v| + params << "#{k}=#{v}" + end + params = params.join("&") + + url = "/watch?v=#{item}" + if !params.empty? + url += "&#{params}" + end + + # Check if item is video ID + if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404 + env.response.headers["Location"] = url + haltf env, status_code: 302 + end + end + + env.response.headers["Location"] = "/" + haltf env, status_code: 302 + end +end diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index fe7e4e1c..0d242ee6 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -330,11 +330,11 @@ module Invidious::Routes::Playlists when "action_edit_playlist" # TODO: Playlist stub when "action_add_video" - if playlist.index.size >= 500 + if playlist.index.size >= CONFIG.playlist_length_limit if redirect - return error_template(400, "Playlist cannot have more than 500 videos") + return error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") else - return error_json(400, "Playlist cannot have more than 500 videos") + return error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") end end diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index fe1d8e54..5f481557 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -61,8 +61,6 @@ module Invidious::Routes::Watch begin video = get_video(id, region: params.region) - rescue ex : VideoRedirect - return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex : NotFoundException LOGGER.error("get_video not found: #{id} : #{ex.message}") return error_template(404, ex) diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index bd72c577..f409f13c 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -1,130 +1,273 @@ module Invidious::Routing - {% for http_method in {"get", "post", "delete", "options", "patch", "put", "head"} %} + extend self + + {% for http_method in {"get", "post", "delete", "options", "patch", "put"} %} macro {{http_method.id}}(path, controller, method = :handle) - {{http_method.id}} \{{ path }} do |env| + unless Kemal::Utils.path_starts_with_slash?(\{{path}}) + raise Kemal::Exceptions::InvalidPathStartException.new({{http_method}}, \{{path}}) + end + + Kemal::RouteHandler::INSTANCE.add_route({{http_method.upcase}}, \{{path}}) do |env| \{{ controller }}.\{{ method.id }}(env) end end {% end %} -end -macro define_user_routes - # User login/out - Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page - Invidious::Routing.post "/login", Invidious::Routes::Login, :login - Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout - Invidious::Routing.get "/Captcha", Invidious::Routes::Login, :captcha - - # User preferences - Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show - Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update - Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme - Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control - Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control - - # User account management - Invidious::Routing.get "/change_password", Invidious::Routes::Account, :get_change_password - Invidious::Routing.post "/change_password", Invidious::Routes::Account, :post_change_password - Invidious::Routing.get "/delete_account", Invidious::Routes::Account, :get_delete - Invidious::Routing.post "/delete_account", Invidious::Routes::Account, :post_delete - Invidious::Routing.get "/clear_watch_history", Invidious::Routes::Account, :get_clear_history - Invidious::Routing.post "/clear_watch_history", Invidious::Routes::Account, :post_clear_history - Invidious::Routing.get "/authorize_token", Invidious::Routes::Account, :get_authorize_token - Invidious::Routing.post "/authorize_token", Invidious::Routes::Account, :post_authorize_token - Invidious::Routing.get "/token_manager", Invidious::Routes::Account, :token_manager - Invidious::Routing.post "/token_ajax", Invidious::Routes::Account, :token_ajax -end + def register_all + {% unless flag?(:api_only) %} + get "/", Routes::Misc, :home + get "/privacy", Routes::Misc, :privacy + get "/licenses", Routes::Misc, :licenses + get "/redirect", Routes::Misc, :cross_instance_redirect -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 %} + self.register_channel_routes + self.register_watch_routes - # 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 + self.register_iv_playlist_routes + self.register_yt_playlist_routes + self.register_search_routes - # Search - Invidious::Routing.get "/api/v1/search", {{namespace}}::Search, :search - Invidious::Routing.get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions + self.register_user_routes + self.register_feed_routes - # Authenticated + # Support push notifications via PubSubHubbub + get "/feed/webhook/:token", Routes::Feeds, :push_notifications_get + post "/feed/webhook/:token", Routes::Feeds, :push_notifications_post - # 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 + get "/modify_notifications", Routes::Notifications, :modify + {% end %} - Invidious::Routing.get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences - Invidious::Routing.post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences + self.register_image_routes + self.register_api_v1_routes + self.register_api_manifest_routes + self.register_video_playback_routes + end - Invidious::Routing.get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed + # ------------------- + # Invidious routes + # ------------------- - 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 + def register_user_routes + # User login/out + get "/login", Routes::Login, :login_page + post "/login", Routes::Login, :login + post "/signout", Routes::Login, :signout + get "/Captcha", Routes::Login, :captcha + # User preferences + get "/preferences", Routes::PreferencesRoute, :show + post "/preferences", Routes::PreferencesRoute, :update + get "/toggle_theme", Routes::PreferencesRoute, :toggle_theme + get "/data_control", Routes::PreferencesRoute, :data_control + post "/data_control", Routes::PreferencesRoute, :update_data_control - 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 + # User account management + get "/change_password", Routes::Account, :get_change_password + post "/change_password", Routes::Account, :post_change_password + get "/delete_account", Routes::Account, :get_delete + post "/delete_account", Routes::Account, :post_delete + get "/clear_watch_history", Routes::Account, :get_clear_history + post "/clear_watch_history", Routes::Account, :post_clear_history + get "/authorize_token", Routes::Account, :get_authorize_token + post "/authorize_token", Routes::Account, :post_authorize_token + get "/token_manager", Routes::Account, :token_manager + post "/token_ajax", Routes::Account, :token_ajax + post "/subscription_ajax", Routes::Subscriptions, :toggle_subscription + get "/subscription_manager", Routes::Subscriptions, :subscription_manager + end + def register_iv_playlist_routes + get "/create_playlist", Routes::Playlists, :new + post "/create_playlist", Routes::Playlists, :create + get "/subscribe_playlist", Routes::Playlists, :subscribe + get "/delete_playlist", Routes::Playlists, :delete_page + post "/delete_playlist", Routes::Playlists, :delete + get "/edit_playlist", Routes::Playlists, :edit + post "/edit_playlist", Routes::Playlists, :update + get "/add_playlist_items", Routes::Playlists, :add_playlist_items_page + post "/playlist_ajax", Routes::Playlists, :playlist_ajax + end - 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 + def register_feed_routes + # Feeds + get "/view_all_playlists", Routes::Feeds, :view_all_playlists_redirect + get "/feed/playlists", Routes::Feeds, :playlists + get "/feed/popular", Routes::Feeds, :popular + get "/feed/trending", Routes::Feeds, :trending + get "/feed/subscriptions", Routes::Feeds, :subscriptions + get "/feed/history", Routes::Feeds, :history - 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 + # RSS Feeds + get "/feed/channel/:ucid", Routes::Feeds, :rss_channel + get "/feed/private", Routes::Feeds, :rss_private + get "/feed/playlist/:plid", Routes::Feeds, :rss_playlist + get "/feeds/videos.xml", Routes::Feeds, :rss_videos + end - Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications - Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + # ------------------- + # Youtube routes + # ------------------- - # 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 + def register_channel_routes + get "/channel/:ucid", Routes::Channels, :home + get "/channel/:ucid/home", Routes::Channels, :home + get "/channel/:ucid/videos", Routes::Channels, :videos + get "/channel/:ucid/playlists", Routes::Channels, :playlists + get "/channel/:ucid/community", Routes::Channels, :community + get "/channel/:ucid/about", Routes::Channels, :about + get "/channel/:ucid/live", Routes::Channels, :live + get "/user/:user/live", Routes::Channels, :live + get "/c/:user/live", Routes::Channels, :live -macro define_api_manifest_routes - Invidious::Routing.get "/api/manifest/dash/id/:id", Invidious::Routes::API::Manifest, :get_dash_video_id + ["", "/videos", "/playlists", "/community", "/about"].each do |path| + # /c/LinusTechTips + get "/c/:user#{path}", Routes::Channels, :brand_redirect + # /user/linustechtips | Not always the same as /c/ + get "/user/:user#{path}", Routes::Channels, :brand_redirect + # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow + get "/attribution_link#{path}", Routes::Channels, :brand_redirect + # /profile?user=linustechtips + get "/profile/#{path}", Routes::Channels, :profile + end + end - 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 + def register_watch_routes + get "/watch", Routes::Watch, :handle + post "/watch_ajax", Routes::Watch, :mark_watched + get "/watch/:id", Routes::Watch, :redirect + get "/shorts/:id", Routes::Watch, :redirect + get "/clip/:clip", Routes::Watch, :clip + get "/w/:id", Routes::Watch, :redirect + get "/v/:id", Routes::Watch, :redirect + get "/e/:id", Routes::Watch, :redirect - 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 + post "/download", Routes::Watch, :download - 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 + get "/embed/", Routes::Embed, :redirect + get "/embed/:id", Routes::Embed, :show + end + + def register_yt_playlist_routes + get "/playlist", Routes::Playlists, :show + get "/mix", Routes::Playlists, :mix + get "/watch_videos", Routes::Playlists, :watch_videos + end + + def register_search_routes + get "/opensearch.xml", Routes::Search, :opensearch + get "/results", Routes::Search, :results + get "/search", Routes::Search, :search + get "/hashtag/:hashtag", Routes::Search, :hashtag + end + + # ------------------- + # Media proxy routes + # ------------------- + + def register_api_manifest_routes + get "/api/manifest/dash/id/:id", Routes::API::Manifest, :get_dash_video_id + + get "/api/manifest/dash/id/videoplayback", Routes::API::Manifest, :get_dash_video_playback + get "/api/manifest/dash/id/videoplayback/*", Routes::API::Manifest, :get_dash_video_playback_greedy + + options "/api/manifest/dash/id/videoplayback", Routes::API::Manifest, :options_dash_video_playback + options "/api/manifest/dash/id/videoplayback/*", Routes::API::Manifest, :options_dash_video_playback + + get "/api/manifest/hls_playlist/*", Routes::API::Manifest, :get_hls_playlist + get "/api/manifest/hls_variant/*", Routes::API::Manifest, :get_hls_variant + end + + def register_video_playback_routes + get "/videoplayback", Routes::VideoPlayback, :get_video_playback + get "/videoplayback/*", Routes::VideoPlayback, :get_video_playback_greedy + + options "/videoplayback", Routes::VideoPlayback, :options_video_playback + options "/videoplayback/*", Routes::VideoPlayback, :options_video_playback + + get "/latest_version", Routes::VideoPlayback, :latest_version + end + + def register_image_routes + get "/ggpht/*", Routes::Images, :ggpht + options "/sb/:authority/:id/:storyboard/:index", Routes::Images, :options_storyboard + get "/sb/:authority/:id/:storyboard/:index", Routes::Images, :get_storyboard + get "/s_p/:id/:name", Routes::Images, :s_p_image + get "/yts/img/:name", Routes::Images, :yts_image + get "/vi/:id/:name", Routes::Images, :thumbnails + end + + # ------------------- + # API routes + # ------------------- + + def register_api_v1_routes + {% begin %} + {{namespace = Routes::API::V1}} + + # Videos + get "/api/v1/videos/:id", {{namespace}}::Videos, :videos + get "/api/v1/storyboards/:id", {{namespace}}::Videos, :storyboards + get "/api/v1/captions/:id", {{namespace}}::Videos, :captions + get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations + get "/api/v1/comments/:id", {{namespace}}::Videos, :comments + + # Feeds + get "/api/v1/trending", {{namespace}}::Feeds, :trending + get "/api/v1/popular", {{namespace}}::Feeds, :popular + + # Channels + get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home + {% for route in {"videos", "latest", "playlists", "community", "search"} %} + get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}} + get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}} + {% end %} + + # 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community + get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect + get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect + + # Search + get "/api/v1/search", {{namespace}}::Search, :search + 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 + + get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences + post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences + + get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed + + get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions + post "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :subscribe_channel + delete "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :unsubscribe_channel + + get "/api/v1/auth/playlists", {{namespace}}::Authenticated, :list_playlists + post "/api/v1/auth/playlists", {{namespace}}::Authenticated, :create_playlist + patch "/api/v1/auth/playlists/:plid",{{namespace}}:: Authenticated, :update_playlist_attribute + delete "/api/v1/auth/playlists/:plid", {{namespace}}::Authenticated, :delete_playlist + post "/api/v1/auth/playlists/:plid/videos", {{namespace}}::Authenticated, :insert_video_into_playlist + delete "/api/v1/auth/playlists/:plid/videos/:index", {{namespace}}::Authenticated, :delete_video_in_playlist -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 + get "/api/v1/auth/tokens", {{namespace}}::Authenticated, :get_tokens + post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token + post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token - Invidious::Routing.options "/videoplayback", Invidious::Routes::VideoPlayback, :options_video_playback - Invidious::Routing.options "/videoplayback/*", Invidious::Routes::VideoPlayback, :options_video_playback + get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications - Invidious::Routing.get "/latest_version", Invidious::Routes::VideoPlayback, :latest_version + # Misc + get "/api/v1/stats", {{namespace}}::Misc, :stats + get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist + get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist + get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes + {% end %} + end end diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index f8b9e4e4..20ae0d47 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -71,7 +71,9 @@ struct Invidious::User Invidious::Database::Playlists.update_description(playlist.id, description) videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| - raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500 + if idx > CONFIG.playlist_length_limit + raise InfoException.new("Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") + end video_id = video_id.try &.as_s? next if !video_id diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index e9526c18..d626c7d1 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1,280 +1,22 @@ -CAPTION_LANGUAGES = { - "", - "English", - "English (auto-generated)", - "English (United Kingdom)", - "English (United States)", - "Afrikaans", - "Albanian", - "Amharic", - "Arabic", - "Armenian", - "Azerbaijani", - "Bangla", - "Basque", - "Belarusian", - "Bosnian", - "Bulgarian", - "Burmese", - "Cantonese (Hong Kong)", - "Catalan", - "Cebuano", - "Chinese", - "Chinese (China)", - "Chinese (Hong Kong)", - "Chinese (Simplified)", - "Chinese (Taiwan)", - "Chinese (Traditional)", - "Corsican", - "Croatian", - "Czech", - "Danish", - "Dutch", - "Dutch (auto-generated)", - "Esperanto", - "Estonian", - "Filipino", - "Finnish", - "French", - "French (auto-generated)", - "Galician", - "Georgian", - "German", - "German (auto-generated)", - "Greek", - "Gujarati", - "Haitian Creole", - "Hausa", - "Hawaiian", - "Hebrew", - "Hindi", - "Hmong", - "Hungarian", - "Icelandic", - "Igbo", - "Indonesian", - "Indonesian (auto-generated)", - "Interlingue", - "Irish", - "Italian", - "Italian (auto-generated)", - "Japanese", - "Japanese (auto-generated)", - "Javanese", - "Kannada", - "Kazakh", - "Khmer", - "Korean", - "Korean (auto-generated)", - "Kurdish", - "Kyrgyz", - "Lao", - "Latin", - "Latvian", - "Lithuanian", - "Luxembourgish", - "Macedonian", - "Malagasy", - "Malay", - "Malayalam", - "Maltese", - "Maori", - "Marathi", - "Mongolian", - "Nepali", - "Norwegian Bokmål", - "Nyanja", - "Pashto", - "Persian", - "Polish", - "Portuguese", - "Portuguese (auto-generated)", - "Portuguese (Brazil)", - "Punjabi", - "Romanian", - "Russian", - "Russian (auto-generated)", - "Samoan", - "Scottish Gaelic", - "Serbian", - "Shona", - "Sindhi", - "Sinhala", - "Slovak", - "Slovenian", - "Somali", - "Southern Sotho", - "Spanish", - "Spanish (auto-generated)", - "Spanish (Latin America)", - "Spanish (Mexico)", - "Spanish (Spain)", - "Sundanese", - "Swahili", - "Swedish", - "Tajik", - "Tamil", - "Telugu", - "Thai", - "Turkish", - "Turkish (auto-generated)", - "Ukrainian", - "Urdu", - "Uzbek", - "Vietnamese", - "Vietnamese (auto-generated)", - "Welsh", - "Western Frisian", - "Xhosa", - "Yiddish", - "Yoruba", - "Zulu", -} - -REGIONS = {"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW"} - -# See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476 -VIDEO_FORMATS = { - "5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, - "6" => {"ext" => "flv", "width" => 450, "height" => 270, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, - "13" => {"ext" => "3gp", "acodec" => "aac", "vcodec" => "mp4v"}, - "17" => {"ext" => "3gp", "width" => 176, "height" => 144, "acodec" => "aac", "abr" => 24, "vcodec" => "mp4v"}, - "18" => {"ext" => "mp4", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 96, "vcodec" => "h264"}, - "22" => {"ext" => "mp4", "width" => 1280, "height" => 720, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "34" => {"ext" => "flv", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "35" => {"ext" => "flv", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - - "36" => {"ext" => "3gp", "width" => 320, "acodec" => "aac", "vcodec" => "mp4v"}, - "37" => {"ext" => "mp4", "width" => 1920, "height" => 1080, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "38" => {"ext" => "mp4", "width" => 4096, "height" => 3072, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "43" => {"ext" => "webm", "width" => 640, "height" => 360, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, - "44" => {"ext" => "webm", "width" => 854, "height" => 480, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, - "45" => {"ext" => "webm", "width" => 1280, "height" => 720, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - "46" => {"ext" => "webm", "width" => 1920, "height" => 1080, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - "59" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "78" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - - # 3D videos - "82" => {"ext" => "mp4", "height" => 360, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "83" => {"ext" => "mp4", "height" => 480, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "84" => {"ext" => "mp4", "height" => 720, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "85" => {"ext" => "mp4", "height" => 1080, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "100" => {"ext" => "webm", "height" => 360, "format" => "3D", "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, - "101" => {"ext" => "webm", "height" => 480, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - "102" => {"ext" => "webm", "height" => 720, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - - # Apple HTTP Live Streaming - "91" => {"ext" => "mp4", "height" => 144, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, - "92" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, - "93" => {"ext" => "mp4", "height" => 360, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "94" => {"ext" => "mp4", "height" => 480, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "95" => {"ext" => "mp4", "height" => 720, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, - "96" => {"ext" => "mp4", "height" => 1080, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, - "132" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, - "151" => {"ext" => "mp4", "height" => 72, "format" => "HLS", "acodec" => "aac", "abr" => 24, "vcodec" => "h264"}, - - # DASH mp4 video - "133" => {"ext" => "mp4", "height" => 240, "format" => "DASH video", "vcodec" => "h264"}, - "134" => {"ext" => "mp4", "height" => 360, "format" => "DASH video", "vcodec" => "h264"}, - "135" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, - "136" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264"}, - "137" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264"}, - "138" => {"ext" => "mp4", "format" => "DASH video", "vcodec" => "h264"}, # Height can vary (https://github.com/ytdl-org/youtube-dl/issues/4559) - "160" => {"ext" => "mp4", "height" => 144, "format" => "DASH video", "vcodec" => "h264"}, - "212" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, - "264" => {"ext" => "mp4", "height" => 1440, "format" => "DASH video", "vcodec" => "h264"}, - "298" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, - "299" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, - "266" => {"ext" => "mp4", "height" => 2160, "format" => "DASH video", "vcodec" => "h264"}, - - # Dash mp4 audio - "139" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 48, "container" => "m4a_dash"}, - "140" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 128, "container" => "m4a_dash"}, - "141" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 256, "container" => "m4a_dash"}, - "256" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, - "258" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, - "325" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "dtse", "container" => "m4a_dash"}, - "328" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "ec-3", "container" => "m4a_dash"}, - - # Dash webm - "167" => {"ext" => "webm", "height" => 360, "width" => 640, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "168" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "169" => {"ext" => "webm", "height" => 720, "width" => 1280, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "170" => {"ext" => "webm", "height" => 1080, "width" => 1920, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "218" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "219" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "278" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "container" => "webm", "vcodec" => "vp9"}, - "242" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9"}, - "243" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9"}, - "244" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, - "245" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, - "246" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, - "247" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9"}, - "248" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9"}, - "271" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9"}, - # itag 272 videos are either 3840x2160 (e.g. RtoitU2A-3E) or 7680x4320 (sLprVF6d7Ug) - "272" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, - "302" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "303" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "308" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "313" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, - "315" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "330" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "331" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "332" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "333" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "334" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "335" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "336" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "337" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - - # Dash webm audio - "171" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 128}, - "172" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 256}, - - # Dash webm audio with opus inside - "249" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 50}, - "250" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 70}, - "251" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 160}, - - # av01 video only formats sometimes served with "unknown" codecs - "394" => {"ext" => "mp4", "height" => 144, "vcodec" => "av01.0.05M.08"}, - "395" => {"ext" => "mp4", "height" => 240, "vcodec" => "av01.0.05M.08"}, - "396" => {"ext" => "mp4", "height" => 360, "vcodec" => "av01.0.05M.08"}, - "397" => {"ext" => "mp4", "height" => 480, "vcodec" => "av01.0.05M.08"}, -} - -struct VideoPreferences - include JSON::Serializable - - property annotations : Bool - property autoplay : Bool - property comments : Array(String) - property continue : Bool - property continue_autoplay : Bool - property controls : Bool - property listen : Bool - property local : Bool - property preferred_captions : Array(String) - property player_style : String - property quality : String - property quality_dash : String - property raw : Bool - property region : String? - property related_videos : Bool - property speed : Float32 | Float64 - property video_end : Float64 | Int32 - property video_loop : Bool - property extend_desc : Bool - property video_start : Float64 | Int32 - property volume : Int32 - property vr_mode : Bool - property save_player_pos : Bool +enum VideoType + Video + Livestream + Scheduled end struct Video include DB::Serializable + # Version of the JSON structure + # It prevents us from loading an incompatible version from cache + # (either newer or older, if instances with different versions run + # concurrently, e.g during a version upgrade rollout). + # + # NOTE: don't forget to bump this number if any change is made to + # the `params` structure in videos/parser.cr!!! + # + SCHEMA_VERSION = 2 + property id : String @[DB::Field(converter: Video::JSONConverter)] @@ -282,7 +24,7 @@ struct Video property updated : Time @[DB::Field(ignore: true)] - property captions : Array(Caption)? + @captions = [] of Invidious::Videos::Caption @[DB::Field(ignore: true)] property adaptive_fmts : Array(Hash(String, JSON::Any))? @@ -299,289 +41,45 @@ struct Video end end - def to_json(locale : String?, json : JSON::Builder) - json.object do - json.field "type", "video" - - json.field "title", self.title - json.field "videoId", self.id - - json.field "error", info["reason"] if info["reason"]? - - json.field "videoThumbnails" do - generate_thumbnails(json, self.id) - end - json.field "storyboards" do - generate_storyboards(json, self.id, self.storyboards) - end - - json.field "description", self.description - json.field "descriptionHtml", self.description_html - json.field "published", self.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) - json.field "keywords", self.keywords - - json.field "viewCount", self.views - json.field "likeCount", self.likes - json.field "dislikeCount", 0_i64 - - json.field "paid", self.paid - json.field "premium", self.premium - json.field "isFamilyFriendly", self.is_family_friendly - json.field "allowedRegions", self.allowed_regions - json.field "genre", self.genre - json.field "genreUrl", self.genre_url - - json.field "author", self.author - json.field "authorId", self.ucid - json.field "authorUrl", "/channel/#{self.ucid}" - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", self.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "subCountText", self.sub_count_text - - json.field "lengthSeconds", self.length_seconds - json.field "allowRatings", self.allow_ratings - json.field "rating", 0_i64 - json.field "isListed", self.is_listed - json.field "liveNow", self.live_now - json.field "isUpcoming", self.is_upcoming - - if self.premiere_timestamp - json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix - end - - if hlsvp = self.hls_manifest_url - hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", HOST_URL) - json.field "hlsUrl", hlsvp - end - - json.field "dashUrl", "#{HOST_URL}/api/manifest/dash/id/#{id}" - - json.field "adaptiveFormats" do - json.array do - self.adaptive_fmts.each do |fmt| - json.object do - # Only available on regular videos, not livestreams/OTF streams - if init_range = fmt["initRange"]? - json.field "init", "#{init_range["start"]}-#{init_range["end"]}" - end - if index_range = fmt["indexRange"]? - json.field "index", "#{index_range["start"]}-#{index_range["end"]}" - end - - # Not available on MPEG-4 Timed Text (`text/mp4`) streams (livestreams only) - json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]? - - json.field "url", fmt["url"] - json.field "itag", fmt["itag"].as_i.to_s - json.field "type", fmt["mimeType"] - json.field "clen", fmt["contentLength"]? || "-1" - json.field "lmt", fmt["lastModified"] - json.field "projectionType", fmt["projectionType"] - - if fmt_info = itag_to_metadata?(fmt["itag"]) - fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 - json.field "fps", fps - json.field "container", fmt_info["ext"] - json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] - - if fmt_info["height"]? - json.field "resolution", "#{fmt_info["height"]}p" - - quality_label = "#{fmt_info["height"]}p" - if fps > 30 - quality_label += "60" - end - json.field "qualityLabel", quality_label - - if fmt_info["width"]? - json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}" - end - end - end - - # Livestream chunk infos - json.field "targetDurationSec", fmt["targetDurationSec"].as_i if fmt.has_key?("targetDurationSec") - json.field "maxDvrDurationSec", fmt["maxDvrDurationSec"].as_i if fmt.has_key?("maxDvrDurationSec") - - # Audio-related data - json.field "audioQuality", fmt["audioQuality"] if fmt.has_key?("audioQuality") - json.field "audioSampleRate", fmt["audioSampleRate"].as_s.to_i if fmt.has_key?("audioSampleRate") - json.field "audioChannels", fmt["audioChannels"] if fmt.has_key?("audioChannels") - - # Extra misc stuff - json.field "colorInfo", fmt["colorInfo"] if fmt.has_key?("colorInfo") - json.field "captionTrack", fmt["captionTrack"] if fmt.has_key?("captionTrack") - end - end - end - end - - json.field "formatStreams" do - json.array do - self.fmt_stream.each do |fmt| - json.object do - json.field "url", fmt["url"] - json.field "itag", fmt["itag"].as_i.to_s - json.field "type", fmt["mimeType"] - json.field "quality", fmt["quality"] - - fmt_info = itag_to_metadata?(fmt["itag"]) - if fmt_info - fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 - json.field "fps", fps - json.field "container", fmt_info["ext"] - json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] - - if fmt_info["height"]? - json.field "resolution", "#{fmt_info["height"]}p" - - quality_label = "#{fmt_info["height"]}p" - if fps > 30 - quality_label += "60" - end - json.field "qualityLabel", quality_label - - if fmt_info["width"]? - json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}" - end - end - end - end - end - end - end - - json.field "captions" do - json.array do - self.captions.each do |caption| - json.object do - json.field "label", caption.name - json.field "language_code", caption.language_code - json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" - end - end - end - end + # Methods for API v1 JSON - json.field "recommendedVideos" do - json.array do - self.related_videos.each do |rv| - if rv["id"]? - json.object do - json.field "videoId", rv["id"] - json.field "title", rv["title"] - json.field "videoThumbnails" do - generate_thumbnails(json, rv["id"]) - end - - json.field "author", rv["author"] - json.field "authorUrl", "/channel/#{rv["ucid"]?}" - json.field "authorId", rv["ucid"]? - if rv["author_thumbnail"]? - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-") - json.field "width", quality - json.field "height", quality - end - end - end - end - end - - json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i - json.field "viewCountText", rv["short_view_count"]? - json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64 - end - end - end - end - end - end + def to_json(locale : String?, json : JSON::Builder) + Invidious::JSONify::APIv1.video(self, json, locale: locale) end # TODO: remove the locale and follow the crystal convention def to_json(locale : String?, _json : Nil) - JSON.build { |json| to_json(locale, json) } + JSON.build do |json| + Invidious::JSONify::APIv1.video(self, json, locale: locale) + end end def to_json(json : JSON::Builder | Nil = nil) to_json(nil, json) end - def title - info["videoDetails"]["title"]?.try &.as_s || "" - end + # Misc methods - def ucid - info["videoDetails"]["channelId"]?.try &.as_s || "" + def video_type : VideoType + video_type = info["videoType"]?.try &.as_s || "video" + return VideoType.parse?(video_type) || VideoType::Video end - def author - info["videoDetails"]["author"]?.try &.as_s || "" - end - - def length_seconds : Int32 - info.dig?("microformat", "playerMicroformatRenderer", "lengthSeconds").try &.as_s.to_i || - info["videoDetails"]["lengthSeconds"]?.try &.as_s.to_i || 0 - end - - def views : Int64 - info["videoDetails"]["viewCount"]?.try &.as_s.to_i64 || 0_i64 - end - - def likes : Int64 - info["likes"]?.try &.as_i64 || 0_i64 - end - - def dislikes : Int64 - info["dislikes"]?.try &.as_i64 || 0_i64 + def schema_version : Int + return info["version"]?.try &.as_i || 1 end def published : Time - info - .dig?("microformat", "playerMicroformatRenderer", "publishDate") + return info["published"]? .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc end def published=(other : Time) - info["microformat"].as_h["playerMicroformatRenderer"].as_h["publishDate"] = JSON::Any.new(other.to_s("%Y-%m-%d")) - end - - def allow_ratings - r = info["videoDetails"]["allowRatings"]?.try &.as_bool - r.nil? ? false : r + info["published"] = JSON::Any.new(other.to_s("%Y-%m-%d")) end def live_now - info["microformat"]?.try &.["playerMicroformatRenderer"]? - .try &.["liveBroadcastDetails"]?.try &.["isLiveNow"]?.try &.as_bool || false - end - - def is_listed - info["videoDetails"]["isCrawlable"]?.try &.as_bool || false - end - - def is_upcoming - info["videoDetails"]["isUpcoming"]?.try &.as_bool || false + return (self.video_type == VideoType::Livestream) end def premiere_timestamp : Time? @@ -590,31 +88,11 @@ struct Video .try { |t| Time.parse_rfc3339(t.as_s) } end - def keywords - info["videoDetails"]["keywords"]?.try &.as_a.map &.as_s || [] of String - end - def related_videos info["relatedVideos"]?.try &.as_a.map { |h| h.as_h.transform_values &.as_s } || [] of Hash(String, String) end - def allowed_regions - info - .dig?("microformat", "playerMicroformatRenderer", "availableCountries") - .try &.as_a.map &.as_s || [] of String - end - - def author_thumbnail : String - info["authorThumbnail"]?.try &.as_s || "" - end - - def author_verified : Bool - info["authorVerified"]?.try &.as_bool || false - end - - def sub_count_text : String - info["subCountText"]?.try &.as_s || "-" - end + # Methods for parsing streaming data def fmt_stream return @fmt_stream.as(Array(Hash(String, JSON::Any))) if @fmt_stream @@ -665,6 +143,8 @@ struct Video adaptive_fmts.select &.["mimeType"]?.try &.as_s.starts_with?("audio") end + # Misc. methods + def storyboards storyboards = info.dig?("storyboards", "playerStoryboardSpecRenderer", "spec") .try &.as_s.split("|") @@ -728,51 +208,19 @@ struct Video end def paid - reason = info.dig?("playabilityStatus", "reason").try &.as_s || "" - return reason.includes? "requires payment" + return (self.reason || "").includes? "requires payment" end def premium keywords.includes? "YouTube Red" end - def captions : Array(Caption) - return @captions.as(Array(Caption)) if @captions - captions = info["captions"]?.try &.["playerCaptionsTracklistRenderer"]?.try &.["captionTracks"]?.try &.as_a.map do |caption| - name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"] - language_code = caption["languageCode"].to_s - base_url = caption["baseUrl"].to_s - - caption = Caption.new(name.to_s, language_code, base_url) - caption.name = caption.name.split(" - ")[0] - caption + def captions : Array(Invidious::Videos::Caption) + if @captions.empty? && @info.has_key?("captions") + @captions = Invidious::Videos::Caption.from_yt_json(info["captions"]) end - captions ||= [] of Caption - @captions = captions - return @captions.as(Array(Caption)) - end - - def description - description = info - .dig?("microformat", "playerMicroformatRenderer", "description", "simpleText") - .try &.as_s || "" - end - - # TODO - def description=(value : String) - @description = value - end - def description_html - info["descriptionHtml"]?.try &.as_s || "<p></p>" - end - - def description_html=(value : String) - info["descriptionHtml"] = JSON::Any.new(value) - end - - def short_description - info["shortDescription"]?.try &.as_s? || "" + return @captions end def hls_manifest_url : String? @@ -783,25 +231,12 @@ struct Video info.dig?("streamingData", "dashManifestUrl").try &.as_s end - def genre : String - info["genre"]?.try &.as_s || "" - end - def genre_url : String? info["genreUcid"]? ? "/channel/#{info["genreUcid"]}" : nil end - def license : String? - info["license"]?.try &.as_s - end - - def is_family_friendly : Bool - info.dig?("microformat", "playerMicroformatRenderer", "isFamilySafe").try &.as_bool || false - end - def is_vr : Bool? - projection_type = info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s - return {"EQUIRECTANGULAR", "MESH"}.includes? projection_type + return {"EQUIRECTANGULAR", "MESH"}.includes? self.projection_type end def projection_type : String? @@ -811,284 +246,91 @@ struct Video def reason : String? info["reason"]?.try &.as_s end -end - -struct Caption - property name - property language_code - property base_url - - getter name : String - getter language_code : String - getter base_url : String - - setter name - - def initialize(@name, @language_code, @base_url) - end -end - -class VideoRedirect < Exception - property video_id : String - - def initialize(@video_id) - end -end - -# Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer". -# The former is preferred as it has more videos in it. The second has -# the same 11 first entries as the compact rendered. -# -# TODO: "compactRadioRenderer" (Mix) and -# TODO: Use a proper struct/class instead of a hacky JSON object -def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? - return nil if !related["videoId"]? - - # The compact renderer has video length in seconds, where the end - # screen rendered has a full text version ("42:40") - length = related["lengthInSeconds"]?.try &.as_i.to_s - length ||= related.dig?("lengthText", "simpleText").try do |box| - decode_length_seconds(box.as_s).to_s - end - - # Both have "short", so the "long" option shouldn't be required - channel_info = (related["shortBylineText"]? || related["longBylineText"]?) - .try &.dig?("runs", 0) - author = channel_info.try &.dig?("text") - author_verified = has_verified_badge?(related["ownerBadges"]?).to_s + # Macros defining getters/setters for various types of data - ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } - - # "4,088,033 views", only available on compact renderer - # and when video is not a livestream - view_count = related.dig?("viewCountText", "simpleText") - .try &.as_s.gsub(/\D/, "") - - short_view_count = related.try do |r| - HelperExtractors.get_short_view_count(r).to_s - end - - LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container") - - # TODO: when refactoring video types, make a struct for related videos - # or reuse an existing type, if that fits. - return { - "id" => related["videoId"], - "title" => related["title"]["simpleText"], - "author" => author || JSON::Any.new(""), - "ucid" => JSON::Any.new(ucid || ""), - "length_seconds" => JSON::Any.new(length || "0"), - "view_count" => JSON::Any.new(view_count || "0"), - "short_view_count" => JSON::Any.new(short_view_count || "0"), - "author_verified" => JSON::Any.new(author_verified), - } -end - -def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil) - # Init client config for the API - client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region) - if context_screen == "embed" - client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed - end - - # Fetch data from the player endpoint - # 8AEB param for fetching YouTube stories - player_response = YoutubeAPI.player(video_id: video_id, params: "8AEB", client_config: client_config) - - playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s - - if playability_status != "OK" - subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason") - reason = subreason.try &.[]?("simpleText").try &.as_s - reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("") - reason ||= player_response.dig("playabilityStatus", "reason").as_s + private macro getset_string(name) + # Return {{name.stringify}} from `info` + def {{name.id.underscore}} : String + return info[{{name.stringify}}]?.try &.as_s || "" + end - # Stop here if video is not a scheduled livestream - if playability_status != "LIVE_STREAM_OFFLINE" - return { - "reason" => JSON::Any.new(reason), - } + # Update {{name.stringify}} into `info` + def {{name.id.underscore}}=(value : String) + info[{{name.stringify}}] = JSON::Any.new(value) end - else - reason = nil - end - # Don't fetch the next endpoint if the video is unavailable. - if {"OK", "LIVE_STREAM_OFFLINE"}.any?(playability_status) - next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) - player_response = player_response.merge(next_response) + {% if flag?(:debug_macros) %} {{debug}} {% end %} end - params = parse_video_info(video_id, player_response) - params["reason"] = JSON::Any.new(reason) if reason - - # 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 reason.nil? - if context_screen == "embed" - client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed - else - client_config.client_type = YoutubeAPI::ClientType::Android + private macro getset_string_array(name) + # Return {{name.stringify}} from `info` + def {{name.id.underscore}} : Array(String) + return info[{{name.stringify}}]?.try &.as_a.map &.as_s || [] of String end - # 8AEB param for fetching YouTube stories - android_player = YoutubeAPI.player(video_id: video_id, params: "8AEB", client_config: client_config) - - # Sometime, the video is available from the web client, but not on Android, so check - # that here, and fallback to the streaming data from the web client if needed. - # See: https://github.com/iv-org/invidious/issues/2549 - if android_player["playabilityStatus"]["status"] == "OK" - params["streamingData"] = android_player["streamingData"]? || JSON::Any.new("") - else - params["streamingData"] = player_response["streamingData"]? || JSON::Any.new("") + + # Update {{name.stringify}} into `info` + def {{name.id.underscore}}=(value : Array(String)) + info[{{name.stringify}}] = JSON::Any.new(value) end - end - # TODO: clean that up - {"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f| - params[f] = player_response[f] if player_response[f]? + {% if flag?(:debug_macros) %} {{debug}} {% end %} end - return params -end - -def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any) - # Top level elements - - main_results = player_response.dig?("contents", "twoColumnWatchNextResults") - - raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results - - primary_results = main_results.dig?("results", "results", "contents") - - raise BrokenTubeException.new("results") if !primary_results - - video_primary_renderer = primary_results - .as_a.find(&.["videoPrimaryInfoRenderer"]?) - .try &.["videoPrimaryInfoRenderer"] - - video_secondary_renderer = primary_results - .as_a.find(&.["videoSecondaryInfoRenderer"]?) - .try &.["videoSecondaryInfoRenderer"] - - raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer - raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer - - # Related videos - - LOGGER.debug("extract_video_info: parsing related videos...") + {% for op, type in {i32: Int32, i64: Int64} %} + private macro getset_{{op}}(name) + def \{{name.id.underscore}} : {{type}} + return info[\{{name.stringify}}]?.try &.as_i64.to_{{op}} || 0_{{op}} + end - related = [] of JSON::Any + def \{{name.id.underscore}}=(value : Int) + info[\{{name.stringify}}] = JSON::Any.new(value.to_i64) + end - # Parse "compactVideoRenderer" items (under secondary results) - secondary_results = main_results - .dig?("secondaryResults", "secondaryResults", "results") - secondary_results.try &.as_a.each do |element| - if item = element["compactVideoRenderer"]? - related_video = parse_related_video(item) - related << JSON::Any.new(related_video) if related_video + \{% if flag?(:debug_macros) %} \{{debug}} \{% end %} end - end + {% end %} - # If nothing was found previously, fall back to end screen renderer - if related.empty? - # Container for "endScreenVideoRenderer" items - player_overlays = player_response.dig?( - "playerOverlays", "playerOverlayRenderer", - "endScreen", "watchNextEndScreenRenderer", "results" - ) - - player_overlays.try &.as_a.each do |element| - if item = element["endScreenVideoRenderer"]? - related_video = parse_related_video(item) - related << JSON::Any.new(related_video) if related_video - end + private macro getset_bool(name) + # Return {{name.stringify}} from `info` + def {{name.id.underscore}} : Bool + return info[{{name.stringify}}]?.try &.as_bool || false end - end - - # Likes - toplevel_buttons = video_primary_renderer - .try &.dig?("videoActions", "menuRenderer", "topLevelButtons") - - if toplevel_buttons - likes_button = toplevel_buttons.as_a - .find(&.dig("toggleButtonRenderer", "defaultIcon", "iconType").as_s.== "LIKE") - .try &.["toggleButtonRenderer"] - - if likes_button - likes_txt = (likes_button["defaultText"]? || likes_button["toggledText"]?) - .try &.dig?("accessibility", "accessibilityData", "label") - likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt - - LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"") - LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes + # Update {{name.stringify}} into `info` + def {{name.id.underscore}}=(value : Bool) + info[{{name.stringify}}] = JSON::Any.new(value) end - end - - # Description - - short_description = player_response.dig?("videoDetails", "shortDescription") - - description_html = video_secondary_renderer.try &.dig?("description", "runs") - .try &.as_a.try { |t| content_to_comment_html(t, video_id) } - - # Video metadata - - metadata = video_secondary_renderer - .try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows") - .try &.as_a - - genre = player_response.dig?("microformat", "playerMicroformatRenderer", "category") - genre_ucid = nil - license = nil - - metadata.try &.each do |row| - metadata_title = row.dig?("metadataRowRenderer", "title", "simpleText").try &.as_s - contents = row.dig?("metadataRowRenderer", "contents", 0) - if metadata_title == "Category" - contents = contents.try &.dig?("runs", 0) - - genre = contents.try &.["text"]? - genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId") - elsif metadata_title == "License" - license = contents.try &.dig?("runs", 0, "text") - elsif metadata_title == "Licensed to YouTube by" - license = contents.try &.["simpleText"]? - end + {% if flag?(:debug_macros) %} {{debug}} {% end %} end - # Author infos + # Method definitions, using the macros above - if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") - author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url") - author_verified = has_verified_badge?(author_info["badges"]?) + getset_string author + getset_string authorThumbnail + getset_string description + getset_string descriptionHtml + getset_string genre + getset_string genreUcid + getset_string license + getset_string shortDescription + getset_string subCountText + getset_string title + getset_string ucid - subs_text = author_info["subscriberCountText"]? - .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") } - .try &.as_s.split(" ", 2)[0] - end + getset_string_array allowedRegions + getset_string_array keywords - # Return data - - params = { - "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), - "relatedVideos" => JSON::Any.new(related), - "likes" => JSON::Any.new(likes || 0_i64), - "dislikes" => JSON::Any.new(0_i64), - "descriptionHtml" => JSON::Any.new(description_html || "<p></p>"), - "genre" => JSON::Any.new(genre.try &.as_s || ""), - "genreUrl" => JSON::Any.new(nil), - "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""), - "license" => JSON::Any.new(license.try &.as_s || ""), - "authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""), - "authorVerified" => JSON::Any.new(author_verified), - "subCountText" => JSON::Any.new(subs_text || "-"), - } + getset_i32 lengthSeconds + getset_i64 likes + getset_i64 views - return params + getset_bool allowRatings + getset_bool authorVerified + getset_bool isFamilyFriendly + getset_bool isListed + getset_bool isUpcoming end def get_video(id, refresh = true, region = nil, force_refresh = false) @@ -1098,7 +340,8 @@ def get_video(id, refresh = true, region = nil, force_refresh = false) if (refresh && (Time.utc - video.updated > 10.minutes) || (video.premiere_timestamp.try &.< Time.utc)) || - force_refresh + force_refresh || + video.schema_version != Video::SCHEMA_VERSION # cache control begin video = fetch_video(id, region) Invidious::Database::Videos.update(video) @@ -1137,12 +380,6 @@ def fetch_video(id, region) end end - # Try to fetch video info using an embedded client - if info["reason"]? - embed_info = extract_video_info(video_id: id, context_screen: "embed") - info = embed_info if !embed_info["reason"]? - end - if reason = info["reason"]? if reason == "Video unavailable" raise NotFoundException.new(reason.as_s || "") @@ -1160,10 +397,6 @@ def fetch_video(id, region) return video end -def itag_to_metadata?(itag : JSON::Any) - return VIDEO_FORMATS[itag.to_s]? -end - def process_continuation(query, plid, id) continuation = nil if plid @@ -1178,135 +411,6 @@ def process_continuation(query, plid, id) continuation end -def process_video_params(query, preferences) - annotations = query["iv_load_policy"]?.try &.to_i? - autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } - comments = query["comments"]?.try &.split(",").map(&.downcase) - continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe } - continue_autoplay = query["continue_autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } - listen = query["listen"]?.try { |q| (q == "true" || q == "1").to_unsafe } - local = query["local"]?.try { |q| (q == "true" || q == "1").to_unsafe } - player_style = query["player_style"]? - preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase) - quality = query["quality"]? - quality_dash = query["quality_dash"]? - region = query["region"]? - related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe } - speed = query["speed"]?.try &.rchop("x").to_f? - video_loop = query["loop"]?.try { |q| (q == "true" || q == "1").to_unsafe } - extend_desc = query["extend_desc"]?.try { |q| (q == "true" || q == "1").to_unsafe } - volume = query["volume"]?.try &.to_i? - vr_mode = query["vr_mode"]?.try { |q| (q == "true" || q == "1").to_unsafe } - save_player_pos = query["save_player_pos"]?.try { |q| (q == "true" || q == "1").to_unsafe } - - if preferences - # region ||= preferences.region - annotations ||= preferences.annotations.to_unsafe - autoplay ||= preferences.autoplay.to_unsafe - comments ||= preferences.comments - continue ||= preferences.continue.to_unsafe - continue_autoplay ||= preferences.continue_autoplay.to_unsafe - listen ||= preferences.listen.to_unsafe - local ||= preferences.local.to_unsafe - player_style ||= preferences.player_style - preferred_captions ||= preferences.captions - quality ||= preferences.quality - quality_dash ||= preferences.quality_dash - related_videos ||= preferences.related_videos.to_unsafe - speed ||= preferences.speed - video_loop ||= preferences.video_loop.to_unsafe - extend_desc ||= preferences.extend_desc.to_unsafe - volume ||= preferences.volume - vr_mode ||= preferences.vr_mode.to_unsafe - save_player_pos ||= preferences.save_player_pos.to_unsafe - end - - annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe - autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe - comments ||= CONFIG.default_user_preferences.comments - continue ||= CONFIG.default_user_preferences.continue.to_unsafe - continue_autoplay ||= CONFIG.default_user_preferences.continue_autoplay.to_unsafe - listen ||= CONFIG.default_user_preferences.listen.to_unsafe - local ||= CONFIG.default_user_preferences.local.to_unsafe - player_style ||= CONFIG.default_user_preferences.player_style - preferred_captions ||= CONFIG.default_user_preferences.captions - quality ||= CONFIG.default_user_preferences.quality - quality_dash ||= CONFIG.default_user_preferences.quality_dash - related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe - speed ||= CONFIG.default_user_preferences.speed - video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe - extend_desc ||= CONFIG.default_user_preferences.extend_desc.to_unsafe - volume ||= CONFIG.default_user_preferences.volume - vr_mode ||= CONFIG.default_user_preferences.vr_mode.to_unsafe - save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe - - annotations = annotations == 1 - autoplay = autoplay == 1 - continue = continue == 1 - continue_autoplay = continue_autoplay == 1 - listen = listen == 1 - local = local == 1 - related_videos = related_videos == 1 - video_loop = video_loop == 1 - extend_desc = extend_desc == 1 - vr_mode = vr_mode == 1 - save_player_pos = save_player_pos == 1 - - if CONFIG.disabled?("dash") && quality == "dash" - quality = "high" - end - - if CONFIG.disabled?("local") && local - local = false - end - - if start = query["t"]? || query["time_continue"]? || query["start"]? - video_start = decode_time(start) - end - video_start ||= 0 - - if query["end"]? - video_end = decode_time(query["end"]) - end - video_end ||= -1 - - raw = query["raw"]?.try &.to_i? - raw ||= 0 - raw = raw == 1 - - controls = query["controls"]?.try &.to_i? - controls ||= 1 - controls = controls >= 1 - - params = VideoPreferences.new({ - annotations: annotations, - autoplay: autoplay, - comments: comments, - continue: continue, - continue_autoplay: continue_autoplay, - controls: controls, - listen: listen, - local: local, - player_style: player_style, - preferred_captions: preferred_captions, - quality: quality, - quality_dash: quality_dash, - raw: raw, - region: region, - related_videos: related_videos, - speed: speed, - video_end: video_end, - video_loop: video_loop, - extend_desc: extend_desc, - video_start: video_start, - volume: volume, - vr_mode: vr_mode, - save_player_pos: save_player_pos, - }) - - return params -end - def build_thumbnails(id) return { {host: HOST_URL, height: 720, width: 1280, name: "maxres", url: "maxres"}, @@ -1320,34 +424,3 @@ def build_thumbnails(id) {host: HOST_URL, height: 90, width: 120, name: "end", url: "3"}, } end - -def generate_thumbnails(json, id) - json.array do - build_thumbnails(id).each do |thumbnail| - json.object do - json.field "quality", thumbnail[:name] - json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg" - json.field "width", thumbnail[:width] - json.field "height", thumbnail[:height] - end - end - end -end - -def generate_storyboards(json, id, storyboards) - json.array do - storyboards.each do |storyboard| - json.object do - json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}" - json.field "templateUrl", storyboard[:url] - json.field "width", storyboard[:width] - json.field "height", storyboard[:height] - json.field "count", storyboard[:count] - json.field "interval", storyboard[:interval] - json.field "storyboardWidth", storyboard[:storyboard_width] - json.field "storyboardHeight", storyboard[:storyboard_height] - json.field "storyboardCount", storyboard[:storyboard_count] - end - end - end -end diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr new file mode 100644 index 00000000..4642c1a7 --- /dev/null +++ b/src/invidious/videos/caption.cr @@ -0,0 +1,168 @@ +require "json" + +module Invidious::Videos + struct Caption + property name : String + property language_code : String + property base_url : String + + def initialize(@name, @language_code, @base_url) + end + + # Parse the JSON structure from Youtube + def self.from_yt_json(container : JSON::Any) : Array(Caption) + caption_tracks = container + .dig?("playerCaptionsTracklistRenderer", "captionTracks") + .try &.as_a + + captions_list = [] of Caption + return captions_list if caption_tracks.nil? + + caption_tracks.each do |caption| + name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"] + name = name.to_s.split(" - ")[0] + + language_code = caption["languageCode"].to_s + base_url = caption["baseUrl"].to_s + + captions_list << Caption.new(name, language_code, base_url) + end + + return captions_list + end + + # List of all caption languages available on Youtube. + LANGUAGES = { + "", + "English", + "English (auto-generated)", + "English (United Kingdom)", + "English (United States)", + "Afrikaans", + "Albanian", + "Amharic", + "Arabic", + "Armenian", + "Azerbaijani", + "Bangla", + "Basque", + "Belarusian", + "Bosnian", + "Bulgarian", + "Burmese", + "Cantonese (Hong Kong)", + "Catalan", + "Cebuano", + "Chinese", + "Chinese (China)", + "Chinese (Hong Kong)", + "Chinese (Simplified)", + "Chinese (Taiwan)", + "Chinese (Traditional)", + "Corsican", + "Croatian", + "Czech", + "Danish", + "Dutch", + "Dutch (auto-generated)", + "Esperanto", + "Estonian", + "Filipino", + "Finnish", + "French", + "French (auto-generated)", + "Galician", + "Georgian", + "German", + "German (auto-generated)", + "Greek", + "Gujarati", + "Haitian Creole", + "Hausa", + "Hawaiian", + "Hebrew", + "Hindi", + "Hmong", + "Hungarian", + "Icelandic", + "Igbo", + "Indonesian", + "Indonesian (auto-generated)", + "Interlingue", + "Irish", + "Italian", + "Italian (auto-generated)", + "Japanese", + "Japanese (auto-generated)", + "Javanese", + "Kannada", + "Kazakh", + "Khmer", + "Korean", + "Korean (auto-generated)", + "Kurdish", + "Kyrgyz", + "Lao", + "Latin", + "Latvian", + "Lithuanian", + "Luxembourgish", + "Macedonian", + "Malagasy", + "Malay", + "Malayalam", + "Maltese", + "Maori", + "Marathi", + "Mongolian", + "Nepali", + "Norwegian Bokmål", + "Nyanja", + "Pashto", + "Persian", + "Polish", + "Portuguese", + "Portuguese (auto-generated)", + "Portuguese (Brazil)", + "Punjabi", + "Romanian", + "Russian", + "Russian (auto-generated)", + "Samoan", + "Scottish Gaelic", + "Serbian", + "Shona", + "Sindhi", + "Sinhala", + "Slovak", + "Slovenian", + "Somali", + "Southern Sotho", + "Spanish", + "Spanish (auto-generated)", + "Spanish (Latin America)", + "Spanish (Mexico)", + "Spanish (Spain)", + "Sundanese", + "Swahili", + "Swedish", + "Tajik", + "Tamil", + "Telugu", + "Thai", + "Turkish", + "Turkish (auto-generated)", + "Ukrainian", + "Urdu", + "Uzbek", + "Vietnamese", + "Vietnamese (auto-generated)", + "Welsh", + "Western Frisian", + "Xhosa", + "Yiddish", + "Yoruba", + "Zulu", + } + end +end diff --git a/src/invidious/videos/formats.cr b/src/invidious/videos/formats.cr new file mode 100644 index 00000000..e98e7257 --- /dev/null +++ b/src/invidious/videos/formats.cr @@ -0,0 +1,116 @@ +module Invidious::Videos::Formats + def self.itag_to_metadata?(itag : JSON::Any) + return FORMATS[itag.to_s]? + end + + # See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476 + private FORMATS = { + "5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, + "6" => {"ext" => "flv", "width" => 450, "height" => 270, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, + "13" => {"ext" => "3gp", "acodec" => "aac", "vcodec" => "mp4v"}, + "17" => {"ext" => "3gp", "width" => 176, "height" => 144, "acodec" => "aac", "abr" => 24, "vcodec" => "mp4v"}, + "18" => {"ext" => "mp4", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 96, "vcodec" => "h264"}, + "22" => {"ext" => "mp4", "width" => 1280, "height" => 720, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "34" => {"ext" => "flv", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "35" => {"ext" => "flv", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + + "36" => {"ext" => "3gp", "width" => 320, "acodec" => "aac", "vcodec" => "mp4v"}, + "37" => {"ext" => "mp4", "width" => 1920, "height" => 1080, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "38" => {"ext" => "mp4", "width" => 4096, "height" => 3072, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "43" => {"ext" => "webm", "width" => 640, "height" => 360, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, + "44" => {"ext" => "webm", "width" => 854, "height" => 480, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, + "45" => {"ext" => "webm", "width" => 1280, "height" => 720, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + "46" => {"ext" => "webm", "width" => 1920, "height" => 1080, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + "59" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "78" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + + # 3D videos + "82" => {"ext" => "mp4", "height" => 360, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "83" => {"ext" => "mp4", "height" => 480, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "84" => {"ext" => "mp4", "height" => 720, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "85" => {"ext" => "mp4", "height" => 1080, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "100" => {"ext" => "webm", "height" => 360, "format" => "3D", "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, + "101" => {"ext" => "webm", "height" => 480, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + "102" => {"ext" => "webm", "height" => 720, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + + # Apple HTTP Live Streaming + "91" => {"ext" => "mp4", "height" => 144, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, + "92" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, + "93" => {"ext" => "mp4", "height" => 360, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "94" => {"ext" => "mp4", "height" => 480, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "95" => {"ext" => "mp4", "height" => 720, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, + "96" => {"ext" => "mp4", "height" => 1080, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, + "132" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, + "151" => {"ext" => "mp4", "height" => 72, "format" => "HLS", "acodec" => "aac", "abr" => 24, "vcodec" => "h264"}, + + # DASH mp4 video + "133" => {"ext" => "mp4", "height" => 240, "format" => "DASH video", "vcodec" => "h264"}, + "134" => {"ext" => "mp4", "height" => 360, "format" => "DASH video", "vcodec" => "h264"}, + "135" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, + "136" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264"}, + "137" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264"}, + "138" => {"ext" => "mp4", "format" => "DASH video", "vcodec" => "h264"}, # Height can vary (https://github.com/ytdl-org/youtube-dl/issues/4559) + "160" => {"ext" => "mp4", "height" => 144, "format" => "DASH video", "vcodec" => "h264"}, + "212" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, + "264" => {"ext" => "mp4", "height" => 1440, "format" => "DASH video", "vcodec" => "h264"}, + "298" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, + "299" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, + "266" => {"ext" => "mp4", "height" => 2160, "format" => "DASH video", "vcodec" => "h264"}, + + # Dash mp4 audio + "139" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 48, "container" => "m4a_dash"}, + "140" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 128, "container" => "m4a_dash"}, + "141" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 256, "container" => "m4a_dash"}, + "256" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, + "258" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, + "325" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "dtse", "container" => "m4a_dash"}, + "328" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "ec-3", "container" => "m4a_dash"}, + + # Dash webm + "167" => {"ext" => "webm", "height" => 360, "width" => 640, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "168" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "169" => {"ext" => "webm", "height" => 720, "width" => 1280, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "170" => {"ext" => "webm", "height" => 1080, "width" => 1920, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "218" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "219" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "278" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "container" => "webm", "vcodec" => "vp9"}, + "242" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9"}, + "243" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9"}, + "244" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, + "245" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, + "246" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, + "247" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9"}, + "248" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9"}, + "271" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9"}, + # itag 272 videos are either 3840x2160 (e.g. RtoitU2A-3E) or 7680x4320 (sLprVF6d7Ug) + "272" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, + "302" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "303" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "308" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "313" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, + "315" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "330" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "331" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "332" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "333" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "334" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "335" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "336" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "337" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + + # Dash webm audio + "171" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 128}, + "172" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 256}, + + # Dash webm audio with opus inside + "249" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 50}, + "250" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 70}, + "251" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 160}, + + # av01 video only formats sometimes served with "unknown" codecs + "394" => {"ext" => "mp4", "height" => 144, "vcodec" => "av01.0.05M.08"}, + "395" => {"ext" => "mp4", "height" => 240, "vcodec" => "av01.0.05M.08"}, + "396" => {"ext" => "mp4", "height" => 360, "vcodec" => "av01.0.05M.08"}, + "397" => {"ext" => "mp4", "height" => 480, "vcodec" => "av01.0.05M.08"}, + } +end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr new file mode 100644 index 00000000..5df49286 --- /dev/null +++ b/src/invidious/videos/parser.cr @@ -0,0 +1,371 @@ +require "json" + +# Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer". +# The former is preferred as it has more videos in it. The second has +# the same 11 first entries as the compact rendered. +# +# TODO: "compactRadioRenderer" (Mix) and +# TODO: Use a proper struct/class instead of a hacky JSON object +def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? + return nil if !related["videoId"]? + + # The compact renderer has video length in seconds, where the end + # screen rendered has a full text version ("42:40") + length = related["lengthInSeconds"]?.try &.as_i.to_s + length ||= related.dig?("lengthText", "simpleText").try do |box| + decode_length_seconds(box.as_s).to_s + end + + # Both have "short", so the "long" option shouldn't be required + channel_info = (related["shortBylineText"]? || related["longBylineText"]?) + .try &.dig?("runs", 0) + + author = channel_info.try &.dig?("text") + author_verified = has_verified_badge?(related["ownerBadges"]?).to_s + + ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } + + # "4,088,033 views", only available on compact renderer + # and when video is not a livestream + view_count = related.dig?("viewCountText", "simpleText") + .try &.as_s.gsub(/\D/, "") + + short_view_count = related.try do |r| + HelperExtractors.get_short_view_count(r).to_s + end + + LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container") + + # TODO: when refactoring video types, make a struct for related videos + # or reuse an existing type, if that fits. + return { + "id" => related["videoId"], + "title" => related["title"]["simpleText"], + "author" => author || JSON::Any.new(""), + "ucid" => JSON::Any.new(ucid || ""), + "length_seconds" => JSON::Any.new(length || "0"), + "view_count" => JSON::Any.new(view_count || "0"), + "short_view_count" => JSON::Any.new(short_view_count || "0"), + "author_verified" => JSON::Any.new(author_verified), + } +end + +def extract_video_info(video_id : String, proxy_region : String? = nil) + # Init client config for the API + client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region) + + # Fetch data from the player endpoint + # 8AEB param is used to fetch YouTube stories + player_response = YoutubeAPI.player(video_id: video_id, params: "8AEB", client_config: client_config) + + playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s + + if playability_status != "OK" + subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason") + reason = subreason.try &.[]?("simpleText").try &.as_s + reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("") + reason ||= player_response.dig("playabilityStatus", "reason").as_s + + # Stop here if video is not a scheduled livestream + if !{"LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) + return { + "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), + "reason" => JSON::Any.new(reason), + } + end + elsif video_id != player_response.dig("videoDetails", "videoId") + # YouTube may return a different video player response than expected. + # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 + raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (WEB client)") + else + reason = nil + end + + # Don't fetch the next endpoint if the video is unavailable. + if {"OK", "LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) + next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) + player_response = player_response.merge(next_response) + end + + params = parse_video_info(video_id, player_response) + params["reason"] = JSON::Any.new(reason) if reason + + new_player_response = nil + + if reason.nil? + # Fetch the video streams using an Android client in order to get the + # decrypted URLs and maybe fix throttling issues (#2194). See the + # following issue for an explanation about decrypted URLs: + # https://github.com/TeamNewPipe/NewPipeExtractor/issues/562 + client_config.client_type = YoutubeAPI::ClientType::Android + new_player_response = try_fetch_streaming_data(video_id, client_config) + elsif !reason.includes?("your country") # Handled separately + # The Android embedded client could help here + client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed + new_player_response = try_fetch_streaming_data(video_id, client_config) + end + + # Last hope + if new_player_response.nil? + client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed + new_player_response = try_fetch_streaming_data(video_id, client_config) + end + + # Replace player response and reset reason + if !new_player_response.nil? + player_response = new_player_response + params.delete("reason") + end + + {"captions", "playabilityStatus", "playerConfig", "storyboards", "streamingData"}.each do |f| + params[f] = player_response[f] if player_response[f]? + end + + # Data structure version, for cache control + params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64) + + return params +end + +def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)? + LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.") + # 8AEB param is used to fetch YouTube stories + response = YoutubeAPI.player(video_id: id, params: "8AEB", client_config: client_config) + + playability_status = response["playabilityStatus"]["status"] + LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.") + + if id != response.dig("videoDetails", "videoId") + # YouTube may return a different video player response than expected. + # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 + raise VideoNotAvailableException.new( + "The video returned by YouTube isn't the requested one. (#{client_config.client_type} client)" + ) + elsif playability_status == "OK" + return response + else + return nil + end +end + +def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any) + # Top level elements + + main_results = player_response.dig?("contents", "twoColumnWatchNextResults") + + raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results + + # Primary results are not available on Music videos + # See: https://github.com/iv-org/invidious/pull/3238#issuecomment-1207193725 + if primary_results = main_results.dig?("results", "results", "contents") + video_primary_renderer = primary_results + .as_a.find(&.["videoPrimaryInfoRenderer"]?) + .try &.["videoPrimaryInfoRenderer"] + + video_secondary_renderer = primary_results + .as_a.find(&.["videoSecondaryInfoRenderer"]?) + .try &.["videoSecondaryInfoRenderer"] + + raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer + raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer + end + + video_details = player_response.dig?("videoDetails") + microformat = player_response.dig?("microformat", "playerMicroformatRenderer") + + raise BrokenTubeException.new("videoDetails") if !video_details + raise BrokenTubeException.new("microformat") if !microformat + + # Basic video infos + + title = video_details["title"]?.try &.as_s + + # We have to try to extract viewCount from videoPrimaryInfoRenderer first, + # then from videoDetails, as the latter is "0" for livestreams (we want + # to get the amount of viewers watching). + views_txt = video_primary_renderer + .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount", "runs", 0, "text") + views_txt ||= video_details["viewCount"]? + views = views_txt.try &.as_s.gsub(/\D/, "").to_i64? + + length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"]) + .try &.as_s.to_i64 + + published = microformat["publishDate"]? + .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc + + premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp") + .try { |t| Time.parse_rfc3339(t.as_s) } + + live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow") + .try &.as_bool || false + + # Extra video infos + + allowed_regions = microformat["availableCountries"]? + .try &.as_a.map &.as_s || [] of String + + allow_ratings = video_details["allowRatings"]?.try &.as_bool + family_friendly = microformat["isFamilySafe"].try &.as_bool + is_listed = video_details["isCrawlable"]?.try &.as_bool + is_upcoming = video_details["isUpcoming"]?.try &.as_bool + + keywords = video_details["keywords"]? + .try &.as_a.map &.as_s || [] of String + + # Related videos + + LOGGER.debug("extract_video_info: parsing related videos...") + + related = [] of JSON::Any + + # Parse "compactVideoRenderer" items (under secondary results) + secondary_results = main_results + .dig?("secondaryResults", "secondaryResults", "results") + secondary_results.try &.as_a.each do |element| + if item = element["compactVideoRenderer"]? + related_video = parse_related_video(item) + related << JSON::Any.new(related_video) if related_video + end + end + + # If nothing was found previously, fall back to end screen renderer + if related.empty? + # Container for "endScreenVideoRenderer" items + player_overlays = player_response.dig?( + "playerOverlays", "playerOverlayRenderer", + "endScreen", "watchNextEndScreenRenderer", "results" + ) + + player_overlays.try &.as_a.each do |element| + if item = element["endScreenVideoRenderer"]? + related_video = parse_related_video(item) + related << JSON::Any.new(related_video) if related_video + end + end + end + + # Likes + + toplevel_buttons = video_primary_renderer + .try &.dig?("videoActions", "menuRenderer", "topLevelButtons") + + if toplevel_buttons + likes_button = toplevel_buttons.try &.as_a + .find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE") + .try &.["toggleButtonRenderer"] + + # New format as of september 2022 + likes_button ||= toplevel_buttons.try &.as_a + .find(&.["segmentedLikeDislikeButtonRenderer"]?) + .try &.dig?( + "segmentedLikeDislikeButtonRenderer", + "likeButton", "toggleButtonRenderer" + ) + + if likes_button + # Note: The like count from `toggledText` is off by one, as it would + # represent the new like count in the event where the user clicks on "like". + likes_txt = (likes_button["defaultText"]? || likes_button["toggledText"]?) + .try &.dig?("accessibility", "accessibilityData", "label") + likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt + + LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"") + LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes + end + end + + # Description + + description = microformat.dig?("description", "simpleText").try &.as_s || "" + short_description = player_response.dig?("videoDetails", "shortDescription") + + description_html = video_secondary_renderer.try &.dig?("description", "runs") + .try &.as_a.try { |t| content_to_comment_html(t, video_id) } + + # Video metadata + + metadata = video_secondary_renderer + .try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows") + .try &.as_a + + genre = microformat["category"]? + genre_ucid = nil + license = nil + + metadata.try &.each do |row| + metadata_title = extract_text(row.dig?("metadataRowRenderer", "title")) + contents = row.dig?("metadataRowRenderer", "contents", 0) + + if metadata_title == "Category" + contents = contents.try &.dig?("runs", 0) + + genre = contents.try &.["text"]? + genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId") + elsif metadata_title == "License" + license = contents.try &.dig?("runs", 0, "text") + elsif metadata_title == "Licensed to YouTube by" + license = contents.try &.["simpleText"]? + end + end + + # Author infos + + author = video_details["author"]?.try &.as_s + ucid = video_details["channelId"]?.try &.as_s + + if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") + author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url") + author_verified = has_verified_badge?(author_info["badges"]?) + + subs_text = author_info["subscriberCountText"]? + .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") } + .try &.as_s.split(" ", 2)[0] + end + + # Return data + + if live_now + video_type = VideoType::Livestream + elsif !premiere_timestamp.nil? + video_type = VideoType::Scheduled + published = premiere_timestamp || Time.utc + else + video_type = VideoType::Video + end + + params = { + "videoType" => JSON::Any.new(video_type.to_s), + # Basic video infos + "title" => JSON::Any.new(title || ""), + "views" => JSON::Any.new(views || 0_i64), + "likes" => JSON::Any.new(likes || 0_i64), + "lengthSeconds" => JSON::Any.new(length_txt || 0_i64), + "published" => JSON::Any.new(published.to_rfc3339), + # Extra video infos + "allowedRegions" => JSON::Any.new(allowed_regions.map { |v| JSON::Any.new(v) }), + "allowRatings" => JSON::Any.new(allow_ratings || false), + "isFamilyFriendly" => JSON::Any.new(family_friendly || false), + "isListed" => JSON::Any.new(is_listed || false), + "isUpcoming" => JSON::Any.new(is_upcoming || false), + "keywords" => JSON::Any.new(keywords.map { |v| JSON::Any.new(v) }), + # Related videos + "relatedVideos" => JSON::Any.new(related), + # Description + "description" => JSON::Any.new(description || ""), + "descriptionHtml" => JSON::Any.new(description_html || "<p></p>"), + "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), + # Video metadata + "genre" => JSON::Any.new(genre.try &.as_s || ""), + "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""), + "license" => JSON::Any.new(license.try &.as_s || ""), + # Author infos + "author" => JSON::Any.new(author || ""), + "ucid" => JSON::Any.new(ucid || ""), + "authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""), + "authorVerified" => JSON::Any.new(author_verified || false), + "subCountText" => JSON::Any.new(subs_text || "-"), + } + + return params +end diff --git a/src/invidious/videos/regions.cr b/src/invidious/videos/regions.cr new file mode 100644 index 00000000..575f8c25 --- /dev/null +++ b/src/invidious/videos/regions.cr @@ -0,0 +1,27 @@ +# List of geographical regions that Youtube recognizes. +# This is used to determine if a video is either restricted to a list +# of allowed regions (= whitelisted) or if it can't be watched in +# a set of regions (= blacklisted). +REGIONS = { + "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", + "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", + "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", + "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", + "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", + "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", + "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", + "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", + "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", + "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", + "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", + "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", + "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", + "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", + "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", + "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", + "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", + "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", + "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", + "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", + "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW", +} diff --git a/src/invidious/videos/video_preferences.cr b/src/invidious/videos/video_preferences.cr new file mode 100644 index 00000000..34cf7ff0 --- /dev/null +++ b/src/invidious/videos/video_preferences.cr @@ -0,0 +1,156 @@ +struct VideoPreferences + include JSON::Serializable + + property annotations : Bool + property autoplay : Bool + property comments : Array(String) + property continue : Bool + property continue_autoplay : Bool + property controls : Bool + property listen : Bool + property local : Bool + property preferred_captions : Array(String) + property player_style : String + property quality : String + property quality_dash : String + property raw : Bool + property region : String? + property related_videos : Bool + property speed : Float32 | Float64 + property video_end : Float64 | Int32 + property video_loop : Bool + property extend_desc : Bool + property video_start : Float64 | Int32 + property volume : Int32 + property vr_mode : Bool + property save_player_pos : Bool +end + +def process_video_params(query, preferences) + annotations = query["iv_load_policy"]?.try &.to_i? + autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } + comments = query["comments"]?.try &.split(",").map(&.downcase) + continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe } + continue_autoplay = query["continue_autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } + listen = query["listen"]?.try { |q| (q == "true" || q == "1").to_unsafe } + local = query["local"]?.try { |q| (q == "true" || q == "1").to_unsafe } + player_style = query["player_style"]? + preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase) + quality = query["quality"]? + quality_dash = query["quality_dash"]? + region = query["region"]? + related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe } + speed = query["speed"]?.try &.rchop("x").to_f? + video_loop = query["loop"]?.try { |q| (q == "true" || q == "1").to_unsafe } + extend_desc = query["extend_desc"]?.try { |q| (q == "true" || q == "1").to_unsafe } + volume = query["volume"]?.try &.to_i? + vr_mode = query["vr_mode"]?.try { |q| (q == "true" || q == "1").to_unsafe } + save_player_pos = query["save_player_pos"]?.try { |q| (q == "true" || q == "1").to_unsafe } + + if preferences + # region ||= preferences.region + annotations ||= preferences.annotations.to_unsafe + autoplay ||= preferences.autoplay.to_unsafe + comments ||= preferences.comments + continue ||= preferences.continue.to_unsafe + continue_autoplay ||= preferences.continue_autoplay.to_unsafe + listen ||= preferences.listen.to_unsafe + local ||= preferences.local.to_unsafe + player_style ||= preferences.player_style + preferred_captions ||= preferences.captions + quality ||= preferences.quality + quality_dash ||= preferences.quality_dash + related_videos ||= preferences.related_videos.to_unsafe + speed ||= preferences.speed + video_loop ||= preferences.video_loop.to_unsafe + extend_desc ||= preferences.extend_desc.to_unsafe + volume ||= preferences.volume + vr_mode ||= preferences.vr_mode.to_unsafe + save_player_pos ||= preferences.save_player_pos.to_unsafe + end + + annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe + autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe + comments ||= CONFIG.default_user_preferences.comments + continue ||= CONFIG.default_user_preferences.continue.to_unsafe + continue_autoplay ||= CONFIG.default_user_preferences.continue_autoplay.to_unsafe + listen ||= CONFIG.default_user_preferences.listen.to_unsafe + local ||= CONFIG.default_user_preferences.local.to_unsafe + player_style ||= CONFIG.default_user_preferences.player_style + preferred_captions ||= CONFIG.default_user_preferences.captions + quality ||= CONFIG.default_user_preferences.quality + quality_dash ||= CONFIG.default_user_preferences.quality_dash + related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe + speed ||= CONFIG.default_user_preferences.speed + video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe + extend_desc ||= CONFIG.default_user_preferences.extend_desc.to_unsafe + volume ||= CONFIG.default_user_preferences.volume + vr_mode ||= CONFIG.default_user_preferences.vr_mode.to_unsafe + save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe + + annotations = annotations == 1 + autoplay = autoplay == 1 + continue = continue == 1 + continue_autoplay = continue_autoplay == 1 + listen = listen == 1 + local = local == 1 + related_videos = related_videos == 1 + video_loop = video_loop == 1 + extend_desc = extend_desc == 1 + vr_mode = vr_mode == 1 + save_player_pos = save_player_pos == 1 + + if CONFIG.disabled?("dash") && quality == "dash" + quality = "high" + end + + if CONFIG.disabled?("local") && local + local = false + end + + if start = query["t"]? || query["time_continue"]? || query["start"]? + video_start = decode_time(start) + end + video_start ||= 0 + + if query["end"]? + video_end = decode_time(query["end"]) + end + video_end ||= -1 + + raw = query["raw"]?.try &.to_i? + raw ||= 0 + raw = raw == 1 + + controls = query["controls"]?.try &.to_i? + controls ||= 1 + controls = controls >= 1 + + params = VideoPreferences.new({ + annotations: annotations, + autoplay: autoplay, + comments: comments, + continue: continue, + continue_autoplay: continue_autoplay, + controls: controls, + listen: listen, + local: local, + player_style: player_style, + preferred_captions: preferred_captions, + quality: quality, + quality_dash: quality_dash, + raw: raw, + region: region, + related_videos: related_videos, + speed: speed, + video_end: video_end, + video_loop: video_loop, + extend_desc: extend_desc, + video_start: video_start, + volume: volume, + vr_mode: vr_mode, + save_player_pos: save_player_pos, + }) + + return params +end diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 92f81ee4..dea86abe 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -1,7 +1,20 @@ <% ucid = channel.ucid %> <% author = HTML.escape(channel.author) %> +<% channel_profile_pic = URI.parse(channel.author_thumbnail).request_target %> <% content_for "header" do %> +<meta name="description" content="<%= channel.description %>"> +<meta property="og:site_name" content="Invidious"> +<meta property="og:url" content="<%= HOST_URL %>/channel/<%= ucid %>"> +<meta property="og:title" content="<%= author %>"> +<meta property="og:image" content="/ggpht<%= channel_profile_pic %>"> +<meta property="og:description" content="<%= channel.description %>"> +<meta name="twitter:card" content="summary"> +<meta name="twitter:url" content="<%= HOST_URL %>/channel/<%= ucid %>"> +<meta name="twitter:title" content="<%= author %>"> +<meta name="twitter:description" content="<%= channel.description %>"> +<meta name="twitter:image" content="/ggpht<%= channel_profile_pic %>"> +<link rel="alternate" href="https://www.youtube.com/channel/<%= ucid %>"> <title><%= author %> - Invidious</title> <link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= ucid %>" /> <% end %> @@ -19,7 +32,7 @@ <div class="pure-g h-box"> <div class="pure-u-2-3"> <div class="channel-profile"> - <img src="/ggpht<%= URI.parse(channel.author_thumbnail).request_target %>"> + <img src="/ggpht<%= channel_profile_pic %>"> <span><%= author %></span><% if !channel.verified.nil? && channel.verified %> <i class="icon ion ion-md-checkmark-circle"></i><% end %> </div> </div> diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr index 25b24ed4..667cfa37 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -25,6 +25,20 @@ <tr> <td> + <a href="/js/handlers.js?v=<%= ASSET_COMMIT %>">handlers.js</a> + </td> + + <td> + <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a> + </td> + + <td> + <a href="/js/handlers.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a> + </td> + </tr> + + <tr> + <td> <a href="/js/community.js?v=<%= ASSET_COMMIT %>">community.js</a> </td> @@ -169,7 +183,7 @@ </td> <td> - <a href="https://choosealicense.com/licenses/mit/">MIT</a> + <a href="https://choosealicense.com/licenses/mit/">Expat</a> </td> <td> @@ -253,7 +267,7 @@ </td> <td> - <a href="https://choosealicense.com/licenses/mit">MIT</a> + <a href="https://choosealicense.com/licenses/mit">Expat</a> </td> <td> diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index caf5299f..98f72eba 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -67,7 +67,7 @@ </a> </div> <% if env.get("preferences").as(Preferences).show_nick %> - <div class="pure-u-1-4"> + <div class="pure-u-1-4" style="overflow: hidden; white-space: nowrap;"> <span id="user_name"><%= HTML.escape(env.get("user").as(Invidious::User).email) %></span> </div> <% end %> diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr index dbb5e9db..d841982c 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -89,7 +89,7 @@ <label for="captions[0]"><%= translate(locale, "preferences_captions_label") %></label> <% preferences.captions.each_with_index do |caption, index| %> <select class="pure-u-1-6" name="captions[<%= index %>]" id="captions[<%= index %>]"> - <% CAPTION_LANGUAGES.each do |option| %> + <% Invidious::Videos::Caption::LANGUAGES.each do |option| %> <option value="<%= option %>" <% if preferences.captions[index] == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option> <% end %> </select> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 50c63d21..a6f2e524 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -7,7 +7,7 @@ <meta name="thumbnail" content="<%= thumbnail %>"> <meta name="description" content="<%= HTML.escape(video.short_description) %>"> <meta name="keywords" content="<%= video.keywords.join(",") %>"> -<meta property="og:site_name" content="Invidious"> +<meta property="og:site_name" content="<%= author %> | Invidious"> <meta property="og:url" content="<%= HOST_URL %>/watch?v=<%= video.id %>"> <meta property="og:title" content="<%= title %>"> <meta property="og:image" content="/vi/<%= video.id %>/maxres.jpg"> @@ -19,7 +19,6 @@ <meta property="og:video:width" content="1280"> <meta property="og:video:height" content="720"> <meta name="twitter:card" content="player"> -<meta name="twitter:site" content="@omarroth1"> <meta name="twitter:url" content="<%= HOST_URL %>/watch?v=<%= video.id %>"> <meta name="twitter:title" content="<%= title %>"> <meta name="twitter:description" content="<%= HTML.escape(video.short_description) %>"> @@ -270,7 +269,7 @@ we're going to need to do it here in order to allow for translations. <% video.related_videos.each do |rv| %> <% if rv["id"]? %> - <a href="/watch?v=<%= rv["id"] %>"> + <a href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>"> <% if !env.get("preferences").as(Preferences).thin_mode %> <div class="thumbnail"> <img loading="lazy" class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg"> diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index 3feb9233..46e5bf85 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -7,17 +7,16 @@ {% end %} def add_yt_headers(request) - request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36" - request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" - request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" - request.headers["accept-language"] ||= "en-us,en;q=0.5" - return if request.resource.starts_with? "/sorry/index" - request.headers["x-youtube-client-name"] ||= "1" - request.headers["x-youtube-client-version"] ||= "2.20200609" + if request.headers["User-Agent"] == "Crystal" + request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36" + end + request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" + request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" + request.headers["Accept-Language"] ||= "en-us,en;q=0.5" # Preserve original cookies and add new YT consent cookie for EU servers - request.headers["cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+" + request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+" if !CONFIG.cookies.empty? - request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" + request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" end end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index dc65cc52..edc722cf 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -17,6 +17,7 @@ private ITEM_PARSERS = { Parsers::PlaylistRendererParser, Parsers::CategoryRendererParser, Parsers::RichItemRendererParser, + Parsers::ReelItemRendererParser, } record AuthorFallback, name : String, id : String @@ -169,7 +170,7 @@ private module Parsers # Always simpleText # TODO change default value to nil subscriber_count = item_contents.dig?("subscriberCountText", "simpleText") - .try { |s| short_text_to_number(s.as_s.split(" ")[0]) } || 0 + .try { |s| short_text_to_number(s.as_s.split(" ")[0]).to_i32 } || 0 # Auto-generated channels doesn't have videoCountText # Taken from: https://github.com/iv-org/invidious/pull/2228#discussion_r717620922 @@ -369,7 +370,7 @@ private module Parsers end # Parses an InnerTube richItemRenderer into a SearchVideo. - # Returns nil when the given object isn't a shelfRenderer + # Returns nil when the given object isn't a RichItemRenderer # # A richItemRenderer seems to be a simple wrapper for a videoRenderer, used # by the result page for hashtags. It is located inside a continuationItems @@ -390,6 +391,90 @@ private module Parsers return {{@type.name}} end end + + # Parses an InnerTube reelItemRenderer into a SearchVideo. + # Returns nil when the given object isn't a reelItemRenderer + # + # reelItemRenderer items are used in the new (2022) channel layout, + # in the "shorts" tab. + # + module ReelItemRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["reelItemRenderer"]? + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + video_id = item_contents["videoId"].as_s + + video_details_container = item_contents.dig( + "navigationEndpoint", "reelWatchEndpoint", + "overlay", "reelPlayerOverlayRenderer", + "reelPlayerHeaderSupportedRenderers", + "reelPlayerHeaderRenderer" + ) + + # Author infos + + author = video_details_container + .dig?("channelTitleText", "runs", 0, "text") + .try &.as_s || author_fallback.name + + ucid = video_details_container + .dig?("channelNavigationEndpoint", "browseEndpoint", "browseId") + .try &.as_s || author_fallback.id + + # Title & publication date + + title = video_details_container.dig?("reelTitleText") + .try { |t| extract_text(t) } || "" + + published = video_details_container + .dig?("timestampText", "simpleText") + .try { |t| decode_date(t.as_s) } || Time.utc + + # View count + + view_count_text = video_details_container.dig?("viewCountText", "simpleText") + view_count_text ||= video_details_container + .dig?("viewCountText", "accessibility", "accessibilityData", "label") + + view_count = view_count_text.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 + + # Duration + + a11y_data = item_contents + .dig?("accessibility", "accessibilityData", "label") + .try &.as_s || "" + + regex_match = /- (?<min>\d+ minutes? )?(?<sec>\d+ seconds?)+ -/.match(a11y_data) + + minutes = regex_match.try &.["min"].to_i(strict: false) || 0 + seconds = regex_match.try &.["sec"].to_i(strict: false) || 0 + + duration = (minutes*60 + seconds) + + SearchVideo.new({ + title: title, + id: video_id, + author: author, + ucid: ucid, + published: published, + views: view_count, + description_html: "", + length_seconds: duration, + live_now: false, + premium: false, + premiere_timestamp: Time.unix(0), + author_verified: false, + }) + end + + def self.parser_name + return {{@type.name}} + end + end end # The following are the extractors for extracting an array of items from @@ -436,21 +521,31 @@ private module Extractors content = extract_selected_tab(target["tabs"])["content"] if section_list_contents = content.dig?("sectionListRenderer", "contents") - section_list_contents.as_a.each do |renderer_container| - renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0] - - # Category extraction - if items_container = renderer_container_contents["shelfRenderer"]? - raw_items << renderer_container_contents - next - elsif items_container = renderer_container_contents["gridRenderer"]? - else - items_container = renderer_container_contents - end + raw_items = unpack_section_list(section_list_contents) + elsif rich_grid_contents = content.dig?("richGridRenderer", "contents") + raw_items = rich_grid_contents.as_a + end - items_container["items"]?.try &.as_a.each do |item| - raw_items << item - end + return raw_items + end + + private def self.unpack_section_list(contents) + raw_items = [] of JSON::Any + + contents.as_a.each do |renderer_container| + renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0] + + # Category extraction + if items_container = renderer_container_contents["shelfRenderer"]? + raw_items << renderer_container_contents + next + elsif items_container = renderer_container_contents["gridRenderer"]? + else + items_container = renderer_container_contents + end + + items_container["items"]?.try &.as_a.each do |item| + raw_items << item end end @@ -525,14 +620,11 @@ private module Extractors end private def self.extract(target) - raw_items = [] of JSON::Any - if content = target["gridContinuation"]? - raw_items = content["items"].as_a - elsif content = target["continuationItems"]? - raw_items = content.as_a - end + content = target["continuationItems"]? + content ||= target.dig?("gridContinuation", "items") + content ||= target.dig?("richGridContinuation", "contents") - return raw_items + return content.nil? ? [] of JSON::Any : content.as_a end def self.extractor_name diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 30d7613b..91a9332c 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -7,9 +7,17 @@ module YoutubeAPI private DEFAULT_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" - private ANDROID_APP_VERSION = "17.29.35" - private ANDROID_SDK_VERSION = 30_i64 - private IOS_APP_VERSION = "17.30.1" + private ANDROID_APP_VERSION = "17.33.42" + # github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1308 + private ANDROID_USER_AGENT = "com.google.android.youtube/17.33.42 (Linux; U; Android 12; US) gzip" + private ANDROID_SDK_VERSION = 31_i64 + private ANDROID_VERSION = "12" + private IOS_APP_VERSION = "17.33.2" + # github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1330 + private IOS_USER_AGENT = "com.google.ios.youtube/17.33.2 (iPhone14,5; U; CPU iOS 15_6 like Mac OS X;)" + # github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1224 + private IOS_VERSION = "15.6.0.19G71" + private WINDOWS_VERSION = "10.0" # Enumerate used to select one of the clients supported by the API enum ClientType @@ -33,80 +41,130 @@ module YoutubeAPI # List of hard-coded values used by the different clients HARDCODED_CLIENTS = { ClientType::Web => { - name: "WEB", - version: "2.20220804.07.00", - api_key: DEFAULT_API_KEY, - screen: "WATCH_FULL_SCREEN", + name: "WEB", + name_proto: "1", + version: "2.20221118.01.00", + api_key: DEFAULT_API_KEY, + screen: "WATCH_FULL_SCREEN", + os_name: "Windows", + os_version: WINDOWS_VERSION, + platform: "DESKTOP", }, ClientType::WebEmbeddedPlayer => { - name: "WEB_EMBEDDED_PLAYER", # 56 - version: "1.20220803.01.00", - api_key: DEFAULT_API_KEY, - screen: "EMBED", + name: "WEB_EMBEDDED_PLAYER", + name_proto: "56", + version: "1.20220803.01.00", + api_key: DEFAULT_API_KEY, + screen: "EMBED", + os_name: "Windows", + os_version: WINDOWS_VERSION, + platform: "DESKTOP", }, ClientType::WebMobile => { - name: "MWEB", - version: "2.20220805.01.00", - api_key: DEFAULT_API_KEY, + name: "MWEB", + name_proto: "2", + version: "2.20220805.01.00", + api_key: DEFAULT_API_KEY, + os_name: "Android", + os_version: ANDROID_VERSION, + platform: "MOBILE", }, ClientType::WebScreenEmbed => { - name: "WEB", - version: "2.20220804.00.00", - api_key: DEFAULT_API_KEY, - screen: "EMBED", + name: "WEB", + name_proto: "1", + version: "2.20220804.00.00", + api_key: DEFAULT_API_KEY, + screen: "EMBED", + os_name: "Windows", + os_version: WINDOWS_VERSION, + platform: "DESKTOP", }, # Android ClientType::Android => { name: "ANDROID", + name_proto: "3", version: ANDROID_APP_VERSION, api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", android_sdk_version: ANDROID_SDK_VERSION, + user_agent: ANDROID_USER_AGENT, + os_name: "Android", + os_version: ANDROID_VERSION, + platform: "MOBILE", }, ClientType::AndroidEmbeddedPlayer => { - name: "ANDROID_EMBEDDED_PLAYER", # 55 - version: ANDROID_APP_VERSION, - api_key: DEFAULT_API_KEY, + name: "ANDROID_EMBEDDED_PLAYER", + name_proto: "55", + version: ANDROID_APP_VERSION, + api_key: DEFAULT_API_KEY, }, ClientType::AndroidScreenEmbed => { - name: "ANDROID", # 3 + name: "ANDROID", + name_proto: "3", version: ANDROID_APP_VERSION, api_key: DEFAULT_API_KEY, screen: "EMBED", android_sdk_version: ANDROID_SDK_VERSION, + user_agent: ANDROID_USER_AGENT, + os_name: "Android", + os_version: ANDROID_VERSION, + platform: "MOBILE", }, # IOS ClientType::IOS => { - name: "IOS", # 5 - version: IOS_APP_VERSION, - api_key: "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc", + name: "IOS", + name_proto: "5", + version: IOS_APP_VERSION, + api_key: "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc", + user_agent: IOS_USER_AGENT, + device_make: "Apple", + device_model: "iPhone14,5", + os_name: "iPhone", + os_version: IOS_VERSION, + platform: "MOBILE", }, ClientType::IOSEmbedded => { - name: "IOS_MESSAGES_EXTENSION", # 66 - version: IOS_APP_VERSION, - api_key: DEFAULT_API_KEY, + name: "IOS_MESSAGES_EXTENSION", + name_proto: "66", + version: IOS_APP_VERSION, + api_key: DEFAULT_API_KEY, + user_agent: IOS_USER_AGENT, + device_make: "Apple", + device_model: "iPhone14,5", + os_name: "iPhone", + os_version: IOS_VERSION, + platform: "MOBILE", }, ClientType::IOSMusic => { - name: "IOS_MUSIC", # 26 - version: "4.32", - api_key: "AIzaSyBAETezhkwP0ZWA02RsqT1zu78Fpt0bC_s", + name: "IOS_MUSIC", + name_proto: "26", + version: "5.21", + api_key: "AIzaSyBAETezhkwP0ZWA02RsqT1zu78Fpt0bC_s", + user_agent: "com.google.ios.youtubemusic/5.21 (iPhone14,5; U; CPU iOS 15_6 like Mac OS X;)", + device_make: "Apple", + device_model: "iPhone14,5", + os_name: "iPhone", + os_version: IOS_VERSION, + platform: "MOBILE", }, # TV app ClientType::TvHtml5 => { - name: "TVHTML5", # 7 - version: "7.20220325", - api_key: DEFAULT_API_KEY, + name: "TVHTML5", + name_proto: "7", + version: "7.20220325", + api_key: DEFAULT_API_KEY, }, ClientType::TvHtml5ScreenEmbed => { - name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", # 85 - version: "2.0", - api_key: DEFAULT_API_KEY, - screen: "EMBED", + name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", + name_proto: "85", + version: "2.0", + api_key: DEFAULT_API_KEY, + screen: "EMBED", }, } @@ -160,6 +218,10 @@ module YoutubeAPI HARDCODED_CLIENTS[@client_type][:name] end + def name_proto : String + HARDCODED_CLIENTS[@client_type][:name_proto] + end + # :ditto: def version : String HARDCODED_CLIENTS[@client_type][:version] @@ -179,6 +241,30 @@ module YoutubeAPI HARDCODED_CLIENTS[@client_type][:android_sdk_version]? end + def user_agent : String? + HARDCODED_CLIENTS[@client_type][:user_agent]? + end + + def os_name : String? + HARDCODED_CLIENTS[@client_type][:os_name]? + end + + def device_make : String? + HARDCODED_CLIENTS[@client_type][:device_make]? + end + + def device_model : String? + HARDCODED_CLIENTS[@client_type][:device_model]? + end + + def os_version : String? + HARDCODED_CLIENTS[@client_type][:os_version]? + end + + def platform : String? + HARDCODED_CLIENTS[@client_type][:platform]? + end + # Convert to string, for logging purposes def to_s return { @@ -226,6 +312,26 @@ module YoutubeAPI client_context["client"]["androidSdkVersion"] = android_sdk_version end + if device_make = client_config.device_make + client_context["client"]["deviceMake"] = device_make + end + + if device_model = client_config.device_model + client_context["client"]["deviceModel"] = device_model + end + + if os_name = client_config.os_name + client_context["client"]["osName"] = os_name + end + + if os_version = client_config.os_version + client_context["client"]["osVersion"] = os_version + end + + if platform = client_config.platform + client_context["client"]["platform"] = platform + end + return client_context end @@ -361,8 +467,18 @@ module YoutubeAPI ) # JSON Request data, required by the API data = { - "videoId" => video_id, - "context" => self.make_context(client_config), + "contentCheckOk" => true, + "videoId" => video_id, + "context" => self.make_context(client_config), + "racyCheckOk" => true, + "user" => { + "lockedSafetyMode" => false, + }, + "playbackContext" => { + "contentPlaybackContext" => { + "html5Preference": "HTML5_PREF_WANTS", + }, + }, } # Append the additional parameters if those were provided @@ -460,10 +576,17 @@ module YoutubeAPI url = "#{endpoint}?key=#{client_config.api_key}&prettyPrint=false" headers = HTTP::Headers{ - "Content-Type" => "application/json; charset=UTF-8", - "Accept-Encoding" => "gzip, deflate", + "Content-Type" => "application/json; charset=UTF-8", + "Accept-Encoding" => "gzip, deflate", + "x-goog-api-format-version" => "2", + "x-youtube-client-name" => client_config.name_proto, + "x-youtube-client-version" => client_config.version, } + if user_agent = client_config.user_agent + headers["User-Agent"] = user_agent + end + # Logging LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"") LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config}") |
