diff options
66 files changed, 4146 insertions, 1832 deletions
diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index 1f811b7c..756374da 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -17,6 +17,8 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v1 + with: + platforms: arm64 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 @@ -28,12 +30,43 @@ jobs: username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_PASSWORD }} - - name: Build and push for Push Event + - name: Cache Docker layers + if: github.ref == 'refs/heads/master' + uses: actions/cache@v2 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-multi-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-multi-buildx + + - name: Build and push Docker AMD64 image for Push Event if: github.ref == 'refs/heads/master' uses: docker/build-push-action@v2 with: context: . file: docker/Dockerfile + platforms: linux/amd64 labels: quay.expires-after=12w push: true tags: quay.io/invidious/invidious:${{ github.sha }},quay.io/invidious/invidious:latest + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new + + - name: Build and push Docker ARM64 image for Push Event + if: github.ref == 'refs/heads/master' + uses: docker/build-push-action@v2 + 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,quay.io/invidious/invidious:latest-arm64 + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new + + - name: Override old Docker cache + if: github.ref == 'refs/heads/master' + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache diff --git a/assets/css/default.css b/assets/css/default.css index 1d62bc01..ce6c30c9 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -282,6 +282,21 @@ input[type="search"]::-webkit-search-cancel-button { } } + +/* + * Video "cards" (results/playlist/channel videos) + */ + +.video-card-row { margin: 15px 0; } + +.flexible { display: flex; } +.flex-left { flex: 1 1 100%; flex-wrap: wrap; } +.flex-right { flex: 1 0 max-content; flex-wrap: nowrap; } + +p.channel-name { margin: 0; } +p.video-data { margin: 0; font-weight: bold; font-size: 80%; } + + /* * Footer */ @@ -492,11 +507,6 @@ hr { } /* Description Expansion Styling*/ -#description-box { - display: flex; - flex-direction: column; -} - #descexpansionbutton { display: none } @@ -511,7 +521,27 @@ hr { height: 100%; } -#descexpansionbutton + label { +#descexpansionbutton ~ label { order: 1; margin-top: 20px; } + +/* Bidi (bidirectional text) support */ +h1, +h2, +h3, +h4, +h5, +p, +#descriptionWrapper, +#description-box { + unicode-bidi: plaintext; + text-align: start; +} + +#descriptionWrapper { + max-width: 600px; +} + +/* Center the "invidious" logo on the search page */ +#logo > h1 { text-align: center; } diff --git a/config/config.example.yml b/config/config.example.yml index e8330705..d2346719 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,13 +1,825 @@ -channel_threads: 1 -feed_threads: 1 +######################################### +# +# Database configuration +# +######################################### + +## +## Database configuration with separate parameters. +## This setting is MANDATORY, unless 'database_url' is used. +## db: user: kemal password: kemal host: localhost port: 5432 dbname: invidious -# alternatively, the database URL can be provided directly - if both are set then the latter takes precedence -# database_url: postgres://kemal:kemal@localhost:5432/invidious -full_refresh: false -https_only: false + +## +## Database configuration using a single URI. This is an +## alternative to the 'db' parameter above. If both forms +## are used, then only database_url is used. +## This setting is MANDATORY, unless 'db' is used. +## +## Note: The 'database_url' setting allows the use of UNIX +## sockets. To do so, remove the IP address (or FQDN) and port +## and append the 'host' parameter. E.g: +## postgres://kemal:kemal@/invidious?host=/var/run/postgresql +## +## Accepted values: a postgres:// URI +## Default: postgres://kemal:kemal@localhost:5432/invidious +## +#database_url: postgres://kemal:kemal@localhost:5432/invidious + +## +## Enable automatic table integrity check. This will create +## the required tables and columns if anything is missing. +## +## Accepted values: true, false +## Default: false +## +#check_tables: false + + + +######################################### +# +# Server config +# +######################################### + +# ----------------------------- +# Network (inbound) +# ----------------------------- + +## +## Port to listen on for incoming connections. +## +## Note: Ports lower than 1024 requires either root privileges +## (not recommended) or the "CAP_NET_BIND_SERVICE" capability +## (See https://stackoverflow.com/a/414258 and `man capabilities`) +## +## Accepted values: 1-65535 +## Default: 3000 +## +#port: 3000 + +## +## When the invidious instance is behind a proxy, and the proxy +## listens on a different port than the instance does, this lets +## invidious know about it. This is used to craft absolute URLs +## to the instance (e.g in the API). +## +## Note: This setting is MANDATORY if invidious is behind a +## reverse proxy. +## +## Accepted values: 1-65535 +## Default: <none> +## +#external_port: + +## +## Interface address to listen on for incoming connections. +## +## Accepted values: a valid IPv4 or IPv6 address. +## default: 0.0.0.0 (listen on all interfaces) +## +#host_binding: 0.0.0.0 + +## +## Domain name under which this instance is hosted. This is +## used to craft absolute URLs to the instance (e.g in the API). +## The domain MUST be defined if your instance is accessed from +## a domain name (like 'example.com'). +## +## Accepted values: a fully qualified domain name (FQDN) +## Default: <none> +## domain: + +## +## Tell Invidious that it is behind a proxy that provides only +## HTTPS, so all links must use the https:// scheme. This +## setting MUST be set to true if invidious is behind a +## reverse proxy serving HTTPs. +## +## Accepted values: true, false +## Default: false +## +https_only: false + +## +## Enable/Disable 'Strict-Transport-Security'. Make sure that +## the domain specified under 'domain' is served securely. +## +## Accepted values: true, false +## Default: true +## +#hsts: true + + +# ----------------------------- +# Network (outbound) +# ----------------------------- + +## +## Disable proxying server-wide. Can be disable as a whole, or +## only for a single function. +## +## Accepted values: true, false, dash, livestreams, downloads, local +## Default: false +## +#disable_proxy: false + +## +## Size of the HTTP pool used to connect to youtube. Each +## domain ('youtube.com', 'ytimg.com', ...) has its own pool. +## +## Accepted values: a positive integer +## Default: 100 +## +#pool_size: 100 + +## +## Enable/Disable the use of QUIC (HTTP/3) when connecting +## to the youtube API and websites ('youtube.com', 'ytimg.com'). +## QUIC's main advantages are its lower latency and lower bandwidth +## use, compared to its predecessors. However, the current version +## of QUIC used in invidious is still based on the IETF draft 31, +## meaning that the underlying library may still not be fully +## optimized. You can read more about QUIC at the link below: +## https://datatracker.ietf.org/doc/html/draft-ietf-quic-transport-31 +## +## Note: you should try both options and see what is the best for your +## instance. In general QUIC is recommended for public instances. Your +## mileage may vary. +## +## Note 2: Using QUIC prevents some captcha challenges from appearing. +## See: https://github.com/iv-org/invidious/issues/957#issuecomment-576424042 +## +## Accepted values: true, false +## Default: true +## +#use_quic: true + +## +## Additionnal cookies to be sent when requesting the youtube API. +## +## Accepted values: a string in the format "name1=value1; name2=value2..." +## Default: <none> +## +#cookies: + +## +## Force connection to youtube over a specific IP family. +## +## Note: This may sometimes resolve issues involving rate-limiting. +## See https://github.com/ytdl-org/youtube-dl/issues/21729. +## +## Accepted values: ipv4, ipv6 +## Default: <none> +## +#force_resolve: + + +# ----------------------------- +# Logging +# ----------------------------- + +## +## Path to log file. Can be absolute or relative to the invidious +## binary. This is overriden if "-o OUTPUT" or "--output=OUTPUT" +## are passed on the command line. +## +## Accepted values: a filesystem path or 'STDOUT' +## Default: STDOUT +## +#output: STDOUT + +## +## Logging Verbosity. This is overriden if "-l LEVEL" or +## "--log-level=LEVEL" are passed on the command line. +## +## Accepted values: All, Trace, Debug, Info, Warn, Error, Fatal, Off +## Default: Info +## +#log_level: Info + + +# ----------------------------- +# Features +# ----------------------------- + +## +## Enable/Disable the "Popular" tab on the main page. +## +## Accepted values: true, false +## Default: true +## +#popular_enabled: true + +## +## Enable/Disable statstics (available at /api/v1/stats). +## The following data is available: +## - Software name ("invidious") and version+branch (same data as +## displayed in the footer, e.g: "2021.05.13-75e5b49" / "master") +## - The value of the 'registration_enabled' config (true/false) +## - Number of currently registered users +## - Number of registered users who connected in the last month +## - Number of registered users who connected in the last 6 months +## - Timestamp of the last server restart +## - Timestamp of the last "Channel Refresh" job execution +## +## Warning: This setting MUST be set to true if you plan to run +## a public instance. It is used by api.invidious.io to refresh +## your instance's status. +## +## Accepted values: true, false +## Default: false +## +#statistics_enabled: false + + +# ----------------------------- +# Users and accounts +# ----------------------------- + +## +## Allow/Forbid Invidious (local) account creation. Invidious +## accounts allow users to subscribe to channels and to create +## playlists without a Google account. +## +## Accepted values: true, false +## Default: true +## +#registration_enabled: true + +## +## Allow/Forbid users to log-in. This setting affects the ability +## to connect with BOTH Google and Invidious (local) accounts. +## +## Accepted values: true, false +## Default: true +## +#login_enabled: true + +## +## Enable/Disable the captcha challenge on the login page. +## +## Note: this is a basic captcha challenge that doesn't +## depend on any third parties. +## +## Accepted values: true, false +## Default: true +## +#captcha_enabled: true + +## +## List of usernames that will be granted administrator rights. +## A user with administrator rights will be able to change the +## server configuration options listed below in /preferences, +## in addition to the usual user preferences. +## +## Server-wide settings: +## - popular_enabled +## - captcha_enabled +## - login_enabled +## - registration_enabled +## - statistics_enabled +## Default user preferences: +## - default_home +## - feed_menu +## +## Accepted values: an array of strings +## Default: [""] +## +#admins: [""] + + +# ----------------------------- +# Background jobs +# ----------------------------- + +## +## 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 overriden if "-c THREADS" or +## "--channel-threads=THREADS" are passed on the command line. +## +## Accepted values: a positive integer +## Default: 1 +## +channel_threads: 1 + +## +## Forcefully dump and re-download the entire list of uploaded +## videos when crawling channel (during subscriptions update). +## +## Accepted values: true, false +## Default: false +## +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 overriden if "-f THREADS" or +## "--feed-threads=THREADS" are passed on the command line. +## +## Accepted values: a positive integer +## Default: 1 +## +feed_threads: 1 + +## +## Enable/Disable the polling job that keeps the decryption +## function (for "secured" videos) up to date. +## +## Note: This part of the code is currently broken, so changing +## this setting has no impact. +## +## Accepted values: true, false +## Default: true +## +#decrypt_polling: true + + +# ----------------------------- +# Captcha API +# ----------------------------- + +## +## URL of the captcha solving service. +## +## Accepted values: any URL +## Default: https://api.anti-captcha.com +## +#captcha_api_url: https://api.anti-captcha.com + +## +## API key for the captcha solving service. +## +## Accepted values: a string +## Default: <none> +## +#captcha_key: + + +# ----------------------------- +# Miscellanous +# ----------------------------- + +## +## custom banner displayed at the top of every page. This can +## used for instance announcements, e.g. +## +## Accepted values: any string. HTML is accepted. +## Default: <none> +## +#banner: + +## +## Subscribe to channels using PubSubHub (Google PubSubHubbub service). +## PubSubHub allows Invidious to be instantly notified when a new video +## is published on any subscribed channels. When PubSubHub is not used, +## Invidious will check for new videos every minute. +## +## Note: This setting is recommended for public instances. +## +## Note 2: +## - Requires a public instance (it uses /feed/webhook/v1) +## - Requires 'domain' and 'hmac_key' to be set. +## - Setting this parameter to any number greater than zero will +## enable channel subscriptions via PubSubHub, but will limit the +## amount of concurrent subscriptions. +## +## Accepted values: true, false, a positive integer +## Default: false +## +#use_pubsub_feeds: false + +## +## HMAC signing key used for CSRF tokens and pubsub +## subscriptions verification. +## +## Accepted values: a string +## Default: <none> +## +#hmac_key: + +## +## List of video IDs where the "download" widget must be +## disabled, in order to comply with DMCA requests. +## +## Accepted values: an array of string +## Default: <none> +## +#dmca_content: + +## +## Cache video annotations in the database. +## +## Warning: empty annotations or annotations that only contain +## cards won't be cached. +## +## Accepted values: true, false +## Default: false +## +#cache_annotations: false + + + +######################################### +# +# Default user preferences +# +######################################### + +## +## NOTE: All the settings below define the default user +## preferences. They will apply to ALL users connecting +## without a preferences cookie (so either on the first +## connection to the instance or after clearing the +## browser's cookies). +## + +default_user_preferences: + + # ----------------------------- + # Internationalization + # ----------------------------- + + ## + ## Default user interface language (locale). + ## + ## Note: overridin the default (no preferred caption language) + ## is not recommended, in order to not penalize people using + ## other languages. + ## + ## Accepted values: + ## ar (Arabic) + ## da (Danish) + ## de (German) + ## en-US (english, US) + ## el (Greek) + ## eo (Esperanto) + ## es (Spanish) + ## fa (Persian) + ## fi (Finnish) + ## fr (French) + ## he (Hebrew) + ## hr (Hungarian) + ## id (Indonesian) + ## is (Icelandic) + ## it (Italian) + ## ja (Japanese) + ## nb-NO (Norwegian, Bokmål) + ## nl (Dutch) + ## pl (Polish) + ## pt-BR (Portuguese, Brazil) + ## pt-PT (Portuguese, Portugal) + ## ro (Romanian) + ## ru (Russian) + ## sv (Swedish) + ## tr (Turkish) + ## uk (Ukrainian) + ## zh-CN (Chinese, China) (a.k.a "Simplified Chinese") + ## zh-TW (Chinese, Taiwan) (a.k.a "Traditional Chinese") + ## + ## Default: en-US + ## + #locale: en-US + + ## + ## Top 3 prefered languages for video captions. + ## + ## Note: overridin the default (no preferred + ## caption language) is not recommended, in order + ## to not penalize people using other languages. + ## + ## Accepted values: a three-entries array. + ## Each entry can be one of: + ## "English", "English (auto-generated)", + ## "Afrikaans", "Albanian", "Amharic", "Arabic", + ## "Armenian", "Azerbaijani", "Bangla", "Basque", + ## "Belarusian", "Bosnian", "Bulgarian", "Burmese", + ## "Catalan", "Cebuano", "Chinese (Simplified)", + ## "Chinese (Traditional)", "Corsican", "Croatian", + ## "Czech", "Danish", "Dutch", "Esperanto", "Estonian", + ## "Filipino", "Finnish", "French", "Galician", "Georgian", + ## "German", "Greek", "Gujarati", "Haitian Creole", "Hausa", + ## "Hawaiian", "Hebrew", "Hindi", "Hmong", "Hungarian", + ## "Icelandic", "Igbo", "Indonesian", "Irish", "Italian", + ## "Japanese", "Javanese", "Kannada", "Kazakh", "Khmer", + ## "Korean", "Kurdish", "Kyrgyz", "Lao", "Latin", "Latvian", + ## "Lithuanian", "Luxembourgish", "Macedonian", + ## "Malagasy", "Malay", "Malayalam", "Maltese", "Maori", + ## "Marathi", "Mongolian", "Nepali", "Norwegian Bokmål", + ## "Nyanja", "Pashto", "Persian", "Polish", "Portuguese", + ## "Punjabi", "Romanian", "Russian", "Samoan", + ## "Scottish Gaelic", "Serbian", "Shona", "Sindhi", + ## "Sinhala", "Slovak", "Slovenian", "Somali", + ## "Southern Sotho", "Spanish", "Spanish (Latin America)", + ## "Sundanese", "Swahili", "Swedish", "Tajik", "Tamil", + ## "Telugu", "Thai", "Turkish", "Ukrainian", "Urdu", + ## "Uzbek", "Vietnamese", "Welsh", "Western Frisian", + ## "Xhosa", "Yiddish", "Yoruba", "Zulu" + ## + ## Default: ["", "", ""] + ## + #captions: ["", "", ""] + + + # ----------------------------- + # Interface + # ----------------------------- + + ## + ## Enable/Disable dark mode. + ## + ## Accepted values: true, false + ## Default: <none> + ## + #dark_mode: + + ## + ## Enable/Disable thin mode (no video thumbnails). + ## + ## Accepted values: true, false + ## Default: false + ## + #thin_mode: false + + ## + ## List of feeds available on the home page. + ## + ## Note: "Subscriptions" and "Playlists" are only visible + ## when the user is logged in. + ## + ## Accepted values: A list of strings + ## Each entry can be one of: "Popular", "Trending", + ## "Subscriptions", "Playlists" + ## + ## Default: ["Popular", "Trending", "Subscriptions", "Playlists"] (show all feeds) + ## + #feed_menu: ["Popular", "Trending", "Subscriptions", "Playlists"] + + ## + ## Default feed to diplay on the home page. + ## + ## Note: setting this option to "Popular" has no + ## effect when 'popular_enabled' is set to false. + ## + ## Accepted values: Popular, Trending, Subscriptions, Playlists, <none> + ## Default: Popular + ## + #default_home: Popular + + ## + ## Default number of results to display per page. + ## + ## Note: this affects invidious-generated pages only, such + ## as watch history and subscription feeds. Playlists, search + ## results and channel videos depend on the data returned by + ## the Youtube API. + ## + ## Accepted values: any positive integer + ## Default: 40 + ## + #max_results: 40 + + ## + ## Show/hide annotations. + ## + ## Accepted values: true, false + ## Default: false + ## + #annotations: false + + ## + ## Show/hide annotation. + ## + ## Accepted values: true, false + ## Default: false + ## + #annotations_subscribed: false + + ## + ## Type of comments to display below video. + ## + ## Accepted values: a two-entries array. + ## Each entry can be one of: "youtube", "reddit", "" + ## + ## Default: ["youtube", ""] + ## + #comments: ["youtube", ""] + + ## + ## Default player style. + ## + ## Accepted values: invidious, youtube + ## Default: invidious + ## + #player_style: invidious + + ## + ## Show/Hide the "related videos" sidebar when + ## watching a video. + ## + ## Accepted values: true, false + ## Default: true + ## + #related_videos: true + + + # ----------------------------- + # Video player behavior + # ----------------------------- + + ## + ## Automatically play videos on page load. + ## + ## Accepted values: true, false + ## Default: false + ## + #autoplay: false + + ## + ## Automatically load the "next" video (either next in + ## playlist or proposed) when the current video ends. + ## + ## Accepted values: true, false + ## Default: false + ## + #continue: false + + ## + ## Autoplay next video by default. + ## + ## Note: Only effective if 'continue' is set to true. + ## + ## Accepted values: true, false + ## Default: true + ## + #continue_autoplay: true + + ## + ## Play videos in Audio-only mode by default. + ## + ## Accepted values: true, false + ## Default: false + ## + #listen: false + + ## + ## Loop videos automatically. + ## + ## Accepted values: true, false + ## Default: false + ## + #video_loop: false + + + # ----------------------------- + # Video playback settings + # ----------------------------- + + ## + ## Default video quality. + ## + ## Accepted values: dash, hd720, medium, small + ## Default: hd720 + ## + #quality: hd720 + + ## + ## Default dash video quality. + ## + ## Note: this setting only takes effet if the + ## 'quality' parameter is set to "dash". + ## + ## Accepted values: + ## auto, best, 4320p, 2160p, 1440p, 1080p, + ## 720p, 480p, 360p, 240p, 144p, worst + ## Default: auto + ## + #quality_dash: auto + + ## + ## Default video playback speed. + ## + ## Accepted values: 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0 + ## Default: 1.0 + ## + #speed: 1.0 + + ## + ## Default volume. + ## + ## Accepted values: 0-100 + ## Default: 100 + ## + #volume: 100 + + ## + ## Allow 360° videos to be played. + ## + ## Note: This feature requires a WebGL-enabled browser. + ## + ## Accepted values: true, false + ## Default: true + ## + #vr_mode: true + + # ----------------------------- + # Subscription feed + # ----------------------------- + + ## + ## In the "Subscription" feed, only show the latest video + ## of each channel the user is subscribed to. + ## + ## Note: when combined with 'unseen_only', the latest unseen + ## video of each channel will be displayed instead of the + ## latest by date. + ## + ## Accepted values: true, false + ## Default: false + ## + #latest_only: false + + ## + ## Enable/Disable user subscriptions desktop notifications. + ## + ## Accepted values: true, false + ## Default: false + ## + #notifications_only: false + + ## + ## In the "Subscription" feed, Only show the videos that the + ## user haven't watched yet (i.e which are not in their watch + ## history). + ## + ## Accepted values: true, false + ## Default: false + ## + #unseen_only: false + + ## + ## Default sorting parameter for subscription feeds. + ## + ## Accepted values: + ## 'alphabetically' + ## 'alphabetically - reverse' + ## 'channel name' + ## 'channel name - reverse' + ## 'published' + ## 'published - reverse' + ## + ## Default: published + ## + #sort: published + + + # ----------------------------- + # Miscellanous + # ----------------------------- + + ## + ## Proxy videos through instance by default. + ## + ## Warning: As most users won't change this setting in their + ## preferences, defaulting to true will significantly + ## increase the instance's network usage, so make sure that + ## your server's connection can handle it. + ## + ## Accepted values: true, false + ## Default: false + ## + #local: false + + ## + ## Show the connected user's nick at the top right. + ## + ## Accepted values: true, false + ## Default: true + ## + #show_nick: true + + ## + ## Automatically redirect to a random instance when the user uses + ## any "switch invidious instance" link (For videos, it's the plane + ## icon, next to "watch on youtube" and "listen"). When set to false, + ## the user is sent to https://redirect.invidious.io instead, where + ## they can manually select an instance. + ## + ## Accepted values: true, false + ## Default: false + ## + #automatic_instance_redirect: false diff --git a/docker/APKBUILD-boringssl b/docker/APKBUILD-boringssl new file mode 100644 index 00000000..61caa4f1 --- /dev/null +++ b/docker/APKBUILD-boringssl @@ -0,0 +1,46 @@ +# Based on https://aur.archlinux.org/packages/boringssl-git/ +# Maintainer: Omar Roth <omarroth@protonmail.com> +pkgname=boringssl +pkgver=1.1.0 +pkgrel=0 +pkgdesc="BoringSSL is a fork of OpenSSL that is designed to meet Google's needs" +url="https://boringssl.googlesource.com/boringssl" +arch="all" +license="MIT" +replaces="openssl libressl" +depends="!openssl-libs-static" +makedepends_host="linux-headers" +makedepends="cmake git go perl" +subpackages="$pkgname-static $pkgname-dev $pkgname-doc" +source="251b516.tar.gz::https://github.com/google/boringssl/tarball/251b516" +builddir="$srcdir/google-boringssl-251b516" + +prepare() { + : +} + +build() { + cmake -DCMAKE_BUILD_TYPE=Release . + make ssl crypto +} + +check() { + make all_tests +} + +package() { + for i in *.md ; do + install -Dm644 $i "$pkgdir/usr/share/doc/$pkgname/$i" + done + install -d "$pkgdir/usr/lib" + install -d "$pkgdir/usr/include" + cp -R include/openssl "$pkgdir/usr/include" + + install -Dm755 crypto/libcrypto.a "$pkgdir/usr/lib/libcrypto.a" + install -Dm755 ssl/libssl.a "$pkgdir/usr/lib/libssl.a" +# install -Dm755 decrepit/libdecrepit.a "$pkgdir/usr/lib/libdecrepit.a" +# install -Dm755 libboringssl_gtest.a "$pkgdir/usr/lib/libboringssl_gtest.a" +} +sha512sums=" +b1d42ed188cf0cce89d40061fa05de85b387ee4244f1236ea488a431536a2c6b657b4f03daed0ac9328c7f5c4c9330499283b8a67f1444dcf9ba5e97e1199c4e 251b516.tar.gz +" diff --git a/docker/APKBUILD-lsquic b/docker/APKBUILD-lsquic new file mode 100644 index 00000000..51630a0e --- /dev/null +++ b/docker/APKBUILD-lsquic @@ -0,0 +1,43 @@ +# Maintainer: Omar Roth <omarroth@protonmail.com> +pkgname=lsquic +pkgver=2.18.1 +pkgrel=0 +pkgdesc="LiteSpeed QUIC and HTTP/3 Library" +url="https://github.com/litespeedtech/lsquic" +arch="all" +license="MIT" +depends="boringssl-dev boringssl-static zlib-static libevent-static" +makedepends="cmake git go perl bsd-compat-headers linux-headers" +subpackages="$pkgname-static" +source="v$pkgver.tar.gz::https://github.com/litespeedtech/lsquic/tarball/v2.18.1 +ls-qpack-$pkgver.tar.gz::https://github.com/litespeedtech/ls-qpack/tarball/a8ae6ef +ls-hpack-$pkgver.tar.gz::https://github.com/litespeedtech/ls-hpack/tarball/bd5d589" +builddir="$srcdir/litespeedtech-$pkgname-692a910" + +prepare() { + cp -r -T "$srcdir/litespeedtech-ls-qpack-a8ae6ef" "$builddir/src/liblsquic/ls-qpack" + cp -r -T "$srcdir/litespeedtech-ls-hpack-bd5d589" "$builddir/src/lshpack" +} + +build() { + cmake \ + -DCMAKE_BUILD_TYPE=None \ + -DBORINGSSL_INCLUDE=/usr/include/openssl \ + -DBORINGSSL_LIB_crypto=/usr/lib \ + -DBORINGSSL_LIB_ssl=/usr/lib . + make lsquic +} + +check() { + make tests +} + +package() { + install -d "$pkgdir/usr/lib" + install -Dm755 src/liblsquic/liblsquic.a "$pkgdir/usr/lib/liblsquic.a" +} +sha512sums=" +d015a72f1e88750ecb364768a40f532678f11ded09c6447a2e698b20f43fa499ef143a53f4c92a5938dfece0e39e687dc9df4aea97c618faee0c63da771561c3 v2.18.1.tar.gz +c5629085a3881815fb0b72a321eeba8de093eff9417b8ac7bde1ee1264971be0dca6d61d74799b02ae03a4c629b2a9cf21387deeb814935339a8a2503ea33fee ls-qpack-2.18.1.tar.gz +1b9f7ce4c82dadfca8154229a415b0335a61761eba698f814d4b94195c708003deb5cb89318a1ab78ac8fa88b141bc9df283fb1c6e40b3ba399660feaae353a0 ls-hpack-2.18.1.tar.gz +" diff --git a/docker/Dockerfile b/docker/Dockerfile index b8e5af8a..9a535414 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,10 +1,44 @@ +FROM alpine:edge AS liblsquic-builder +WORKDIR /src + +RUN apk add --no-cache build-base git apk-tools abuild cmake go perl linux-headers + +RUN abuild-keygen -a -n && \ + cp /root/.abuild/-*.rsa.pub /etc/apk/keys/ + +COPY docker/APKBUILD-boringssl boringssl/APKBUILD +RUN cd boringssl && abuild -F -r && cd .. + +RUN apk add --repository /root/packages/src boringssl boringssl-dev boringssl-static + +RUN apk add --no-cache zlib-dev zlib-static libevent-dev libevent-static + +COPY docker/APKBUILD-lsquic lsquic/APKBUILD +RUN cd lsquic && abuild -F -r && cd .. + +RUN apk add --repository /root/packages/src lsquic-static + +RUN mkdir tmp && cd tmp && \ + ar -x /usr/lib/libssl.a && \ + ar -x /usr/lib/libcrypto.a && \ + ar -x /usr/lib/liblsquic.a && \ + ar rc liblsquic.a *.o && \ + strip --strip-unneeded liblsquic.a && \ + ranlib liblsquic.a && \ + cp liblsquic.a /root/liblsquic.a && \ + cd .. && rm -rf tmp + + FROM crystallang/crystal:1.0.0-alpine AS builder -RUN apk add --no-cache curl sqlite-static yaml-static +RUN apk add --no-cache sqlite-static yaml-static + WORKDIR /invidious COPY ./shard.yml ./shard.yml COPY ./shard.lock ./shard.lock -RUN shards install && \ - curl -Lo ./lib/lsquic/src/lsquic/ext/liblsquic.a https://github.com/iv-org/lsquic-static-alpine/releases/download/v2.18.1/liblsquic.a +RUN shards install + +COPY --from=liblsquic-builder /root/liblsquic.a ./lib/lsquic/src/lsquic/ext/liblsquic.a + COPY ./src/ ./src/ # TODO: .git folder is required for building – this is destructive. # See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. diff --git a/docker/Dockerfile.arm64 b/docker/Dockerfile.arm64 new file mode 100644 index 00000000..1ec95d8a --- /dev/null +++ b/docker/Dockerfile.arm64 @@ -0,0 +1,66 @@ +FROM alpine:3.14 AS liblsquic-builder +WORKDIR /src + +RUN apk add --no-cache build-base git apk-tools abuild cmake go perl linux-headers + +RUN abuild-keygen -a -n && \ + cp /root/.abuild/-*.rsa.pub /etc/apk/keys/ + +COPY docker/APKBUILD-boringssl boringssl/APKBUILD +RUN cd boringssl && abuild -F -r && cd .. + +RUN apk add --repository /root/packages/src boringssl boringssl-dev boringssl-static + +RUN apk add --no-cache zlib-dev zlib-static libevent-dev libevent-static + +COPY docker/APKBUILD-lsquic lsquic/APKBUILD +RUN cd lsquic && abuild -F -r && cd .. + +RUN apk add --repository /root/packages/src lsquic-static + +RUN mkdir tmp && cd tmp && \ + ar -x /usr/lib/libssl.a && \ + ar -x /usr/lib/libcrypto.a && \ + ar -x /usr/lib/liblsquic.a && \ + ar rc liblsquic.a *.o && \ + strip --strip-unneeded liblsquic.a && \ + ranlib liblsquic.a && \ + cp liblsquic.a /root/liblsquic.a && \ + cd .. && rm -rf tmp + + +FROM alpine:3.14 AS builder +RUN apk add --no-cache 'crystal<2' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev + +WORKDIR /invidious +COPY ./shard.yml ./shard.yml +COPY ./shard.lock ./shard.lock +RUN shards install + +COPY --from=liblsquic-builder /root/liblsquic.a ./lib/lsquic/src/lsquic/ext/liblsquic.a + +COPY ./src/ ./src/ +# TODO: .git folder is required for building – this is destructive. +# See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. +COPY ./.git/ ./.git/ +RUN crystal build ./src/invidious.cr \ + --static --warnings all \ + --link-flags "-lxml2 -llzma" + +FROM alpine:latest +RUN apk add --no-cache librsvg ttf-opensans +WORKDIR /invidious +RUN addgroup -g 1000 -S invidious && \ + adduser -u 1000 -S invidious -G invidious +COPY ./assets/ ./assets/ +COPY --chown=invidious ./config/config.* ./config/ +RUN mv -n config/config.example.yml config/config.yml +RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: postgres/' config/config.yml +COPY ./config/sql/ ./config/sql/ +COPY ./locales/ ./locales/ +COPY --from=builder /invidious/invidious . +RUN chmod o+rX -R ./assets ./config ./locales + +EXPOSE 3000 +USER invidious +CMD [ "/invidious/invidious" ] diff --git a/locales/ar.json b/locales/ar.json index f4fda666..5a981578 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -1,15 +1,15 @@ { "`x` subscribers": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` المشتركين", - "": "`x` المشتركين." + "": "`x` المشتركين" }, "`x` videos": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` المقاطع المرئيَّة", - "": "`x` المقاطع المرئيَّة." + "": "`x` المقاطع المرئيَّة" }, "`x` playlists": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` قوائم التشغيل", - "": "`x` قوائم التشغيل." + "": "`x` قوائم التشغيل" }, "LIVE": "مُباشِر", "Shared `x` ago": "تمَّ رفع المقطع المرئيّ مُنذ `x`", @@ -86,8 +86,8 @@ "dark": "غامق (اسود)", "light": "فاتح (ابيض)", "Thin mode: ": "الوضع الخفيف: ", - "Miscellaneous preferences": "", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", + "Miscellaneous preferences": "تفضيلات متنوعة", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "إعادة توجيه المثيل التلقائي (إعادة التوجيه إلى redirect.invidious.io): ", "Subscription preferences": "تفضيلات الإشتراك", "Show annotations by default for subscribed channels: ": "عرض الملاحظات في الفيديوهات تلقائيا في القنوات المشترك بها فقط: ", "Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ", @@ -117,7 +117,7 @@ "Administrator preferences": "إعدادات المدير", "Default homepage: ": "الصفحة الرئيسية الافتراضية ", "Feed menu: ": "قائمة التدفقات: ", - "Show nickname on top: ": "", + "Show nickname on top: ": "إظهار اللقب في الأعلى: ", "Top enabled: ": "تفعيل 'الأفضل' ؟ ", "CAPTCHA enabled: ": "تفعيل الكابتشا: ", "Login enabled: ": "تفعيل الولوج: ", @@ -129,11 +129,11 @@ "Token": "الرمز", "`x` subscriptions": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` مشتركين", - "": "`x` مشتركين." + "": "`x` مشتركين" }, "`x` tokens": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` رموز", - "": "`x` رموز." + "": "`x` رموز" }, "Import/export": "إضافة\\إستخراج", "unsubscribe": "إلغاء الإشتراك", @@ -164,8 +164,8 @@ "Show more": "أظهر المزيد", "Show less": "عرض اقل", "Watch on YouTube": "مشاهدة الفيديو على اليوتيوب", - "Switch Invidious Instance": "", - "Broken? Try another Invidious Instance": "", + "Switch Invidious Instance": "تبديل المثيل Invidious", + "Broken? Try another Invidious Instance": "معطل؟ جرب مثيل Invidious آخر", "Hide annotations": "إخفاء الملاحظات فى الفيديو", "Show annotations": "عرض الملاحظات فى الفيديو", "Genre: ": "النوع: ", @@ -178,7 +178,7 @@ "Shared `x`": "شارك منذ `x`", "`x` views": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` مشاهدات", - "": "`x` مشاهدات." + "": "`x` مشاهدات" }, "Premieres in `x`": "يعرض فى `x`", "Premieres `x`": "يعرض `x`", @@ -187,7 +187,7 @@ "View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع Reddit", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "عرض `x` تعليقات", - "": "عرض `x` تعليقات." + "": "عرض `x` تعليقات" }, "View Reddit comments": "عرض تعليقات ريدإت Reddit", "Hide replies": "إخفاء الردود", @@ -207,7 +207,7 @@ "Password cannot be empty": "الرقم السرى لايمكن ان يكون فارغ", "Password cannot be longer than 55 characters": "الرقم السرى لا يتعدى 55 حرف", "Please log in": "الرجاء تسجيل الدخول", - "Invidious Private Feed for `x`": "صفحة Invidious للمشتركين الخاصة\\مخفية لـ `x`", + "Invidious Private Feed for `x`": "تغذية Invidious خاصة ل 'x'", "channel:`x`": "قناة:`x`", "Deleted or invalid channel": "قناة ممسوحة او غير صالحة", "This channel does not exist.": "القناة غير موجودة.", @@ -215,13 +215,13 @@ "Could not fetch comments": "لم يتمكن من إحضار التعليقات", "View `x` replies": { "([^.,0-9]|^)1([^.,0-9]|$)": "عرض `x` ردود", - "": "عرض `x` ردود." + "": "عرض `x` ردود" }, "`x` ago": "`x` منذ", "Load more": "عرض المزيد", "`x` points": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` نقاط", - "": "`x` نقاط." + "": "`x` نقاط" }, "Could not create mix.": "لم يستطع عمل خلط.", "Empty playlist": "قائمة التشغيل فارغة", @@ -342,31 +342,31 @@ "Zulu": "الزولو", "`x` years": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` سنوات", - "": "`x` سنوات." + "": "`x` سنوات" }, "`x` months": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` شهور", - "": "`x` شهور." + "": "`x` شهور" }, "`x` weeks": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` اسابيع", - "": "`x` اسابيع." + "": "`x` اسابيع" }, "`x` days": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ايام", - "": "`x` ايام." + "": "`x` ايام" }, "`x` hours": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ساعات", - "": "`x` ساعات." + "": "`x` ساعات" }, "`x` minutes": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` دقائق", - "": "`x` دقائق." + "": "`x` دقائق" }, "`x` seconds": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ثوانى", - "": "`x` ثوانى." + "": "`x` ثوانى" }, "Fallback comments: ": "التعليقات البديلة: ", "Popular": "الأكثر شعبية", diff --git a/locales/bn_BD.json b/locales/bn_BD.json index 83bd6555..5f91c67e 100644 --- a/locales/bn_BD.json +++ b/locales/bn_BD.json @@ -1,10 +1,16 @@ { - "`x` subscribers.([^.,0-9]|^)1([^.,0-9]|$)": "`x` সাবস্ক্রাইবার", - "`x` subscribers.": "`x` সাবস্ক্রাইবার।", - "`x` videos.([^.,0-9]|^)1([^.,0-9]|$)": "`x` ভিডিও", - "`x` videos.": "`x` ভিডিও", - "`x` playlists.([^.,0-9]|^)1([^.,0-9]|$)": "`x` প্লেলিস্ট", - "`x` playlists.": "`x` প্লেলিস্ট", + "`x` subscribers": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` সাবস্ক্রাইবার", + "": "`x` সাবস্ক্রাইবার" + }, + "`x` videos": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ভিডিও", + "": "`x` ভিডিও" + }, + "`x` playlists": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` প্লেলিস্ট", + "": "`x` প্লেলিস্ট" + }, "LIVE": "লাইভ", "Shared `x` ago": "`x` আগে শেয়ার করা হয়েছে", "Unsubscribe": "আনসাবস্ক্রাইব", @@ -81,7 +87,7 @@ "light": "", "Thin mode: ": "", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "", "Show annotations by default for subscribed channels: ": "", "Redirect homepage to feed: ": "", @@ -111,6 +117,7 @@ "Administrator preferences": "", "Default homepage: ": "", "Feed menu: ": "", + "Show nickname on top: ": "", "Top enabled: ": "", "CAPTCHA enabled: ": "", "Login enabled: ": "", @@ -120,16 +127,22 @@ "Subscription manager": "", "Token manager": "", "Token": "", - "`x` subscriptions.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` subscriptions.": "", - "`x` tokens.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` tokens.": "", + "`x` subscriptions": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` tokens": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "Import/export": "", "unsubscribe": "", "revoke": "", "Subscriptions": "", - "`x` unseen notifications.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` unseen notifications.": "", + "`x` unseen notifications": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "search": "", "Log out": "", "Released under the AGPLv3 by Omar Roth.": "", @@ -163,15 +176,19 @@ "Whitelisted regions: ": "", "Blacklisted regions: ": "", "Shared `x`": "", - "`x` views.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` views.": "", + "`x` views": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "Premieres in `x`": "", "Premieres `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.": "", "View YouTube comments": "", "View more comments on Reddit": "", - "View `x` comments.([^.,0-9]|^)1([^.,0-9]|$)": "", - "View `x` comments.": "", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "View Reddit comments": "", "Hide replies": "", "Show replies": "", @@ -196,12 +213,16 @@ "This channel does not exist.": "", "Could not get channel info.": "", "Could not fetch comments": "", - "View `x` replies.([^.,0-9]|^)1([^.,0-9]|$)": "", - "View `x` replies.": "", + "View `x` replies": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "`x` ago": "", "Load more": "", - "`x` points.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` points.": "", + "`x` points": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "Could not create mix.": "", "Empty playlist": "", "Not a playlist.": "", @@ -319,20 +340,34 @@ "Yiddish": "", "Yoruba": "", "Zulu": "", - "`x` years.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` years.": "", - "`x` months.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` months.": "", - "`x` weeks.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` weeks.": "", - "`x` days.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` days.": "", - "`x` hours.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` hours.": "", - "`x` minutes.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` minutes.": "", - "`x` seconds.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` seconds.": "", + "`x` years": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` months": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` weeks": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` days": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` hours": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` minutes": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` seconds": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "Fallback comments: ": "", "Popular": "", "Search": "", @@ -358,6 +393,33 @@ "Videos": "", "Playlists": "", "Community": "", + "relevance": "", + "rating": "", + "date": "", + "views": "", + "content_type": "", + "duration": "", + "features": "", + "sort": "", + "hour": "", + "today": "", + "week": "", + "month": "", + "year": "", + "video": "", + "channel": "", + "playlist": "", + "movie": "", + "show": "", + "hd": "", + "subtitles": "", + "creative_commons": "", + "3d": "", + "live": "", + "4k": "", + "location": "", + "hdr": "", + "filter": "", "Current version: ": "", "next_steps_error_message": "", "next_steps_error_message_refresh": "", diff --git a/locales/cs.json b/locales/cs.json index c8320a07..abb2d503 100644 --- a/locales/cs.json +++ b/locales/cs.json @@ -9,7 +9,7 @@ }, "`x` playlists": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` playlist", - "": "`x` playlisty." + "": "`x` playlisty" }, "LIVE": "ŽIVĚ", "Shared `x` ago": "Sdíleno před `x`", @@ -87,7 +87,7 @@ "light": "světlý", "Thin mode: ": "Kompaktní režim: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Nastavení předplatných", "Show annotations by default for subscribed channels: ": "Ve výchozím nastavení zobrazovat poznámky u odebíraných kanálů: ", "Redirect homepage to feed: ": "Přesměrovávat domovskou stránku na informační kanál: ", @@ -117,8 +117,9 @@ "Administrator preferences": "Administrátorská nastavení", "Default homepage: ": "Základní domovská stránka: ", "Feed menu: ": "Menu doporučených: ", + "Show nickname on top: ": "", "Top enabled: ": "", - "CAPTCHA enabled: ": "CAPTCHA povolena: ", + "CAPTCHA enabled: ": "CAPTCHA povolen: ", "Login enabled: ": "Přihlášení povoleno: ", "Registration enabled: ": "Registrace povolena ", "Report statistics: ": "Oznámit statistiky: ", @@ -233,112 +234,112 @@ "Erroneous token": "", "No such user": "", "Token is expired, please try again": "", - "English": "", - "English (auto-generated)": "", - "Afrikaans": "", - "Albanian": "", - "Amharic": "", - "Arabic": "", - "Armenian": "", - "Azerbaijani": "", - "Bangla": "", - "Basque": "", - "Belarusian": "", - "Bosnian": "", - "Bulgarian": "", - "Burmese": "", - "Catalan": "", - "Cebuano": "", - "Chinese (Simplified)": "", - "Chinese (Traditional)": "", - "Corsican": "", - "Croatian": "", - "Czech": "", - "Danish": "", - "Dutch": "", - "Esperanto": "", - "Estonian": "", - "Filipino": "", - "Finnish": "", - "French": "", - "Galician": "", - "Georgian": "", - "German": "", - "Greek": "", - "Gujarati": "", - "Haitian Creole": "", - "Hausa": "", - "Hawaiian": "", - "Hebrew": "", - "Hindi": "", - "Hmong": "", - "Hungarian": "", - "Icelandic": "", - "Igbo": "", - "Indonesian": "", - "Irish": "", - "Italian": "", - "Japanese": "", - "Javanese": "", - "Kannada": "", - "Kazakh": "", - "Khmer": "", - "Korean": "", - "Kurdish": "", - "Kyrgyz": "", - "Lao": "", - "Latin": "", - "Latvian": "", - "Lithuanian": "", - "Luxembourgish": "", - "Macedonian": "", - "Malagasy": "", - "Malay": "", - "Malayalam": "", - "Maltese": "", - "Maori": "", - "Marathi": "", - "Mongolian": "", - "Nepali": "", - "Norwegian Bokmål": "", - "Nyanja": "", - "Pashto": "", - "Persian": "", - "Polish": "", - "Portuguese": "", - "Punjabi": "", - "Romanian": "", - "Russian": "", - "Samoan": "", - "Scottish Gaelic": "", - "Serbian": "", - "Shona": "", - "Sindhi": "", - "Sinhala": "", - "Slovak": "", - "Slovenian": "", - "Somali": "", - "Southern Sotho": "", - "Spanish": "", - "Spanish (Latin America)": "", - "Sundanese": "", - "Swahili": "", - "Swedish": "", - "Tajik": "", - "Tamil": "", - "Telugu": "", - "Thai": "", - "Turkish": "", - "Ukrainian": "", - "Urdu": "", - "Uzbek": "", - "Vietnamese": "", - "Welsh": "", - "Western Frisian": "", - "Xhosa": "", - "Yiddish": "", - "Yoruba": "", - "Zulu": "", + "English": "Angličtina", + "English (auto-generated)": "Angličtina (automaticky generováno)", + "Afrikaans": "Afrikánština", + "Albanian": "Albánština", + "Amharic": "Amharština", + "Arabic": "Arabština", + "Armenian": "Arménština", + "Azerbaijani": "Azerbajdžánština", + "Bangla": "Bengálština", + "Basque": "Baskičtina", + "Belarusian": "Běloruština", + "Bosnian": "Bosenština", + "Bulgarian": "Bulharština", + "Burmese": "Barmština", + "Catalan": "Katalánština", + "Cebuano": "Cebuánština", + "Chinese (Simplified)": "Čínština (zjednodušená)", + "Chinese (Traditional)": "Čínština (tradiční)", + "Corsican": "Korsičtina", + "Croatian": "Chorvatština", + "Czech": "Čeština", + "Danish": "Dánština", + "Dutch": "Nizozemština", + "Esperanto": "Esperanto", + "Estonian": "Estonština", + "Filipino": "Filipínština", + "Finnish": "Finština", + "French": "Francouzština", + "Galician": "Galicijština", + "Georgian": "Gruzínština", + "German": "Němčina", + "Greek": "Řečtina", + "Gujarati": "Gudžarátština", + "Haitian Creole": "Haitská kreolština", + "Hausa": "Hauština", + "Hawaiian": "Havajština", + "Hebrew": "Hebrejština", + "Hindi": "Hindština", + "Hmong": "Hmongština", + "Hungarian": "Maďarština", + "Icelandic": "Islandština", + "Igbo": "Igboština", + "Indonesian": "Indonéština", + "Irish": "Irština", + "Italian": "Italština", + "Japanese": "Japonština", + "Javanese": "Javánština", + "Kannada": "Kannadština", + "Kazakh": "Kazaština", + "Khmer": "Khmerština", + "Korean": "Korejština", + "Kurdish": "Kurdština", + "Kyrgyz": "Kyrgyzština", + "Lao": "Laoština", + "Latin": "Latina", + "Latvian": "Lotyština", + "Lithuanian": "Litevština", + "Luxembourgish": "Lucemburština", + "Macedonian": "Makedonština", + "Malagasy": "Malgaština", + "Malay": "Malajština", + "Malayalam": "Malajálamština", + "Maltese": "Maltština", + "Maori": "Maorština", + "Marathi": "Maráthština", + "Mongolian": "Mongolština", + "Nepali": "Nepálština", + "Norwegian Bokmål": "Norština Bokmål", + "Nyanja": "Čičevština", + "Pashto": "Paštština", + "Persian": "Perština", + "Polish": "Polština", + "Portuguese": "Portugalština", + "Punjabi": "Paňdžábština", + "Romanian": "Rumunština", + "Russian": "Ruština", + "Samoan": "Samojština", + "Scottish Gaelic": "Skotská gaelština", + "Serbian": "Srbština", + "Shona": "Shona", + "Sindhi": "Sindhština", + "Sinhala": "Sinhálština", + "Slovak": "Slovenština", + "Slovenian": "Slovinština", + "Somali": "Somálština", + "Southern Sotho": "Sesothština", + "Spanish": "Španělština", + "Spanish (Latin America)": "Španělština (Latinská Amerika)", + "Sundanese": "Sundština", + "Swahili": "Svahilština", + "Swedish": "Švédština", + "Tajik": "Tádžičtina", + "Tamil": "Tamilština", + "Telugu": "Telugština", + "Thai": "Thajština", + "Turkish": "Turečtina", + "Ukrainian": "Ukrajinština", + "Urdu": "Urdština", + "Uzbek": "Uzbečtina", + "Vietnamese": "Vietnamština", + "Welsh": "Velština", + "Western Frisian": "Západofríština", + "Xhosa": "Xhoština", + "Yiddish": "Jidiš", + "Yoruba": "Jorubština", + "Zulu": "Zuluština", "`x` years": { "([^.,0-9]|^)1([^.,0-9]|$)": "", "": "" @@ -368,18 +369,18 @@ "": "" }, "Fallback comments: ": "", - "Popular": "", + "Popular": "Populární", "Search": "", "Top": "", "About": "Informace", "Rating: ": "Hodnocení: ", "Language: ": "Jazyk: ", "View as playlist": "", - "Default": "", + "Default": "Výchozí", "Music": "Hudba", - "Gaming": "", + "Gaming": "Hry", "News": "Zprávy", - "Movies": "", + "Movies": "Filmy", "Download": "Stáhnout", "Download as: ": "Stáhnout jako: ", "%A %B %-d, %Y": "", @@ -407,12 +408,12 @@ "year": "rok", "video": "video", "channel": "kanál", - "playlist": "", - "movie": "", + "playlist": "playlist", + "movie": "film", "show": "zobrazit", "hd": "HD", "subtitles": "titulky", - "creative_commons": "", + "creative_commons": "Creative Commons", "3d": "3D", "live": "živě", "4k": "4k", diff --git a/locales/da.json b/locales/da.json index d207939c..fca62ecf 100644 --- a/locales/da.json +++ b/locales/da.json @@ -87,7 +87,7 @@ "light": "lys", "Thin mode: ": "Tynd tilstand: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Abonnements præferencer", "Show annotations by default for subscribed channels: ": "Vis annotationer som standard for abonnerede kanaler: ", "Redirect homepage to feed: ": "Omdiriger startside til feed: ", @@ -117,6 +117,7 @@ "Administrator preferences": "Administrator præferencer", "Default homepage: ": "Standard startside: ", "Feed menu: ": "Feed menu: ", + "Show nickname on top: ": "", "Top enabled: ": "Top aktiveret: ", "CAPTCHA enabled: ": "CAPTCHA aktiveret: ", "Login enabled: ": "Login aktiveret: ", @@ -186,7 +187,7 @@ "View more comments on Reddit": "Se flere kommentarer på Reddit", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` kommentarer.([^.,0-9]|^)1([^.,0-9]|$)", - "": "Vis `x` kommentarer." + "": "Vis `x` kommentarer" }, "View Reddit comments": "Vis Reddit kommentarer", "Hide replies": "Skjul svar", @@ -213,14 +214,14 @@ "Could not get channel info.": "Kunne ikke hente kanal info.", "Could not fetch comments": "Kunne ikke hente kommentarer", "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` besvarelser.([^.,0-9]|^)1([^.,0-9]|$)", - "": "Vis 'x' besvarelser." + "([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` besvarelser", + "": "Vis 'x' besvarelser" }, "`x` ago": "'x' siden", "Load more": "Hent flere", "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` point.([^.,0-9]|^)1([^.,0-9]|$)", - "": "'x' point." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` point", + "": "'x' point" }, "Could not create mix.": "Kunne ikke skabe blanding.", "Empty playlist": "Tom playliste", diff --git a/locales/eo.json b/locales/eo.json index f78c27cf..e3970159 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -86,8 +86,8 @@ "dark": "malhela", "light": "hela", "Thin mode: ": "Maldika reĝimo: ", - "Miscellaneous preferences": "", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", + "Miscellaneous preferences": "Aliaj agordoj", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Aŭtomata alidirektado de instalaĵo (retropaŝo al redirect.invidious.io): ", "Subscription preferences": "Abonaj agordoj", "Show annotations by default for subscribed channels: ": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ", "Redirect homepage to feed: ": "Alidirekti hejmpâgon al fluo: ", @@ -117,7 +117,7 @@ "Administrator preferences": "Agordoj de administranto", "Default homepage: ": "Defaŭlta hejmpaĝo: ", "Feed menu: ": "Flua menuo: ", - "Show nickname on top: ": "", + "Show nickname on top: ": "Montri kromnomon supre: ", "Top enabled: ": "Ĉu pli bonaj ŝaltitaj? ", "CAPTCHA enabled: ": "Ĉu CAPTCHA ŝaltita? ", "Login enabled: ": "Ĉu ensaluto aktivita? ", @@ -164,8 +164,8 @@ "Show more": "Montri pli", "Show less": "Montri malpli", "Watch on YouTube": "Vidi filmeton en JuTubo", - "Switch Invidious Instance": "", - "Broken? Try another Invidious Instance": "", + "Switch Invidious Instance": "Ŝanĝi instalaĵon de Indivious", + "Broken? Try another Invidious Instance": "Ĉu misfunkcio? Provu alian instalaĵon de Indivious", "Hide annotations": "Kaŝi prinotojn", "Show annotations": "Montri prinotojn", "Genre: ": "Ĝenro: ", @@ -178,7 +178,7 @@ "Shared `x`": "Konigita `x`", "`x` views": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` spektaĵoj", - "": "`x` spektaĵoj." + "": "`x` spektaĵoj" }, "Premieres in `x`": "Premieras en `x`", "Premieres `x`": "Premieras `x`", @@ -187,7 +187,7 @@ "View more comments on Reddit": "Vidi pli komentoj en Reddit", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "Vidi `x` komentojn", - "": "Vidi `x` komentojn." + "": "Vidi `x` komentojn" }, "View Reddit comments": "Vidi komentojn de Reddit", "Hide replies": "Kaŝi respondojn", @@ -215,13 +215,13 @@ "Could not fetch comments": "Ne povis venigi komentojn", "View `x` replies": { "([^.,0-9]|^)1([^.,0-9]|$)": "Vidi `x` respondojn", - "": "Vidi `x` respondojn." + "": "Vidi `x` respondojn" }, "`x` ago": "antaŭ `x`", "Load more": "Ŝarĝi pli", "`x` points": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` poentoj", - "": "`x` poentoj." + "": "`x` poentoj" }, "Could not create mix.": "Ne povis krei mikson.", "Empty playlist": "Ludlisto estas malplena", @@ -342,31 +342,31 @@ "Zulu": "Zulua", "`x` years": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jaroj", - "": "`x` jaroj." + "": "`x` jaroj" }, "`x` months": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` monatoj", - "": "`x` monatoj." + "": "`x` monatoj" }, "`x` weeks": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semajnoj", - "": "`x` semajnoj." + "": "`x` semajnoj" }, "`x` days": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tagoj", - "": "`x` tagoj." + "": "`x` tagoj" }, "`x` hours": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` horoj", - "": "`x` horoj." + "": "`x` horoj" }, "`x` minutes": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutoj", - "": "`x` minutoj." + "": "`x` minutoj" }, "`x` seconds": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekundoj", - "": "`x` sekundoj." + "": "`x` sekundoj" }, "Fallback comments: ": "Retrodefaŭltaj komentoj: ", "Popular": "Popularaj", diff --git a/locales/es.json b/locales/es.json index 894e3b0d..ee93298c 100644 --- a/locales/es.json +++ b/locales/es.json @@ -5,7 +5,7 @@ }, "`x` videos": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vídeos", - "": "`x` vídeos." + "": "`x` vídeos" }, "`x` playlists": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` listas de reproducción", @@ -178,7 +178,7 @@ "Shared `x`": "Compartido `x`", "`x` views": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizaciones", - "": "`x` visualizaciones." + "": "`x` visualizaciones" }, "Premieres in `x`": "Se estrena en `x`", "Premieres `x`": "Estrenos `x`", @@ -187,7 +187,7 @@ "View more comments on Reddit": "Ver más comentarios en Reddit", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentarios", - "": "Ver `x` comentarios." + "": "Ver `x` comentarios" }, "View Reddit comments": "Ver los comentarios de Reddit", "Hide replies": "Ocultar las respuestas", @@ -215,13 +215,13 @@ "Could not fetch comments": "No se han podido recuperar los comentarios", "View `x` replies": { "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` respuestas", - "": "Ver `x` respuestas." + "": "Ver `x` respuestas" }, "`x` ago": "hace `x`", "Load more": "Cargar más", "`x` points": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` puntos", - "": "`x` puntos." + "": "`x` puntos" }, "Could not create mix.": "No se ha podido crear la mezcla.", "Empty playlist": "La lista de reproducción está vacía", @@ -342,31 +342,31 @@ "Zulu": "Zulú", "`x` years": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` años", - "": "`x` años." + "": "`x` años" }, "`x` months": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` meses", - "": "`x` meses." + "": "`x` meses" }, "`x` weeks": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semanas", - "": "`x` semanas." + "": "`x` semanas" }, "`x` days": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` días", - "": "`x` días." + "": "`x` días" }, "`x` hours": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` horas", - "": "`x` horas." + "": "`x` horas" }, "`x` minutes": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutos", - "": "`x` minutos." + "": "`x` minutos" }, "`x` seconds": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundos", - "": "`x` segundos." + "": "`x` segundos" }, "Fallback comments: ": "Comentarios alternativos: ", "Popular": "Populares", diff --git a/locales/eu.json b/locales/eu.json index 34820a50..87d0b902 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -1,7 +1,16 @@ { - "`x` subscribers": "`x` harpidedun", - "`x` videos": "`x` bideo", - "`x` playlists": "`x` erreprodukzio-zerrenda", + "`x` subscribers": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` harpidedun" + }, + "`x` videos": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` bideo" + }, + "`x` playlists": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` erreprodukzio-zerrenda" + }, "LIVE": "ZUZENEAN", "Shared `x` ago": "Duela `x` partekatua", "Unsubscribe": "Harpidetza kendu", @@ -78,7 +87,7 @@ "light": "argia", "Thin mode: ": "", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Harpidetzen hobespenak", "Show annotations by default for subscribed channels: ": "", "Redirect homepage to feed: ": "", @@ -108,6 +117,7 @@ "Administrator preferences": "", "Default homepage: ": "", "Feed menu: ": "", + "Show nickname on top: ": "", "Top enabled: ": "", "CAPTCHA enabled: ": "", "Login enabled: ": "", @@ -117,13 +127,22 @@ "Subscription manager": "", "Token manager": "", "Token": "", - "`x` subscriptions": "", - "`x` tokens": "", + "`x` subscriptions": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` tokens": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "Import/export": "", "unsubscribe": "", "revoke": "", "Subscriptions": "", - "`x` unseen notifications": "", + "`x` unseen notifications": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "search": "", "Log out": "", "Released under the AGPLv3 by Omar Roth.": "", @@ -157,13 +176,19 @@ "Whitelisted regions: ": "", "Blacklisted regions: ": "", "Shared `x`": "", - "`x` views": "", + "`x` views": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "Premieres in `x`": "", "Premieres `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.": "", "View YouTube comments": "", "View more comments on Reddit": "", - "View `x` comments": "", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "View Reddit comments": "", "Hide replies": "", "Show replies": "", @@ -188,10 +213,16 @@ "This channel does not exist.": "", "Could not get channel info.": "", "Could not fetch comments": "", - "View `x` replies": "", + "View `x` replies": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "`x` ago": "", "Load more": "", - "`x` points": "", + "`x` points": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "Could not create mix.": "", "Empty playlist": "", "Not a playlist.": "", @@ -309,13 +340,34 @@ "Yiddish": "", "Yoruba": "", "Zulu": "", - "`x` years": "", - "`x` months": "", - "`x` weeks": "", - "`x` days": "", - "`x` hours": "", - "`x` minutes": "", - "`x` seconds": "", + "`x` years": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` months": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` weeks": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` days": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` hours": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` minutes": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` seconds": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "Fallback comments: ": "", "Popular": "", "Search": "", @@ -341,6 +393,33 @@ "Videos": "", "Playlists": "", "Community": "", + "relevance": "", + "rating": "", + "date": "", + "views": "", + "content_type": "", + "duration": "", + "features": "", + "sort": "", + "hour": "", + "today": "", + "week": "", + "month": "", + "year": "", + "video": "", + "channel": "", + "playlist": "", + "movie": "", + "show": "", + "hd": "", + "subtitles": "", + "creative_commons": "", + "3d": "", + "live": "", + "4k": "", + "location": "", + "hdr": "", + "filter": "", "Current version: ": "", "next_steps_error_message": "", "next_steps_error_message_refresh": "", diff --git a/locales/fr.json b/locales/fr.json index b3931b36..9c6bd8eb 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -77,8 +77,8 @@ "Fallback captions: ": "Sous-titres alternatifs : ", "Show related videos: ": "Voir les vidéos liées : ", "Show annotations by default: ": "Afficher les annotations par défaut : ", - "Automatically extend video description: ": "", - "Interactive 360 degree videos: ": "", + "Automatically extend video description: ": "Etendre automatiquement la description : ", + "Interactive 360 degree videos: ": "Vidéos interactives à 360° : ", "Visual preferences": "Préférences du site", "Player style: ": "Style du lecteur : ", "Dark mode: ": "Mode sombre : ", @@ -86,9 +86,9 @@ "dark": "sombre", "light": "clair", "Thin mode: ": "Mode léger : ", + "Miscellaneous preferences": "Paramètres divers", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Redirection vers une autre instance automatique (via redirect.invidious.io) : ", "Subscription preferences": "Préférences des abonnements", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", - "Miscellaneous preferences": "", "Show annotations by default for subscribed channels: ": "Afficher les annotations par défaut sur les chaînes auxquelles vous êtes abonnés : ", "Redirect homepage to feed: ": "Rediriger la page d'accueil vers la page d'abonnements : ", "Number of videos shown in feed: ": "Nombre de vidéos affichées dans la page d'abonnements : ", @@ -117,7 +117,7 @@ "Administrator preferences": "Préferences d'Administration", "Default homepage: ": "Page d'accueil par défaut : ", "Feed menu: ": "Préferences des abonnements : ", - "Show nickname on top: ": "", + "Show nickname on top: ": "Afficher le nom d'utilisateur en haut à droite : ", "Top enabled: ": "Top activé : ", "CAPTCHA enabled: ": "CAPTCHA activé : ", "Login enabled: ": "Autoriser l'ouverture de sessions utilisateur : ", @@ -161,11 +161,11 @@ "Title": "Titre", "Playlist privacy": "Paramètres de confidentialité de la liste de lecture", "Editing playlist `x`": "Liste de lecture modifier le `x`", - "Show more": "", - "Show less": "", + "Show more": "Afficher plus", + "Show less": "Afficher moins", "Watch on YouTube": "Voir la vidéo sur Youtube", - "Switch Invidious Instance": "", - "Broken? Try another Invidious Instance": "", + "Switch Invidious Instance": "Changer d'instance", + "Broken? Try another Invidious Instance": "Instance Invidious défectueuse ? Essayez-en une autre", "Hide annotations": "Masquer les annotations", "Show annotations": "Afficher les annotations", "Genre: ": "Genre : ", @@ -410,7 +410,7 @@ "channel": "chaîne", "playlist": "liste de lecture", "movie": "film", - "show": "affichage", + "show": "émission", "hd": "HD", "subtitles": "sous-titres / CC", "creative_commons": "Creative Commons", @@ -421,7 +421,7 @@ "hdr": "HDR", "filter": "filtrer", "Current version: ": "Version actuelle : ", - "next_steps_error_message": "", - "next_steps_error_message_refresh": "", - "next_steps_error_message_go_to_youtube": "" + "next_steps_error_message": "Vous pouvez essayer de : ", + "next_steps_error_message_refresh": "Rafraîchir la page", + "next_steps_error_message_go_to_youtube": "Aller sur Youtube" } diff --git a/locales/he.json b/locales/he.json index cb3f94e5..5d7f85c6 100644 --- a/locales/he.json +++ b/locales/he.json @@ -178,7 +178,7 @@ "Shared `x`": "", "`x` views": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` צפיות.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` צפיות." + "": "`x` צפיות" }, "Premieres in `x`": "", "Premieres `x`": "", @@ -187,7 +187,7 @@ "View more comments on Reddit": "להצגת תגובות נוספות ב־Reddit", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "הצגת `x` תגובות.([^.,0-9]|^)1([^.,0-9]|$)", - "": "הצגת `x` תגובות." + "": "הצגת `x` תגובות" }, "View Reddit comments": "להצגת התגובות ב־Reddit", "Hide replies": "הסתרת תגובות", @@ -214,8 +214,8 @@ "Could not get channel info.": "לא היה ניתן לקבל מידע על הערוץ.", "Could not fetch comments": "לא היה ניתן למשוך את התגובות", "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "הצגת `x` תגובות.([^.,0-9]|^)1([^.,0-9]|$)", - "": "הצגת `x` תגובות." + "([^.,0-9]|^)1([^.,0-9]|$)": "הצגת `x` תגובות", + "": "הצגת `x` תגובות" }, "`x` ago": "לפני `x`", "Load more": "לטעון עוד", @@ -341,32 +341,32 @@ "Yoruba": "יורובה", "Zulu": "זולו", "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שנים.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` שנים." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שנים", + "": "`x` שנים" }, "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` חודשים.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` חודשים." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` חודשים", + "": "`x` חודשים" }, "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שבועות.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` שבועות." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שבועות", + "": "`x` שבועות" }, "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ימים.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` ימים." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ימים", + "": "`x` ימים" }, "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שעות.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` שעות." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שעות", + "": "`x` שעות" }, "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` דקות.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` דקות." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` דקות", + "": "`x` דקות" }, "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שניות.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` שניות." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שניות", + "": "`x` שניות" }, "Fallback comments: ": "", "Popular": "סרטונים פופולריים", diff --git a/locales/hr.json b/locales/hr.json index d278a6ef..fd978edb 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -86,8 +86,8 @@ "dark": "tamno", "light": "svijetlo", "Thin mode: ": "Pojednostavljen prikaz: ", - "Miscellaneous preferences": "", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", + "Miscellaneous preferences": "Razne postavke", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatsko preusmjeravanje instance (u krajnjem slučaju koristi redirect.invidious.io): ", "Subscription preferences": "Postavke pretplata", "Show annotations by default for subscribed channels: ": "Standardno prikaži napomene za pretplaćene kanale: ", "Redirect homepage to feed: ": "Preusmjeri početnu stranicu na feed: ", @@ -117,7 +117,7 @@ "Administrator preferences": "Postavke administratora", "Default homepage: ": "Standardna početna stranica: ", "Feed menu: ": "Izbornik za feedove: ", - "Show nickname on top: ": "", + "Show nickname on top: ": "Prikaži nadimak na vrhu: ", "Top enabled: ": "Najbolji aktivirani: ", "CAPTCHA enabled: ": "Aktivirani CAPTCHA: ", "Login enabled: ": "Prijava aktivirana: ", @@ -164,8 +164,8 @@ "Show more": "Pokaži više", "Show less": "Pokaži manje", "Watch on YouTube": "Gledaj na YouTubeu", - "Switch Invidious Instance": "", - "Broken? Try another Invidious Instance": "", + "Switch Invidious Instance": "Promijeni Invidious instancu", + "Broken? Try another Invidious Instance": "Pokvarena? Probaj jednu drugu Invidious instancu", "Hide annotations": "Sakrij napomene", "Show annotations": "Prikaži napomene", "Genre: ": "Žanr: ", @@ -178,7 +178,7 @@ "Shared `x`": "Dijeljeno `x`", "`x` views": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` gledanja.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` gledanja." + "": "`x` gledanja" }, "Premieres in `x`": "Premijera za `x`", "Premieres `x`": "Premijera `x`", @@ -187,7 +187,7 @@ "View more comments on Reddit": "Prikaži još komentara na Redditu", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "Prikaži `x` komentara.([^.,0-9]|^)1([^.,0-9]|$)", - "": "Prikaži `x` komentara." + "": "Prikaži `x` komentara" }, "View Reddit comments": "Prikaži Reddit komentare", "Hide replies": "Sakrij odgovore", @@ -214,14 +214,14 @@ "Could not get channel info.": "Neuspjelo dobivanje podataka kanala.", "Could not fetch comments": "Neuspjelo dohvaćanje komentara", "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Prikaži `x` odgovora.([^.,0-9]|^)1([^.,0-9]|$)", - "": "Prikaži `x` odgovora." + "([^.,0-9]|^)1([^.,0-9]|$)": "Prikaži `x` odgovora", + "": "Prikaži `x` odgovora" }, "`x` ago": "prije `x`", "Load more": "Učitaj više", "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bodova.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` bodova." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bodova", + "": "`x` bodova" }, "Could not create mix.": "Neuspjelo stvaranje miksa.", "Empty playlist": "Prazna playlista", @@ -341,32 +341,32 @@ "Yoruba": "Jorubški", "Zulu": "Zulu", "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` g.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` g." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` g", + "": "`x` g" }, "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mj.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` mj." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mj", + "": "`x` mj" }, "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tj.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` tj." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tj", + "": "`x` tj" }, "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dana.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` dana." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dana", + "": "`x` dana" }, "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` h.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` h." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` h", + "": "`x` h" }, "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` min.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` min." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` min", + "": "`x` min" }, "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` s.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` s." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` s", + "": "`x` s" }, "Fallback comments: ": "Alternativni komentari: ", "Popular": "Popularni", diff --git a/locales/hu-HU.json b/locales/hu-HU.json index a0c6c17f..1c00bdbe 100644 --- a/locales/hu-HU.json +++ b/locales/hu-HU.json @@ -1,7 +1,16 @@ { - "`x` subscribers": "`x` feliratkozó", - "`x` videos": "`x` videó", - "`x` playlists": "`x` playlist", + "`x` subscribers": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` feliratkozó" + }, + "`x` videos": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` videó" + }, + "`x` playlists": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` playlist" + }, "LIVE": "ÉLŐ", "Shared `x` ago": "`x` óta megosztva", "Unsubscribe": "Leiratkozás", @@ -78,7 +87,7 @@ "light": "világos", "Thin mode: ": "Vékony mód: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Feliratkozási beállítások", "Show annotations by default for subscribed channels: ": "Szövegmagyarázatok mutatása alapértelmezésben feliratkozott csatornák esetében: ", "Redirect homepage to feed: ": "Kezdő oldal átirányitása a feed-re: ", @@ -108,6 +117,7 @@ "Administrator preferences": "Adminisztrátor beállítások", "Default homepage: ": "Alapértelmezett oldal: ", "Feed menu: ": "Feed menü: ", + "Show nickname on top: ": "", "Top enabled: ": "Top lista engedélyezve: ", "CAPTCHA enabled: ": "CAPTCHA engedélyezve: ", "Login enabled: ": "Bejelentkezés engedélyezve: ", @@ -117,13 +127,22 @@ "Subscription manager": "Feliratkozás kezelő", "Token manager": "Token kezelő", "Token": "Token", - "`x` subscriptions": "`x` feliratkozás", - "`x` tokens": "`x` token", + "`x` subscriptions": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` feliratkozás" + }, + "`x` tokens": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` token" + }, "Import/export": "Import/export", "unsubscribe": "leiratkozás", "revoke": "visszavonás", "Subscriptions": "Feliratkozások", - "`x` unseen notifications": "`x` kimaradt érdesítés", + "`x` unseen notifications": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` kimaradt érdesítés" + }, "search": "keresés", "Log out": "Kijelentkezés", "Released under the AGPLv3 by Omar Roth.": "Omar Roth által kiadva AGPLv3 licensz alatt.", @@ -145,10 +164,10 @@ "Show more": "Mutass többet", "Show less": "Mutass kevesebbet", "Watch on YouTube": "Megtekintés a YouTube-on", - "Hide annotations": "Szövegmagyarázat elrejtése", - "Show annotations": "Szövegmagyarázat mutatása", "Switch Invidious Instance": "", "Broken? Try another Invidious Instance": "", + "Hide annotations": "Szövegmagyarázat elrejtése", + "Show annotations": "Szövegmagyarázat mutatása", "Genre: ": "Műfaj: ", "License: ": "Licensz: ", "Family friendly? ": "Családbarát? ", @@ -157,19 +176,26 @@ "Whitelisted regions: ": "Engedélyezett régiók: ", "Blacklisted regions: ": "Tiltott régiók: ", "Shared `x`": "Megosztva `x`", - "`x` views": "`x` megtekintés", + "`x` views": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` megtekintés" + }, "Premieres in `x`": "premierel `x` múlva", "Premieres `x`": "`x`-t premierel", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Úgy látszik, hogy a JavaScript ki van kapcsolva a böngésződben. Kattints ide hogy megtekintsd a kommenteket, de tudd, hogy így kicsit tovább tarthat a betöltés.", "View YouTube comments": "YouTube kommentek megtekintése", "View more comments on Reddit": "További kommentek megtekintése Redditen", - "View `x` comments": "`x` komment megtekintése", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` komment megtekintése" + }, "View Reddit comments": "Reddit kommentek megtekintése", "Hide replies": "Válaszok elrejtése", "Show replies": "Válaszok mutatása", "Incorrect password": "Helytelen jelszó", "Quota exceeded, try again in a few hours": "Kvóta túllépve, próbálkozz pár órával később", "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Sikertelen bejelentkezés. Győződj meg róla, hogy a kétfaktoros hitelesítés (hitelesítő vagy SMS) engedélyezve van.", + "Invalid TFA code": "", "Login failed. This may be because two-factor authentication is not turned on for your account.": "Sikertelen bejelentkezés. Győződj meg róla, hogy a kétfaktoros hitelesítés engedélyezve van.", "Wrong answer": "Rossz válasz", "Erroneous CAPTCHA": "Hibás CAPTCHA", @@ -187,10 +213,16 @@ "This channel does not exist.": "Ez a csatorna nem létezik.", "Could not get channel info.": "Nem sikerült lekérni a csatorna adatokat.", "Could not fetch comments": "Nem sikerült lekérni a kommenteket", - "View `x` replies": "`x` válasz megtekintése", + "View `x` replies": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` válasz megtekintése" + }, "`x` ago": "`x` óta", "Load more": "További betöltése", - "`x` points": "`x` pont", + "`x` points": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` pont" + }, "Could not create mix.": "Nem tudok mix-et készíteni.", "Empty playlist": "Üres lejátszási lista", "Not a playlist.": "Nem lejátszási lista.", @@ -308,13 +340,34 @@ "Yiddish": "jiddis", "Yoruba": "joruba", "Zulu": "zulu", - "`x` years": "`x` év", - "`x` months": "`x` hónap", - "`x` weeks": "`x` hét", - "`x` days": "`x` nap", - "`x` hours": "`x` óra", - "`x` minutes": "`x` perc", - "`x` seconds": "`x` másodperc", + "`x` years": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` év" + }, + "`x` months": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` hónap" + }, + "`x` weeks": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` hét" + }, + "`x` days": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` nap" + }, + "`x` hours": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` óra" + }, + "`x` minutes": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` perc" + }, + "`x` seconds": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` másodperc" + }, "Fallback comments: ": "Másodlagos kommentek: ", "Popular": "Népszerű", "Search": "Keresés", @@ -340,6 +393,33 @@ "Videos": "Videók", "Playlists": "Lejátszási listák", "Community": "Közösség", + "relevance": "", + "rating": "", + "date": "", + "views": "", + "content_type": "", + "duration": "", + "features": "", + "sort": "", + "hour": "", + "today": "", + "week": "", + "month": "", + "year": "", + "video": "", + "channel": "", + "playlist": "", + "movie": "", + "show": "", + "hd": "", + "subtitles": "", + "creative_commons": "", + "3d": "", + "live": "", + "4k": "", + "location": "", + "hdr": "", + "filter": "", "Current version: ": "Jelenlegi verzió: ", "next_steps_error_message": "", "next_steps_error_message_refresh": "", diff --git a/locales/id.json b/locales/id.json index 0d81ff6a..d3558396 100644 --- a/locales/id.json +++ b/locales/id.json @@ -1,15 +1,15 @@ { "`x` subscribers": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pelanggan", - "": "`x` pelanggan." + "": "`x` pelanggan" }, "`x` videos": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video", - "": "`x` video." + "": "`x` video" }, "`x` playlists": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` daftar putar", - "": "`x` daftar putar." + "": "`x` daftar putar" }, "LIVE": "SIARAN LANGSUNG", "Shared `x` ago": "Dibagikan`x` lalu", @@ -117,7 +117,7 @@ "Administrator preferences": "Preferensi administrator", "Default homepage: ": "Laman beranda default: ", "Feed menu: ": "Menu umpan: ", - "Show nickname on top: ": "", + "Show nickname on top: ": "Tampilkan nama panggilan di atas: ", "Top enabled: ": "Teratas diaktifkan: ", "CAPTCHA enabled: ": "CAPTCHA diaktifkan: ", "Login enabled: ": "Masuk diaktifkan: ", @@ -129,11 +129,11 @@ "Token": "Token", "`x` subscriptions": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` langganan", - "": "`x` langganan." + "": "`x` langganan" }, "`x` tokens": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token", - "": "`x` token." + "": "`x` token" }, "Import/export": "Impor/ekspor", "unsubscribe": "batal langganan", @@ -141,7 +141,7 @@ "Subscriptions": "Langganan", "`x` unseen notifications": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pemberitahuan belum dilihat", - "": "`x` pemberitahuan belum dilihat." + "": "`x` pemberitahuan belum dilihat" }, "search": "cari", "Log out": "Keluar", @@ -178,7 +178,7 @@ "Shared `x`": "Berbagi`x`", "`x` views": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tampilan.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` tampilan." + "": "`x` tampilan" }, "Premieres in `x`": "Tayang dalam `x`", "Premieres `x`": "Tayang `x`", @@ -187,7 +187,7 @@ "View more comments on Reddit": "Lihat lebih banyak komentar di Reddit", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "Lihat`x` komentar.([^.,0-9]|^)1([^.,0-9]|$)", - "": "Lihat`x` komentar." + "": "Lihat`x` komentar" }, "View Reddit comments": "Lihat komentar Reddit", "Hide replies": "Sembunyikan balasan", @@ -214,14 +214,14 @@ "Could not get channel info.": "Tidak bisa mendapatkan info kanal.", "Could not fetch comments": "Tidak dapat memuat komentar", "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Lihat`x` balasan.([^.,0-9]|^)1([^.,0-9]|$)", - "": "Lihat `x` balasan." + "([^.,0-9]|^)1([^.,0-9]|$)": "Lihat`x` balasan", + "": "Lihat `x` balasan" }, "`x` ago": "`x` lalu", "Load more": "Muat lebih banyak", "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` titik.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` titik." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` titik", + "": "`x` titik" }, "Could not create mix.": "Tidak dapat membuat mix.", "Empty playlist": "Daftar putar kosong", @@ -341,32 +341,32 @@ "Yoruba": "Bahasa Yoruba", "Zulu": "Bahasa Zulu", "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tahun.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` tahun." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tahun", + "": "`x` tahun" }, "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bulan.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` bulan." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bulan", + "": "`x` bulan" }, "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pekan.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` pekan." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pekan", + "": "`x` pekan" }, "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hari.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` hari." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hari", + "": "`x` hari" }, "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jam.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` jam." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jam", + "": "`x` jam" }, "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` menit.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` menit." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` menit", + "": "`x` menit" }, "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` detik.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` detik." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` detik", + "": "`x` detik" }, "Fallback comments: ": "Komentar mundur: ", "Popular": "Populer", @@ -421,7 +421,7 @@ "hdr": "hdr", "filter": "saring", "Current version: ": "Versi saat ini: ", - "next_steps_error_message": "next_steps_error_message", + "next_steps_error_message": "", "next_steps_error_message_refresh": "", "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/lt.json b/locales/lt.json new file mode 100644 index 00000000..68d7437a --- /dev/null +++ b/locales/lt.json @@ -0,0 +1,427 @@ +{ + "`x` subscribers": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` prenumeratorius", + "": "`x` prenumeratoriai" + }, + "`x` videos": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vaizdo įrašas", + "": "`x` vaizdo įrašai" + }, + "`x` playlists": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` grojaraštis", + "": "`x` grojaraščiai" + }, + "LIVE": "LIVE", + "Shared `x` ago": "Pasidalino prieš `x`", + "Unsubscribe": "Atšaukti prenumeratą", + "Subscribe": "Prenumeruoti", + "View channel on YouTube": "Peržiūrėti kanalą YouTube", + "View playlist on YouTube": "Peržiūrėti grojaraštį YouTube", + "newest": "naujausia", + "oldest": "seniausia", + "popular": "populiaru", + "last": "paskutinis", + "Next page": "Kitas puslapis", + "Previous page": "Ankstesnis puslapis", + "Clear watch history?": "Išvalyti žiūrėjimo istoriją?", + "New password": "Naujas slaptažodis", + "New passwords must match": "Naujas slaptažodis turi sutapti", + "Cannot change password for Google accounts": "Negalima pakeisti Google paskyros slaptažodžio", + "Authorize token?": "Autorizuoti žetoną?", + "Authorize token for `x`?": "Autorizuoti žetoną `x`?", + "Yes": "Taip", + "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 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", + "Delete account?": "Ištrinti paskyrą?", + "History": "Istorija", + "An alternative front-end to YouTube": "Alternatyvus YouTube žiūrėjimo būdas", + "JavaScript license information": "JavaScript licencijos informacija", + "source": "šaltinis", + "Log in": "Prisijungti", + "Log in/register": "Prisijungti/ registruotis", + "Log in with Google": "Prisijungti naudojantis Google", + "User ID": "Naudotojo ID", + "Password": "Slaptažodis", + "Time (h:mm:ss):": "Laikas (h:mm:ss):", + "Text CAPTCHA": "CAPTCHA tekstas", + "Image CAPTCHA": "CAPTCHA paveikslėlis", + "Sign In": "Prisijungti", + "Register": "Registruotis", + "E-mail": "El. paštas", + "Google verification code": "Google patvirtinimo kodas", + "Preferences": "Pasirinktys", + "Player preferences": "Grotuvo pasirinktys", + "Always loop: ": "Visada kartoti: ", + "Autoplay: ": "Leisti automatiškai: ", + "Play next by default: ": "Leisti sekantį automatiškai kaip nustatyta: ", + "Autoplay next video: ": "Automatiškai leisti sekantį vaizdo įrašą: ", + "Listen by default: ": "Klausytis kaip nustatyta: ", + "Proxy videos: ": "Vaizdo įrašams naudoti proxy: ", + "Default speed: ": "Numatytasis greitis: ", + "Preferred video quality: ": "Pageidaujama vaizdo kokybė: ", + "Player volume: ": "Grotuvo garsas: ", + "Default comments: ": "Numatytieji komentarai: ", + "youtube": "youtube", + "reddit": "reddit", + "Default captions: ": "Numatytieji subtitrai: ", + "Fallback captions: ": "Atsarginiai subtitrai: ", + "Show related videos: ": "Rodyti susijusius vaizdo įrašus: ", + "Show annotations by default: ": "Rodyti anotacijas pagal nutylėjimą: ", + "Automatically extend video description: ": "Automatiškai išplėsti vaizdo įrašo aprašymą: ", + "Interactive 360 degree videos: ": "Interaktyvūs 360 laipsnių vaizdo įrašai: ", + "Visual preferences": "Vizualinės nuostatos", + "Player style: ": "Vaizdo grotuvo stilius: ", + "Dark mode: ": "Tamsus rėžimas: ", + "Theme: ": "Tema: ", + "dark": "tamsi", + "light": "šviesi", + "Thin mode: ": "Sugretintas rėžimas: ", + "Miscellaneous preferences": "Įvairios nuostatos", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatinis šaltinio nukreipimas (atsarginis nukreipimas į redirect.Invidous.io): ", + "Subscription preferences": "Prenumeratų nuostatos", + "Show annotations by default for subscribed channels: ": "Prenumeruojamiems kanalams subtitrus rodyti pagal nutylėjimą: ", + "Redirect homepage to feed: ": "Peradresuoti pagrindinį puslapį į kanalų sąrašą: ", + "Number of videos shown in feed: ": "Vaizdo įrašų kiekis kanalų sąraše: ", + "Sort videos by: ": "Rūšiuoti vaizdo įrašus pagal: ", + "published": "paskelbta", + "published - reverse": "paskelbta - atvirkštine tvarka", + "alphabetically": "pagal abėcėlę", + "alphabetically - reverse": "pagal abėcėlę - atvirkštine tvarka", + "channel name": "kanalo pavadinimas", + "channel name - reverse": "kanalo pavadinimas - atvirkštine tvarka", + "Only show latest video from channel: ": "Rodyti tik naujausius vaizdo įrašus iš kanalo: ", + "Only show latest unwatched video from channel: ": "Rodyti tik naujausius nežiūrėtus vaizdo įrašus iš kanalo: ", + "Only show unwatched: ": "Rodyti tik nežiūrėtus: ", + "Only show notifications (if there are any): ": "Rodyti tik pranešimus (jei yra): ", + "Enable web notifications": "Įgalinti žiniatinklio pranešimus", + "`x` uploaded a video": "`x` įkėlė vaizdo įrašą", + "`x` is live": "`x` transliuoja tiesiogiai", + "Data preferences": "Duomenų parinktys", + "Clear watch history": "Išvalyti žiūrėjimo istoriją", + "Import/export data": "Importuoti/ eksportuoti duomenis", + "Change password": "Pakeisti slaptažodį", + "Manage subscriptions": "Valdyti prenumeratas", + "Manage tokens": "Valdyti žetonus", + "Watch history": "Žiūrėjimo istorija", + "Delete account": "Ištrinti paskyrą", + "Administrator preferences": "Administratoriaus nuostatos", + "Default homepage: ": "Numatytasis pagrindinis puslapis ", + "Feed menu: ": "Kanalų sąrašo meniu: ", + "Show nickname on top: ": "Rodyti slapyvardį viršuje: ", + "Top enabled: ": "Įgalinti viršų: ", + "CAPTCHA enabled: ": "Įgalinta CAPTCHA: ", + "Login enabled: ": "Įgalintas prisijungimas: ", + "Registration enabled: ": "Įgalinta registracija: ", + "Report statistics: ": "Dalintis statistika: ", + "Save preferences": "Išsaugoti nuostatas", + "Subscription manager": "Prenumeratų valdytojas", + "Token manager": "Žetonų valdytojas", + "Token": "Žetonas", + "`x` subscriptions": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` prenumerata", + "": "`x` prenumeratos" + }, + "`x` tokens": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` žetonas", + "": "`x` žetonai" + }, + "Import/export": "Importuoti/ eksportuoti", + "unsubscribe": "atšaukti prenumeratą", + "revoke": "atšaukti", + "Subscriptions": "Prenumeratos", + "`x` unseen notifications": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` nematytas pranešimas", + "": "`x` nematyti pranešimai" + }, + "search": "ieškoti", + "Log out": "Atsijungti", + "Released under the AGPLv3 by Omar Roth.": "Išleista pagal AGPLv3 - Omar Roth.", + "Source available here.": "Kodas prieinamas čia.", + "View JavaScript license information.": "Žiūrėti JavaScript licencijos informaciją.", + "View privacy policy.": "Žiūrėti privatumo politiką.", + "Trending": "Populiarūs", + "Public": "Viešas", + "Unlisted": "Neįtrauktas į sąrašą", + "Private": "Neviešas", + "View all playlists": "Žiūrėti visus grojaraščius", + "Updated `x` ago": "Atnaujinta prieš `x`", + "Delete playlist `x`?": "Ištrinti grojaraštį `x`?", + "Delete playlist": "Ištrinti grojaraštį", + "Create playlist": "Sukurti grojaraštį", + "Title": "Pavadinimas", + "Playlist privacy": "Grojaraščio privatumas", + "Editing playlist `x`": "Redaguojamas grojaraštis `x`", + "Show more": "Rodyti daugiau", + "Show less": "Rodyti mažiau", + "Watch on YouTube": "Žiaurėti Youtube", + "Switch Invidious Instance": "Keisti Invidious šaltinį", + "Broken? Try another Invidious Instance": "Neveikia? Bandyk kitą Invidious šaltinį", + "Hide annotations": "Slėpti anotacijas", + "Show annotations": "Rodyti anotacijas", + "Genre: ": "Žanras: ", + "License: ": "Licencija: ", + "Family friendly? ": "Draugiška šeimai? ", + "Wilson score: ": "Wilson taškai: ", + "Engagement: ": "Įsitraukimas: ", + "Whitelisted regions: ": "Prieinantys regionai: ", + "Blacklisted regions: ": "Blokuojami regionai: ", + "Shared `x`": "Pasidalino `x`", + "`x` views": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` peržiūrų", + "": "`x` peržiūrų" + }, + "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.", + "View YouTube comments": "Žiūrėti YouTube komentarus", + "View more comments on Reddit": "Žiūrėti daugiau komentarų Reddit", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Žiūrėti `x` komentarus", + "": "Žiūrėti `x` komentarus" + }, + "View Reddit comments": "Žiūrėti Reddit komentarus", + "Hide replies": "Slėpti atsakymus", + "Show replies": "Rodyti atsakymus", + "Incorrect password": "Slaptažodis neteisingas", + "Quota exceeded, try again in a few hours": "Viršyta kvota, bandykite dar kartą po keleto valandų", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Nepavyko prisijungti, įsitikinkite, kad yra įjungta dviejų etapų autentifikacija (Autentifikatorius arba SMS).", + "Invalid TFA code": "Neteisingas TFA kodas", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "Prisijungimas nepavyko. Tai gali būti todėl, kad jūsų paskyroje nėra įjungta dviejų etapų autentifikacija.", + "Wrong answer": "Atsakymas neteisingas", + "Erroneous CAPTCHA": "Klaidinga CAPTCHA", + "CAPTCHA is a required field": "CAPTCHA yra reikalinga šiam laukeliui", + "User ID is a required field": "Vartotojo ID yra reikalingas šiam laukeliui", + "Password is a required field": "Slaptažodis yra reikalingas šiam laukeliui", + "Wrong username or password": "Neteisingas vartotojo vardas arba slaptažodis", + "Please sign in using 'Log in with Google'": "Prašome prisijungti naudojant \"Prisijungti su\" Google \"", + "Password cannot be empty": "Slaptažodžio laukelis negali būti tuščias", + "Password cannot be longer than 55 characters": "Slaptažodis negali būti ilgesnis nei 55 simboliai", + "Please log in": "Prašome prisijungti", + "Invidious Private Feed for `x`": "Invidious neviešas kanalų sąrašas `x`", + "channel:`x`": "kanalas:`x`", + "Deleted or invalid channel": "Panaikintas arba netinkamas kanalas", + "This channel does not exist.": "Šis kanalas neegzistuoja.", + "Could not get channel info.": "Nepavyko gauti kanalo informacijos.", + "Could not fetch comments": "Nepavyko atsiųsti komentarų", + "View `x` replies": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Žiūrėti `x` atsakymus", + "": "Žiūrėti `x` atsakymus" + }, + "`x` ago": "`x` prieš", + "Load more": "Pakrauti daugiau", + "`x` points": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` taškai", + "": "`x` taškai" + }, + "Could not create mix.": "Nepavyko sukurti derinio.", + "Empty playlist": "Tuščias grojaraštis", + "Not a playlist.": "Ne grojaraštis.", + "Playlist does not exist.": "Grojaraštis neegzistuoja.", + "Could not pull trending pages.": "Nepavyko pritraukti 'dabar populiaru' puslapių.", + "Hidden field \"challenge\" is a required field": "Paslėptas laukas „iššūkis“ yra privalomas laukas", + "Hidden field \"token\" is a required field": "Paslėptas laukas „žetonas“ yra privalomas laukas", + "Erroneous challenge": "Klaidingas iššūkis", + "Erroneous token": "Klaidingas žetonas", + "No such user": "Nėra tokio vartotojo", + "Token is expired, please try again": "Žetonas pasibaigęs, prašome bandyti dar kartą", + "English": "Anglų", + "English (auto-generated)": "Anglų (Sugeneruota automatiškai)", + "Afrikaans": "Afrikans", + "Albanian": "Albanų", + "Amharic": "Amharų", + "Arabic": "Arabų", + "Armenian": "Armėnų", + "Azerbaijani": "Azerbaidžanų", + "Bangla": "Bengalų", + "Basque": "Baskų", + "Belarusian": "Baltarusių", + "Bosnian": "Bosnių", + "Bulgarian": "Bulgarų", + "Burmese": "Birmiečių", + "Catalan": "Katalonų", + "Cebuano": "Cebuano", + "Chinese (Simplified)": "Kinų (supaprastinta)", + "Chinese (Traditional)": "Kinų (tradicinė)", + "Corsican": "Korsikiečių", + "Croatian": "Kroatų", + "Czech": "Čekų", + "Danish": "Danų", + "Dutch": "Nyderlandų", + "Esperanto": "Esperanto", + "Estonian": "Estų", + "Filipino": "Filipiniečių", + "Finnish": "Suomių", + "French": "Prancūzų", + "Galician": "Galicijos", + "Georgian": "Sakartveliečių", + "German": "Vokiečių", + "Greek": "Graikų", + "Gujarati": "Gujarati", + "Haitian Creole": "Haičio kreolė", + "Hausa": "Hausa", + "Hawaiian": "Havajiečių", + "Hebrew": "Hebrajų", + "Hindi": "Hindi", + "Hmong": "Hmong", + "Hungarian": "Vengrų", + "Icelandic": "Islandų", + "Igbo": "Igbo", + "Indonesian": "Indoneziečių", + "Irish": "Airių", + "Italian": "Italų", + "Japanese": "Japonų", + "Javanese": "Javos", + "Kannada": "Kannada", + "Kazakh": "Kazachų", + "Khmer": "Khmerų", + "Korean": "Korejiėčių", + "Kurdish": "Kurdų", + "Kyrgyz": "Kirgizų", + "Lao": "Lao", + "Latin": "Lotynų", + "Latvian": "Latvių", + "Lithuanian": "Lietuvių", + "Luxembourgish": "Liuksemburgiečių", + "Macedonian": "Šiaurės makedonų", + "Malagasy": "Malagasi", + "Malay": "Malajų", + "Malayalam": "Malayalam", + "Maltese": "Maltiečių", + "Maori": "Maori", + "Marathi": "Marathi", + "Mongolian": "Mongolų", + "Nepali": "Nepaliečių", + "Norwegian Bokmål": "Norvegų Bokmål", + "Nyanja": "Nyanja", + "Pashto": "Paštunų", + "Persian": "Persų", + "Polish": "Lenkų", + "Portuguese": "Portugalų", + "Punjabi": "Punjabi", + "Romanian": "Romėnų", + "Russian": "Rusų", + "Samoan": "Samoa", + "Scottish Gaelic": "Škotų Gaelic", + "Serbian": "Serbų", + "Shona": "Shona", + "Sindhi": "Sindhi", + "Sinhala": "Sinhala", + "Slovak": "Slovakų", + "Slovenian": "Slovėnų", + "Somali": "Somaliečių", + "Southern Sotho": "Pietų Sotho", + "Spanish": "Ispanų", + "Spanish (Latin America)": "Ispanų (Lotynų Amerika)", + "Sundanese": "Sudaniečių", + "Swahili": "Svahili", + "Swedish": "Švedų", + "Tajik": "Tadžikų", + "Tamil": "Tamilų", + "Telugu": "Telugų", + "Thai": "Talaindiečių", + "Turkish": "Turkų", + "Ukrainian": "Ukrainiečių", + "Urdu": "Udrų", + "Uzbek": "Uzbekų", + "Vietnamese": "Vietnamiečių", + "Welsh": "Velso", + "Western Frisian": "Vakarų Fryzų", + "Xhosa": "Xhosa", + "Yiddish": "Jidiš", + "Yoruba": "Yorubiečių", + "Zulu": "Zulu", + "`x` years": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` metus", + "": "`x` metus" + }, + "`x` months": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mėnesį", + "": "`x` mėnesius" + }, + "`x` weeks": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` savaitę", + "": "`x` savaites" + }, + "`x` days": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dieną", + "": "`x` dienas" + }, + "`x` hours": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x`valandą", + "": "`x` valandas" + }, + "`x` minutes": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutę", + "": "`x` minutes" + }, + "`x` seconds": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekundę", + "": "`x` sekundes" + }, + "Fallback comments: ": "Atsarginiai komentarai: ", + "Popular": "Šiuo metu populiaru", + "Search": "Paieška", + "Top": "Top", + "About": "Apie", + "Rating: ": "Reitingas: ", + "Language: ": "Kalba: ", + "View as playlist": "Žiūrėti kaip grojaraštį", + "Default": "Numatytasis", + "Music": "Muzika", + "Gaming": "Žaidimai", + "News": "Naujienos", + "Movies": "Filmai", + "Download": "Atsisiųsti", + "Download as: ": "Atsisiųsti kaip: ", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "(edited)": "(redaguota)", + "YouTube comment permalink": "YouTube komentaro adresas", + "permalink": "adresas", + "`x` marked it with a ❤": "`x` pažymėjo tai su ❤", + "Audio mode": "Garso rėžimas", + "Video mode": "Vaizdo rėžimas", + "Videos": "Vaizdo įrašai", + "Playlists": "Grojaraiščiai", + "Community": "Bendruomenė", + "relevance": "Aktualumas", + "rating": "Reitingas", + "date": "Įkėlimo data", + "views": "Peržiūrų skaičius", + "content_type": "Tipas", + "duration": "Trukmė", + "features": "Funkcijos", + "sort": "Rūšiuoti pagal", + "hour": "Per paskutinę valandą", + "today": "Šiandien", + "week": "Šią savaitę", + "month": "Šį mėnesį", + "year": "Šiais metais", + "video": "Vaizdo įrašas", + "channel": "Kanalas", + "playlist": "Grojaraštis", + "movie": "Filmas", + "show": "Serialas", + "hd": "HD", + "subtitles": "Subtitrai/CC", + "creative_commons": "Creative Commons", + "3d": "3D", + "live": "Tiesiogiai", + "4k": "4K", + "location": "Vietovė", + "hdr": "HDR", + "filter": "Filtras", + "Current version: ": "Dabartinė versija: ", + "next_steps_error_message": "Po to turėtumėte pabandyti: ", + "next_steps_error_message_refresh": "Atnaujinti", + "next_steps_error_message_go_to_youtube": "Eiti į Youtube" +} diff --git a/locales/nb-NO.json b/locales/nb-NO.json index 2eb30083..10723bfa 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -5,11 +5,11 @@ }, "`x` videos": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videoer", - "": "`x` videoer." + "": "`x` videoer" }, "`x` playlists": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` spillelister", - "": "`x` spillelister." + "": "`x` spillelister" }, "LIVE": "SANNTIDSVISNING", "Shared `x` ago": "Delt for `x` siden", @@ -78,7 +78,7 @@ "Show related videos: ": "Vis relaterte videoer? ", "Show annotations by default: ": "Vis merknader som forvalg? ", "Automatically extend video description: ": "Utvid videobeskrivelse automatisk: ", - "Interactive 360 degree videos: ": "", + "Interactive 360 degree videos: ": "Interaktive 360-gradersfilmer: ", "Visual preferences": "Visuelle innstillinger", "Player style: ": "Avspillerstil: ", "Dark mode: ": "Mørk drakt: ", @@ -86,8 +86,8 @@ "dark": "Mørk", "light": "Lys", "Thin mode: ": "Tynt modus: ", - "Miscellaneous preferences": "", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", + "Miscellaneous preferences": "Ulike innstillinger", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatisk instansomdirigering (faller tilbake til redirect.invidious.io): ", "Subscription preferences": "Abonnementsinnstillinger", "Show annotations by default for subscribed channels: ": "Vis merknader som forvalg for kanaler det abonneres på? ", "Redirect homepage to feed: ": "Videresend hjemmeside til kilde: ", @@ -117,7 +117,7 @@ "Administrator preferences": "Administratorinnstillinger", "Default homepage: ": "Forvalgt hjemmeside: ", "Feed menu: ": "Kilde-meny: ", - "Show nickname on top: ": "", + "Show nickname on top: ": "Vis kallenavn på toppen: ", "Top enabled: ": "Topp påskrudd? ", "CAPTCHA enabled: ": "CAPTCHA påskrudd? ", "Login enabled: ": "Innlogging påskrudd? ", @@ -164,8 +164,8 @@ "Show more": "Vis mer", "Show less": "Vis mindre", "Watch on YouTube": "Vis video på YouTube", - "Switch Invidious Instance": "", - "Broken? Try another Invidious Instance": "", + "Switch Invidious Instance": "Bytt Invidious-instans", + "Broken? Try another Invidious Instance": "Knekt? Forsøk en annen Invidious-instans", "Hide annotations": "Skjul merknader", "Show annotations": "Vis merknader", "Genre: ": "Sjanger: ", @@ -178,7 +178,7 @@ "Shared `x`": "Delt `x`", "`x` views": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visninger", - "": "`x` visninger." + "": "`x` visninger" }, "Premieres in `x`": "Premiere om `x`", "Premieres `x`": "Première `x`", @@ -187,7 +187,7 @@ "View more comments on Reddit": "Vis flere kommenterer på Reddit", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` kommentarer", - "": "Vis `x` kommentarer." + "": "Vis `x` kommentarer" }, "View Reddit comments": "Vis Reddit-kommentarer", "Hide replies": "Skjul svar", @@ -215,13 +215,13 @@ "Could not fetch comments": "Kunne ikke hente kommentarer", "View `x` replies": { "([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` svar", - "": "Vis `x` svar." + "": "Vis `x` svar" }, "`x` ago": "`x` siden", "Load more": "Last inn flere", "`x` points": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` poeng", - "": "`x` poeng." + "": "`x` poeng" }, "Could not create mix.": "Kunne ikke opprette miks.", "Empty playlist": "Spillelisten er tom", @@ -342,31 +342,31 @@ "Zulu": "Zulu", "`x` years": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` år", - "": "`x` år." + "": "`x` år" }, "`x` months": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` måneder", - "": "`x` måneder." + "": "`x` måneder" }, "`x` weeks": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` uker", - "": "`x` uker." + "": "`x` uker" }, "`x` days": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dager", - "": "`x` dager." + "": "`x` dager" }, "`x` hours": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` timer", - "": "`x` timer." + "": "`x` timer" }, "`x` minutes": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutter", - "": "`x` minutter." + "": "`x` minutter" }, "`x` seconds": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekunder", - "": "`x` sekunder." + "": "`x` sekunder" }, "Fallback comments: ": "Tilbakefallskommentarer: ", "Popular": "Populært", diff --git a/locales/nl.json b/locales/nl.json index 9597e1bd..c4948fd1 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -1,15 +1,15 @@ { "`x` subscribers": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnees", - "": "`x` abonnees." + "": "`x` abonnees" }, "`x` videos": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video's", - "": "`x` video's." + "": "`x` video's" }, "`x` playlists": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` afspeellijsten", - "": "`x` afspeellijsten." + "": "`x` afspeellijsten" }, "LIVE": "LIVE", "Shared `x` ago": "Gedeeld: `x` geleden", @@ -129,11 +129,11 @@ "Token": "Toegangssleutel", "`x` subscriptions": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnementen", - "": "`x` abonnementen." + "": "`x` abonnementen" }, "`x` tokens": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` toegangssleutels", - "": "`x` toegangssleutels." + "": "`x` toegangssleutels" }, "Import/export": "Importeren/Exporteren", "unsubscribe": "Deabonneren", @@ -178,7 +178,7 @@ "Shared `x`": "`x` gedeeld", "`x` views": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` weergaven", - "": "`x` weergaven." + "": "`x` weergaven" }, "Premieres in `x`": "Verschijnt over `x`", "Premieres `x`": "Verschijnt op `x`", @@ -187,7 +187,7 @@ "View more comments on Reddit": "Meer reacties bekijken op Reddit", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` reacties tonen", - "": "`x` reacties tonen." + "": "`x` reacties tonen" }, "View Reddit comments": "Reddit-reacties tonen", "Hide replies": "Antwoorden verbergen", @@ -215,13 +215,13 @@ "Could not fetch comments": "Kan reacties niet ophalen", "View `x` replies": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` antwoorden tonen", - "": "`x` antwoorden tonen." + "": "`x` antwoorden tonen" }, "`x` ago": "`x` geleden", "Load more": "Meer laden", "`x` points": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` punten", - "": "`x` punten." + "": "`x` punten" }, "Could not create mix.": "Kan geen mix maken.", "Empty playlist": "Lege afspeellijst", @@ -342,31 +342,31 @@ "Zulu": "Zulu", "`x` years": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jaar", - "": "`x` jaren." + "": "`x` jaren" }, "`x` months": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` maanden", - "": "`x` maanden." + "": "`x` maanden" }, "`x` weeks": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` weken", - "": "`x` weken." + "": "`x` weken" }, "`x` days": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dagen", - "": "`x` dagen." + "": "`x` dagen" }, "`x` hours": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` uur", - "": "`x` uren." + "": "`x` uren" }, "`x` minutes": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuten", - "": "`x` minuten." + "": "`x` minuten" }, "`x` seconds": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` seconden", - "": "`x` seconden." + "": "`x` seconden" }, "Fallback comments: ": "Terugvallen op ", "Popular": "Populair", diff --git a/locales/pt-BR.json b/locales/pt-BR.json index 5af56ddf..478847f2 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -86,8 +86,8 @@ "dark": "escuro", "light": "claro", "Thin mode: ": "Modo compacto: ", - "Miscellaneous preferences": "", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", + "Miscellaneous preferences": "Preferências diversas", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Redirecionamento de instância automática (fallback para redirect.invidious.io): ", "Subscription preferences": "Preferências de inscrições", "Show annotations by default for subscribed channels: ": "Sempre mostrar anotações dos vídeos de canais inscritos: ", "Redirect homepage to feed: ": "Redirecionar página inicial para o feed: ", @@ -117,7 +117,7 @@ "Administrator preferences": "Preferências de administrador", "Default homepage: ": "Página de início padrão: ", "Feed menu: ": "Menu do feed: ", - "Show nickname on top: ": "", + "Show nickname on top: ": "Mostrar o nickname no topo: ", "Top enabled: ": "Habilitar destaques: ", "CAPTCHA enabled: ": "Habilitar CAPTCHA: ", "Login enabled: ": "Habilitar login: ", @@ -164,8 +164,8 @@ "Show more": "Mostrar mais", "Show less": "Mostrar menos", "Watch on YouTube": "Assistir no YouTube", - "Switch Invidious Instance": "", - "Broken? Try another Invidious Instance": "", + "Switch Invidious Instance": "Mudar a instância do Invidious", + "Broken? Try another Invidious Instance": "Quebrou? Tente outra Instância do Invidious", "Hide annotations": "Ocultar anotações", "Show annotations": "Mostrar anotações", "Genre: ": "Gênero: ", diff --git a/locales/si.json b/locales/si.json index cbc9bdde..f59629d0 100644 --- a/locales/si.json +++ b/locales/si.json @@ -87,7 +87,7 @@ "light": "", "Thin mode: ": "", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "", "Show annotations by default for subscribed channels: ": "", "Redirect homepage to feed: ": "", @@ -117,6 +117,7 @@ "Administrator preferences": "", "Default homepage: ": "", "Feed menu: ": "", + "Show nickname on top: ": "", "Top enabled: ": "", "CAPTCHA enabled: ": "", "Login enabled: ": "", diff --git a/locales/sk.json b/locales/sk.json index 9330232e..32df0569 100644 --- a/locales/sk.json +++ b/locales/sk.json @@ -1,10 +1,16 @@ { - "`x` subscribers.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` subscribers.": "`x` odberateľov", - "`x` videos.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` videos.": "", - "`x` playlists.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` playlists.": "", + "`x` subscribers": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` odberateľov" + }, + "`x` videos": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` playlists": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "LIVE": "NAŽIVO", "Shared `x` ago": "", "Unsubscribe": "Zrušiť odber", @@ -81,7 +87,7 @@ "light": "svetlá", "Thin mode: ": "Tenký režim: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Nastavenia predplatného", "Show annotations by default for subscribed channels: ": "Predvolene zobraziť anotácie odoberaných kanálov: ", "Redirect homepage to feed: ": "Presmerovanie domovskej stránky na informačný kanál: ", @@ -111,6 +117,7 @@ "Administrator preferences": "", "Default homepage: ": "", "Feed menu: ": "", + "Show nickname on top: ": "", "Top enabled: ": "", "CAPTCHA enabled: ": "", "Login enabled: ": "", @@ -120,16 +127,22 @@ "Subscription manager": "", "Token manager": "", "Token": "", - "`x` subscriptions.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` subscriptions.": "", - "`x` tokens.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` tokens.": "", + "`x` subscriptions": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` tokens": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "Import/export": "", "unsubscribe": "", "revoke": "", "Subscriptions": "", - "`x` unseen notifications.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` unseen notifications.": "", + "`x` unseen notifications": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "search": "", "Log out": "", "Released under the AGPLv3 by Omar Roth.": "", @@ -163,15 +176,19 @@ "Whitelisted regions: ": "", "Blacklisted regions: ": "", "Shared `x`": "", - "`x` views.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` views.": "", + "`x` views": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "Premieres in `x`": "", "Premieres `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.": "", "View YouTube comments": "", "View more comments on Reddit": "", - "View `x` comments.([^.,0-9]|^)1([^.,0-9]|$)": "", - "View `x` comments.": "", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "View Reddit comments": "", "Hide replies": "", "Show replies": "", @@ -196,12 +213,16 @@ "This channel does not exist.": "", "Could not get channel info.": "", "Could not fetch comments": "", - "View `x` replies.([^.,0-9]|^)1([^.,0-9]|$)": "", - "View `x` replies.": "", + "View `x` replies": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "`x` ago": "", "Load more": "", - "`x` points.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` points.": "", + "`x` points": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "Could not create mix.": "", "Empty playlist": "", "Not a playlist.": "", @@ -319,20 +340,34 @@ "Yiddish": "", "Yoruba": "", "Zulu": "", - "`x` years.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` years.": "", - "`x` months.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` months.": "", - "`x` weeks.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` weeks.": "", - "`x` days.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` days.": "", - "`x` hours.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` hours.": "", - "`x` minutes.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` minutes.": "", - "`x` seconds.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` seconds.": "", + "`x` years": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` months": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` weeks": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` days": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` hours": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` minutes": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` seconds": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "Fallback comments: ": "", "Popular": "", "Search": "", @@ -358,6 +393,33 @@ "Videos": "", "Playlists": "", "Community": "", + "relevance": "", + "rating": "", + "date": "", + "views": "", + "content_type": "", + "duration": "", + "features": "", + "sort": "", + "hour": "", + "today": "", + "week": "", + "month": "", + "year": "", + "video": "", + "channel": "", + "playlist": "", + "movie": "", + "show": "", + "hd": "", + "subtitles": "", + "creative_commons": "", + "3d": "", + "live": "", + "4k": "", + "location": "", + "hdr": "", + "filter": "", "Current version: ": "", "next_steps_error_message": "", "next_steps_error_message_refresh": "", diff --git a/locales/sr.json b/locales/sr.json index 4835e9a3..83cc12c1 100644 --- a/locales/sr.json +++ b/locales/sr.json @@ -87,7 +87,7 @@ "light": "", "Thin mode: ": "", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "", "Show annotations by default for subscribed channels: ": "", "Redirect homepage to feed: ": "", @@ -117,6 +117,7 @@ "Administrator preferences": "", "Default homepage: ": "", "Feed menu: ": "", + "Show nickname on top: ": "", "Top enabled: ": "", "CAPTCHA enabled: ": "", "Login enabled: ": "", @@ -163,6 +164,8 @@ "Show more": "", "Show less": "", "Watch on YouTube": "", + "Switch Invidious Instance": "", + "Broken? Try another Invidious Instance": "", "Hide annotations": "", "Show annotations": "", "Genre: ": "", diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json index 7ac90fc8..92cfd103 100644 --- a/locales/sr_Cyrl.json +++ b/locales/sr_Cyrl.json @@ -1,7 +1,16 @@ { - "`x` subscribers.": "`x` пратилац", - "`x` videos.": "`x` видеа", - "`x` playlists.": "`x` плејлиста/е", + "`x` subscribers": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` пратилац" + }, + "`x` videos": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` видеа" + }, + "`x` playlists": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` плејлиста/е" + }, "LIVE": "УЖИВО", "Shared `x` ago": "Објављено пре `x`", "Unsubscribe": "Прекините праћење", @@ -78,7 +87,7 @@ "light": "светла", "Thin mode: ": "Узани режим: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Подешавања о праћењима", "Show annotations by default for subscribed channels: ": "Увек приказуј анотације за канале које пратим: ", "Redirect homepage to feed: ": "Прикажи праћења као почетну страницу: ", @@ -108,6 +117,7 @@ "Administrator preferences": "Подешавања администратора", "Default homepage: ": "Подразумевана главна страница: ", "Feed menu: ": "Мени довода: ", + "Show nickname on top: ": "", "Top enabled: ": "", "CAPTCHA enabled: ": "CAPTCHA укључена?: ", "Login enabled: ": "Пријава укључена?: ", @@ -117,13 +127,22 @@ "Subscription manager": "Управљање праћењима", "Token manager": "Управљање токенима", "Token": "Токен", - "`x` subscriptions.": "`x`праћења", - "`x` tokens.": "`x`токена", + "`x` subscriptions": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x`праћења" + }, + "`x` tokens": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x`токена" + }, "Import/export": "Увези/извези", "unsubscribe": "укини праћење", "revoke": "опозови", "Subscriptions": "Праћења", - "`x` unseen notifications.": "`x` непрочитаних обавештења", + "`x` unseen notifications": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` непрочитаних обавештења" + }, "search": "претрага", "Log out": "Одјавите се", "Released under the AGPLv3 by Omar Roth.": "Издао Омар Рот (Omar Roth) под условима AGPLv3 лиценце.", @@ -157,13 +176,19 @@ "Whitelisted regions: ": "Дозвољене области: ", "Blacklisted regions: ": "Забрањене области: ", "Shared `x`": "", - "`x` views.": "`x` прегледа.", + "`x` views": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "`x` прегледа" + }, "Premieres in `x`": "Емитује се уживо за `x`", "Premieres `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.": "Здраво! Изгледа да је искључен JavaScript. Кликните овде да бисте приказали коментаре. Требаће мало дуже да се учитају.", "View YouTube comments": "Прикажи коментаре са YouTube-а", "View more comments on Reddit": "Прикажи још коментара на Reddit-у", - "View `x` comments.": "", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "View Reddit comments": "Прикажи коментаре са Reddit-а", "Hide replies": "Сакриј одговоре", "Show replies": "Прикажи одговоре", @@ -188,10 +213,16 @@ "This channel does not exist.": "", "Could not get channel info.": "", "Could not fetch comments": "", - "View `x` replies.": "", + "View `x` replies": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "`x` ago": "", "Load more": "", - "`x` points.": "", + "`x` points": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "Could not create mix.": "", "Empty playlist": "", "Not a playlist.": "", @@ -309,13 +340,34 @@ "Yiddish": "", "Yoruba": "", "Zulu": "", - "`x` years.": "", - "`x` months.": "", - "`x` weeks.": "", - "`x` days.": "", - "`x` hours.": "", - "`x` minutes.": "", - "`x` seconds.": "", + "`x` years": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` months": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` weeks": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` days": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` hours": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` minutes": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` seconds": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, "Fallback comments: ": "", "Popular": "", "Search": "", @@ -341,6 +393,33 @@ "Videos": "", "Playlists": "", "Community": "", + "relevance": "", + "rating": "", + "date": "", + "views": "", + "content_type": "", + "duration": "", + "features": "", + "sort": "", + "hour": "", + "today": "", + "week": "", + "month": "", + "year": "", + "video": "", + "channel": "", + "playlist": "", + "movie": "", + "show": "", + "hd": "", + "subtitles": "", + "creative_commons": "", + "3d": "", + "live": "", + "4k": "", + "location": "", + "hdr": "", + "filter": "", "Current version: ": "Тренутна верзија: ", "next_steps_error_message": "", "next_steps_error_message_refresh": "", diff --git a/locales/tr.json b/locales/tr.json index 1b3b55d7..5246ab6f 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -86,8 +86,8 @@ "dark": "karanlık", "light": "aydınlık", "Thin mode: ": "İnce mod: ", - "Miscellaneous preferences": "", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", + "Miscellaneous preferences": "Çeşitli tercihler", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Otomatik örnek yeniden yönlendirmesi (yedek: redirect.invidious.io): ", "Subscription preferences": "Abonelik tercihleri", "Show annotations by default for subscribed channels: ": "Abone olunan kanallar için ek açıklamaları öntanımlı olarak göster: ", "Redirect homepage to feed: ": "Ana sayfayı akışa yönlendir: ", @@ -117,7 +117,7 @@ "Administrator preferences": "Yönetici tercihleri", "Default homepage: ": "Öntanımlı ana sayfa: ", "Feed menu: ": "Akış menüsü: ", - "Show nickname on top: ": "", + "Show nickname on top: ": "Takma adı üstte göster: ", "Top enabled: ": "Top etkin: ", "CAPTCHA enabled: ": "CAPTCHA etkin: ", "Login enabled: ": "Oturum açma etkin: ", @@ -164,8 +164,8 @@ "Show more": "Daha fazla göster", "Show less": "Daha az göster", "Watch on YouTube": "YouTube'da izle", - "Switch Invidious Instance": "", - "Broken? Try another Invidious Instance": "", + "Switch Invidious Instance": "Invidious Örneğini Değiştir", + "Broken? Try another Invidious Instance": "Bozuk mu? Başka bir Invidious örneğini deneyin", "Hide annotations": "Ek açıklamaları gizle", "Show annotations": "Ek açıklamaları göster", "Genre: ": "Tür: ", @@ -177,8 +177,8 @@ "Blacklisted regions: ": "Kara listeye alınan bölgeler: ", "Shared `x`": "`x` paylaşıldı", "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` görüntüleme.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` görüntüleme." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` görüntüleme", + "": "`x` görüntüleme" }, "Premieres in `x`": "`x`içinde ilk gösterim", "Premieres `x`": "`x` ilk gösterim", @@ -186,8 +186,8 @@ "View YouTube comments": "YouTube yorumlarını görüntüle", "View more comments on Reddit": "Reddit'te daha fazla yorum görüntüle", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yorumu görüntüle.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` yorumu görüntüle." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yorumu görüntüle", + "": "`x` yorumu görüntüle" }, "View Reddit comments": "Reddit yorumlarını görüntüle", "Hide replies": "Cevapları gizle", @@ -214,14 +214,14 @@ "Could not get channel info.": "Kanal bilgisi alınamadı.", "Could not fetch comments": "Yorumlar alınamadı", "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yanıtı görüntüle.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` yanıtı görüntüle." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yanıtı görüntüle", + "": "`x` yanıtı görüntüle" }, "`x` ago": "`x` önce", "Load more": "Daha fazla yükle", "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` puan.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` puan." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` puan", + "": "`x` puan" }, "Could not create mix.": "Mix oluşturulamadı.", "Empty playlist": "Boş oynatma listesi", @@ -341,32 +341,32 @@ "Yoruba": "Yoruba dili", "Zulu": "Zuluca", "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yıl.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` yıl." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yıl", + "": "`x` yıl" }, "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ay.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` ay." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ay", + "": "`x` ay" }, "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hafta.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` hafta." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hafta", + "": "`x` hafta" }, "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` gün.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` gün." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` gün", + "": "`x` gün" }, "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` saat.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` saat." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` saat", + "": "`x` saat" }, "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dakika.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` dakika." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dakika", + "": "`x` dakika" }, "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` saniye.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` saniye." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` saniye", + "": "`x` saniye" }, "Fallback comments: ": "Yedek yorumlar: ", "Popular": "Popüler", @@ -393,35 +393,35 @@ "Videos": "Videolar", "Playlists": "Oynatma listeleri", "Community": "Topluluk", - "relevance": "ilgi", - "rating": "değerlendirme", - "date": "tarih", - "views": "görüntüleme", - "content_type": "içerik_türü", - "duration": "süre", - "features": "özellikler", - "sort": "sırala", - "hour": "saat", - "today": "bugün", - "week": "hafta", - "month": "ay", - "year": "yıl", - "video": "video", - "channel": "kanal", - "playlist": "oynatma listesi", - "movie": "film", - "show": "gösteri", + "relevance": "İlgi", + "rating": "Değerlendirme", + "date": "Yükleme tarihi", + "views": "Görüntüleme sayısı", + "content_type": "Tür", + "duration": "Süre", + "features": "Özellikler", + "sort": "Sıralama Ölçütü", + "hour": "Son Saat", + "today": "Bugün", + "week": "Bu hafta", + "month": "Bu ay", + "year": "Bu yıl", + "video": "Video", + "channel": "Kanal", + "playlist": "Oynatma listesi", + "movie": "Film", + "show": "Gösteri", "hd": "HD", - "subtitles": "alt yazılar", + "subtitles": "Alt yazılar", "creative_commons": "Creative Commons", "3d": "3B", - "live": "canlı", + "live": "Canlı", "4k": "4K", - "location": "konum", + "location": "Konum", "hdr": "HDR", - "filter": "filtrele", + "filter": "Filtrele", "Current version: ": "Şu anki sürüm: ", - "next_steps_error_message": "", - "next_steps_error_message_refresh": "", - "next_steps_error_message_go_to_youtube": "" + "next_steps_error_message": "Bundan sonra şunları denemelisiniz: ", + "next_steps_error_message_refresh": "Yenile", + "next_steps_error_message_go_to_youtube": "Youtube'a git" } diff --git a/locales/vi.json b/locales/vi.json new file mode 100644 index 00000000..a3614ce9 --- /dev/null +++ b/locales/vi.json @@ -0,0 +1,427 @@ +{ + "`x` subscribers": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscribers", + "": "`x` subscribers" + }, + "`x` videos": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video", + "": "" + }, + "`x` playlists": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "LIVE": "TRỰC TIẾP", + "Shared `x` ago": "Đã chia sẻ` x` trước", + "Unsubscribe": "Hủy đăng ký", + "Subscribe": "Đăng ký", + "View channel on YouTube": "Xem kênh trên YouTube", + "View playlist on YouTube": "Xem danh sách phát trên YouTube", + "newest": "mới nhất", + "oldest": "lâu đời nhất", + "popular": "phổ biến", + "last": "Cuối cùng", + "Next page": "Trang tiếp theo", + "Previous page": "Trang trước", + "Clear watch history?": "Xóa lịch sử xem?", + "New password": "Mật khẩu mới", + "New passwords must match": "Mật khẩu mới phải khớp", + "Cannot change password for Google accounts": "Không thể thay đổi mật khẩu cho tài khoản Google", + "Authorize token?": "Cấp phép mã thông báo?", + "Authorize token for `x`?": "Cấp phép mã thông báo cho` x`?", + "Yes": "Đúng", + "No": "Không", + "Import and Export Data": "Nhập và xuất dữ liệu", + "Import": "Nhập", + "Import Invidious data": "Nhập dữ liệu sống động", + "Import YouTube subscriptions": "Nhập đăng ký YouTube", + "Import FreeTube subscriptions (.db)": "Nhập đăng ký FreeTube (.db)", + "Import NewPipe subscriptions (.json)": "Nhập đăng ký NewPipe (.json)", + "Import NewPipe data (.zip)": "Nhập dữ liệu NewPipe (.zip)", + "Export": "Xuất", + "Export subscriptions as OPML": "Xuất đăng ký dưới dạng OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Xuất đăng ký dưới dạng OPML (cho NewPipe & FreeTube)", + "Export data as JSON": "Xuất dữ liệu dưới dạng JSON", + "Delete account?": "Xóa tài khoản?", + "History": "Lịch sử", + "An alternative front-end to YouTube": "Giao diện người dùng thay thế cho YouTube", + "JavaScript license information": "Thông tin giấy phép JavaScript", + "source": "nguồn", + "Log in": "Đăng nhập", + "Log in/register": "Đăng nhập / đăng ký", + "Log in with Google": "Đăng nhập bằng Google", + "User ID": "Tên người dùng", + "Password": "Mật khẩu", + "Time (h:mm:ss):": "Thời gian (h: mm: ss):", + "Text CAPTCHA": "Nhắn tin tới CAPTCHA", + "Image CAPTCHA": "Hình ảnh CAPTCHA", + "Sign In": "Đăng nhập", + "Register": "Đăng ký", + "E-mail": "E-mail", + "Google verification code": "Mã xác minh của Google", + "Preferences": "Sở thích", + "Player preferences": "Tùy chọn người chơi", + "Always loop: ": "Luôn lặp lại: ", + "Autoplay: ": "Tự chạy: ", + "Play next by default: ": "Phát tiếp theo theo mặc định: ", + "Autoplay next video: ": "Tự động phát video tiếp theo: ", + "Listen by default: ": "Nghe theo mặc định: ", + "Proxy videos: ": "Video proxy: ", + "Default speed: ": "Tốc độ mặc định: ", + "Preferred video quality: ": "Chất lượng video ưa thích: ", + "Player volume: ": "Khối lượng trình phát: ", + "Default comments: ": "Nhận xét mặc định: ", + "youtube": "youtube", + "reddit": "reddit", + "Default captions: ": "Phụ đề mặc định: ", + "Fallback captions: ": "Phụ đề dự phòng: ", + "Show related videos: ": "Hiển thị các video có liên quan: ", + "Show annotations by default: ": "Hiển thị chú thích theo mặc định: ", + "Automatically extend video description: ": "Tự động mở rộng mô tả video: ", + "Interactive 360 degree videos: ": "Video 360 độ tương tác: ", + "Visual preferences": "Tùy chọn hình ảnh", + "Player style: ": "Phong cách người chơi: ", + "Dark mode: ": "Chế độ tối: ", + "Theme: ": "Chủ đề: ", + "dark": "tối", + "light": "ánh sáng", + "Thin mode: ": "Chế độ mỏng: ", + "Miscellaneous preferences": "Tùy chọn khác", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Chuyển hướng phiên bản tự động (dự phòng thành redirect.invidious.io): ", + "Subscription preferences": "Tùy chọn đăng ký", + "Show annotations by default for subscribed channels: ": "Hiển thị chú thích theo mặc định cho các kênh đã đăng ký: ", + "Redirect homepage to feed: ": "Chuyển hướng trang chủ đến nguồn cấp dữ liệu: ", + "Number of videos shown in feed: ": "Số lượng video được hiển thị trong nguồn cấp dữ liệu: ", + "Sort videos by: ": "Sắp xếp video theo: ", + "published": "được phát hành", + "published - reverse": "đã xuất bản - đảo ngược", + "alphabetically": "theo thứ tự bảng chữ cái", + "alphabetically - reverse": "theo thứ tự bảng chữ cái - đảo ngược", + "channel name": "Tên kênh", + "channel name - reverse": "tên kênh - đảo ngược", + "Only show latest video from channel: ": "Chỉ hiển thị video mới nhất từ kênh: ", + "Only show latest unwatched video from channel: ": "Chỉ hiển thị video chưa xem mới nhất từ kênh: ", + "Only show unwatched: ": "Chỉ hiển thị chưa xem: ", + "Only show notifications (if there are any): ": "Chỉ hiển thị thông báo (nếu có): ", + "Enable web notifications": "Bật thông báo web", + "`x` uploaded a video": "` x` đã tải lên một video", + "`x` is live": "` x` đang phát trực tiếp", + "Data preferences": "Tùy chọn dữ liệu", + "Clear watch history": "Xóa lịch sử xem", + "Import/export data": "Nhập / xuất dữ liệu", + "Change password": "Đổi mật khẩu", + "Manage subscriptions": "Quản lý các mục đăng kí", + "Manage tokens": "Quản lý mã thông báo", + "Watch history": "Lịch sử xem", + "Delete account": "Xóa tài khoản", + "Administrator preferences": "Tùy chọn quản trị viên", + "Default homepage: ": "Trang chủ mặc định: ", + "Feed menu: ": "Menu nguồn cấp dữ liệu: ", + "Show nickname on top: ": "Hiển thị biệt hiệu ở trên cùng: ", + "Top enabled: ": "Đã bật hàng đầu: ", + "CAPTCHA enabled: ": "Đã bật CAPTCHA: ", + "Login enabled: ": "Đã bật đăng nhập: ", + "Registration enabled: ": "Đã bật đăng ký: ", + "Report statistics: ": "Báo cáo thống kê: ", + "Save preferences": "Lưu tùy chọn", + "Subscription manager": "Người quản lý đăng ký", + "Token manager": "Trình quản lý mã thông báo", + "Token": "Mã thông báo", + "`x` subscriptions": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` tokens": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Import/export": "", + "unsubscribe": "", + "revoke": "", + "Subscriptions": "", + "`x` unseen notifications": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "search": "Tìm kiếm", + "Log out": "Đăng xuất", + "Released under the AGPLv3 by Omar Roth.": "Được phát hành theo AGPLv3 bởi Omar Roth.", + "Source available here.": "Nguồn có sẵn ở đây.", + "View JavaScript license information.": "Xem thông tin giấy phép JavaScript.", + "View privacy policy.": "Xem chính sách bảo mật.", + "Trending": "Xu hướng", + "Public": "Công cộng", + "Unlisted": "Riêng tư", + "Private": "Riêng tư", + "View all playlists": "Xem tất cả danh sách phát", + "Updated `x` ago": "Đã cập nhật` x` trước", + "Delete playlist `x`?": "Xóa danh sách phát` x`?", + "Delete playlist": "Xóa danh sách phát", + "Create playlist": "Tạo danh sách phát", + "Title": "Tiêu đề", + "Playlist privacy": "Bảo mật danh sách phát", + "Editing playlist `x`": "Chỉnh sửa danh sách phát` x`", + "Show more": "Cho xem nhiều hơn", + "Show less": "Hiện ít hơn", + "Watch on YouTube": "Xem trên YouTube", + "Switch Invidious Instance": "Chuyển phiên bản Invidious", + "Broken? Try another Invidious Instance": "Bị hỏng? Hãy thử một Phiên bản Invidious khác", + "Hide annotations": "Ẩn chú thích", + "Show annotations": "Hiển thị chú thích", + "Genre: ": "Thể loại: ", + "License: ": "Giấy phép: ", + "Family friendly? ": "Gia đình thân thiện? ", + "Wilson score: ": "Điểm số Wilson: ", + "Engagement: ": "Hôn ước: ", + "Whitelisted regions: ": "Các vùng nằm trong danh sách trắng: ", + "Blacklisted regions: ": "Khu vực nằm trong danh sách đen: ", + "Shared `x`": "Chia sẻ` x`", + "`x` views": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Premieres in `x`": "", + "Premieres `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.": "", + "View YouTube comments": "", + "View more comments on Reddit": "", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "View Reddit comments": "Xem nhận xét trên Reddit", + "Hide replies": "Ẩn câu trả lời", + "Show replies": "Hiển thị câu trả lời", + "Incorrect password": "Mật khẩu không đúng", + "Quota exceeded, try again in a few hours": "Đã vượt quá hạn ngạch, hãy thử lại sau vài giờ nữa", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Không thể đăng nhập, hãy đảm bảo rằng xác thực hai yếu tố (Authenticator hoặc SMS) được bật.", + "Invalid TFA code": "Mã TFA không hợp lệ", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "Đăng nhập không thành công. Điều này có thể là do xác thực hai yếu tố chưa được bật cho tài khoản của bạn.", + "Wrong answer": "Câu trả lời sai", + "Erroneous CAPTCHA": "CAPTCHA bị lỗi", + "CAPTCHA is a required field": "CAPTCHA là trường bắt buộc", + "User ID is a required field": "User ID là trường bắt buộc", + "Password is a required field": "Mật khẩu là trường bắt buộc", + "Wrong username or password": "Tên người dùng hoặc mật khẩu sai", + "Please sign in using 'Log in with Google'": "Vui lòng đăng nhập bằng 'Đăng nhập bằng Google'", + "Password cannot be empty": "Mật khẩu không được để trống", + "Password cannot be longer than 55 characters": "Mật khẩu không được dài hơn 55 ký tự", + "Please log in": "Xin vui lòng đăng nhập", + "Invidious Private Feed for `x`": "Nguồn cấp dữ liệu riêng tư Invidious cho` x`", + "channel:`x`": "kênh:` x`", + "Deleted or invalid channel": "Kênh đã xóa hoặc không hợp lệ", + "This channel does not exist.": "Kênh này không tồn tại.", + "Could not get channel info.": "Không thể tải thông tin kênh.", + "Could not fetch comments": "Không thể tìm nạp nhận xét", + "View `x` replies": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` ago": "", + "Load more": "", + "`x` points": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Could not create mix.": "Không thể tạo kết hợp.", + "Empty playlist": "Danh sách phát trống", + "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 \"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", + "No such user": "Không có người dùng như vậy", + "Token is expired, please try again": "Token đã hết hạn, vui lòng thử lại", + "English": "Tiếng Anh", + "English (auto-generated)": "Tiếng Anh (auto-generated))", + "Afrikaans": "Tiếng Afrikaans", + "Albanian": "Tiếng Albania", + "Amharic": "Amharic", + "Arabic": "Tiếng Ả Rập", + "Armenian": "Tiếng Armenia", + "Azerbaijani": "Azerbaijan", + "Bangla": "Bangla", + "Basque": "Tiếng Basque", + "Belarusian": "Người Belarus", + "Bosnian": "Tiếng Bosnia", + "Bulgarian": "Tiếng Bungari", + "Burmese": "Tiếng Miến Điện", + "Catalan": "Tiếng Catalan", + "Cebuano": "Cebuano", + "Chinese (Simplified)": "Tiếng Trung (Giản thể)", + "Chinese (Traditional)": "Truyền thống Trung Hoa)", + "Corsican": "Corsican", + "Croatian": "Tiếng Croatia", + "Czech": "Tiếng Séc", + "Danish": "Người Đan Mạch", + "Dutch": "Tiếng Hà Lan", + "Esperanto": "Quốc tế ngữ", + "Estonian": "Tiếng Estonia", + "Filipino": "Filipino", + "Finnish": "Tiếng Phần Lan", + "French": "Người Pháp", + "Galician": "Tiếng Galicia", + "Georgian": "Tiếng Georgia", + "German": "Tiếng Đức", + "Greek": "Người Hy Lạp", + "Gujarati": "Gujarati", + "Haitian Creole": "Tiếng Creole của Haiti", + "Hausa": "Hausa", + "Hawaiian": "Tiếng Hawaii", + "Hebrew": "Tiếng Do Thái", + "Hindi": "Tiếng Hindi", + "Hmong": "Hmong", + "Hungarian": "Người Hungary", + "Icelandic": "Tiếng Iceland", + "Igbo": "Igbo", + "Indonesian": "Tiếng Indonesia", + "Irish": "Tiếng Ailen", + "Italian": "Người Ý", + "Japanese": "Tiếng Nhật", + "Javanese": "Tiếng Java", + "Kannada": "Tiếng Kannada", + "Kazakh": "Tiếng Kazakh", + "Khmer": "Tiếng Khmer", + "Korean": "Hàn Quốc", + "Kurdish": "Tiếng Kurd", + "Kyrgyz": "Kyrgyz", + "Lao": "Lào", + "Latin": "Latin", + "Latvian": "Tiếng Latvia", + "Lithuanian": "Tiếng Litva", + "Luxembourgish": "Tiếng Luxembourg", + "Macedonian": "Người Macedonian", + "Malagasy": "Malagasy", + "Malay": "Tiếng Mã Lai", + "Malayalam": "Tiếng Malayalam", + "Maltese": "Cây nho", + "Maori": "Tiếng Maori", + "Marathi": "Marathi", + "Mongolian": "Tiếng Mông Cổ", + "Nepali": "Tiếng Nepal", + "Norwegian Bokmål": "Tiếng Na Uy Bokmål", + "Nyanja": "Nyanja", + "Pashto": "Pashto", + "Persian": "Tiếng Ba Tư", + "Polish": "Đánh bóng", + "Portuguese": "Tiếng Bồ Đào Nha", + "Punjabi": "Punjabi", + "Romanian": "Tiếng Rumani", + "Russian": "Tiếng Nga", + "Samoan": "Samoan", + "Scottish Gaelic": "Tiếng Gaelic Scotland", + "Serbian": "Tiếng Serbia", + "Shona": "Shona", + "Sindhi": "Sindhi", + "Sinhala": "Sinhala", + "Slovak": "Tiếng Slovak", + "Slovenian": "Tiếng Slovenia", + "Somali": "Tiếng Somali", + "Southern Sotho": "Southern Sotho", + "Spanish": "Người Tây Ban Nha", + "Spanish (Latin America)": "Tiếng Tây Ban Nha (Mỹ Latinh)", + "Sundanese": "Tiếng Sundan", + "Swahili": "Tiếng Swahili", + "Swedish": "Tiếng Thụy Điển", + "Tajik": "Tajik", + "Tamil": "Tamil", + "Telugu": "Tiếng Telugu", + "Thai": "Tiếng Thái", + "Turkish": "Tiếng Thổ Nhĩ Kỳ", + "Ukrainian": "Tiếng Ukraina", + "Urdu": "Tiếng Urdu", + "Uzbek": "Tiếng Uzbek", + "Vietnamese": "Tiếng Việt", + "Welsh": "Người xứ Wales", + "Western Frisian": "Western Frisian", + "Xhosa": "Xhosa", + "Yiddish": "Yiddish", + "Yoruba": "Yoruba", + "Zulu": "Tiếng Zulu", + "`x` years": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` months": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` weeks": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` days": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` hours": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` minutes": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "`x` seconds": { + "([^.,0-9]|^)1([^.,0-9]|$)": "", + "": "" + }, + "Fallback comments: ": "Nhận xét dự phòng: ", + "Popular": "Phổ biến", + "Search": "Tìm kiếm", + "Top": "Hàng đầu", + "About": "Trong khoảng", + "Rating: ": "Xếp hạng: ", + "Language: ": "Ngôn ngữ: ", + "View as playlist": "Xem dưới dạng danh sách phát", + "Default": "Mặc định", + "Music": "Âm nhạc", + "Gaming": "Trò chơi", + "News": "Tin tức", + "Movies": "Phim", + "Download": "Tải xuống", + "Download as: ": "Tải tệp dưới dạng: ", + "%A %B %-d, %Y": "% A% B% -d,% Y", + "(edited)": "(đã chỉnh sửa)", + "YouTube comment permalink": "Liên kết cố định nhận xét trên YouTube", + "permalink": "liên kết cố định", + "`x` marked it with a ❤": "` x` đã đánh dấu nó bằng một ❤", + "Audio mode": "Chế độ âm thanh", + "Video mode": "Chế độ quay", + "Videos": "Video", + "Playlists": "Danh sách phát", + "Community": "Cộng đồng", + "relevance": "liên quan", + "rating": "Xếp hạng", + "date": "ngày", + "views": "lượt xem", + "content_type": "content_type", + "duration": "thời lượng", + "features": "đặc trưng", + "sort": "sắp xếp", + "hour": "giờ", + "today": "hôm nay", + "week": "tuần", + "month": "tháng", + "year": "năm", + "video": "video", + "channel": "kênh", + "playlist": "danh sách phát", + "movie": "bộ phim", + "show": "chỉ", + "hd": "hd", + "subtitles": "phụ đề", + "creative_commons": "Commons sáng tạo", + "3d": "3d", + "live": "trực tiếp", + "4k": "4k", + "location": "vị trí", + "hdr": "hdr", + "filter": "bộ lọc", + "Current version: ": "Phiên bản hiện tại: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" +} diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 00e295fa..93631b3f 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1,15 +1,15 @@ { "`x` subscribers": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 位订阅者", - "": "`x` 位订阅者." + "": "`x` 位订阅者" }, "`x` videos": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个视频", - "": "`x` 个视频." + "": "`x` 个视频" }, "`x` playlists": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个播放列表", - "": "`x` 个播放列表。" + "": "`x` 个播放列表" }, "LIVE": "直播", "Shared `x` ago": "`x` 前分享", @@ -87,7 +87,7 @@ "light": "亮色", "Thin mode: ": "窄页模式: ", "Miscellaneous preferences": "其他选项", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "自动实例重定向(回退到redirect.invious.io): ", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "自动实例重定向 (回退到redirect.invidious.io): ", "Subscription preferences": "订阅设置", "Show annotations by default for subscribed channels: ": "默认情况下显示已订阅频道的注释: ", "Redirect homepage to feed: ": "跳转主页到 feed: ", @@ -129,11 +129,11 @@ "Token": "令牌", "`x` subscriptions": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个订阅", - "": "`x` 个订阅." + "": "`x` 个订阅" }, "`x` tokens": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个令牌", - "": "`x` 个令牌。" + "": "`x` 个令牌" }, "Import/export": "导入/导出", "unsubscribe": "取消订阅", @@ -141,7 +141,7 @@ "Subscriptions": "订阅", "`x` unseen notifications": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 条未读通知", - "": "`x` 条未读通知。" + "": "`x` 条未读通知" }, "search": "搜索", "Log out": "登出", @@ -178,7 +178,7 @@ "Shared `x`": "`x`发布", "`x` views": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 播放", - "": "`x` 次观看." + "": "`x` 次观看" }, "Premieres in `x`": "首映于 `x` 后", "Premieres `x`": "首映于 `x`", @@ -187,7 +187,7 @@ "View more comments on Reddit": "在 Reddit 查看更多评论", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "查看 `x` 条评论", - "": "查看 `x` 条评论." + "": "查看 `x` 条评论" }, "View Reddit comments": "查看 Reddit 评论", "Hide replies": "隐藏回复", @@ -215,13 +215,13 @@ "Could not fetch comments": "无法获取评论", "View `x` replies": { "([^.,0-9]|^)1([^.,0-9]|$)": "查看 `x` 条回复", - "": "查看 `x` 条回复." + "": "查看 `x` 条回复" }, "`x` ago": "`x` 前", "Load more": "加载更多", "`x` points": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 分", - "": "`x` 分." + "": "`x` 分" }, "Could not create mix.": "无法创建合集。", "Empty playlist": "空播放列表", @@ -342,31 +342,31 @@ "Zulu": "祖鲁语", "`x` years": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 年", - "": "`x` 年." + "": "`x` 年" }, "`x` months": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 月", - "": "`x` 个月." + "": "`x` 个月" }, "`x` weeks": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 周", - "": "`x` 周." + "": "`x` 周" }, "`x` days": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天", - "": "`x` 天." + "": "`x` 天" }, "`x` hours": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 小时", - "": "`x` 小时." + "": "`x` 小时" }, "`x` minutes": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 分钟", - "": "`x` 分钟." + "": "`x` 分钟" }, "`x` seconds": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 秒", - "": "`x` 秒." + "": "`x` 秒" }, "Fallback comments: ": "后备评论: ", "Popular": "热门频道", @@ -395,17 +395,17 @@ "Community": "社区", "relevance": "相关度", "rating": "评分", - "date": "日期", + "date": "上传日期", "views": "观看次数", - "content_type": "content_type", + "content_type": "类型", "duration": "持续时间", "features": "功能", - "sort": "排序", - "hour": "小时", + "sort": "排序依据", + "hour": "上个小时", "today": "今日", - "week": "周", - "month": "月", - "year": "年份", + "week": "本周", + "month": "本月", + "year": "今年", "video": "视频", "channel": "频道", "playlist": "播放列表", @@ -421,7 +421,7 @@ "hdr": "hdr", "filter": "过滤器", "Current version: ": "当前版本: ", - "next_steps_error_message": "next_steps_error_message", - "next_steps_error_message_refresh": "next_steps_error_message_refresh", - "next_steps_error_message_go_to_youtube": "next_steps_error_message_go_to_youtube" + "next_steps_error_message": "在此之后你应尝试: ", + "next_steps_error_message_refresh": "刷新", + "next_steps_error_message_go_to_youtube": "转到 Youtube" } diff --git a/locales/zh-TW.json b/locales/zh-TW.json index c651bcc0..e554b23a 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -71,7 +71,7 @@ "Preferred video quality: ": "偏好的影片畫質: ", "Player volume: ": "播放器音量: ", "Default comments: ": "預設留言: ", - "youtube": "youtube", + "youtube": "YouTube", "reddit": "reddit", "Default captions: ": "預設字幕: ", "Fallback captions: ": "汰退字幕: ", @@ -86,8 +86,8 @@ "dark": "深色", "light": "淺色", "Thin mode: ": "精簡模式: ", - "Miscellaneous preferences": "", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", + "Miscellaneous preferences": "其他偏好設定", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "自動站台重新導向(汰退至 redirect.invidious.io): ", "Subscription preferences": "訂閱偏好設定", "Show annotations by default for subscribed channels: ": "預設為已訂閱的頻道顯示註釋: ", "Redirect homepage to feed: ": "重新導向首頁至 feed: ", @@ -117,7 +117,7 @@ "Administrator preferences": "管理員偏好設定", "Default homepage: ": "預設首頁: ", "Feed menu: ": "Feed 選單: ", - "Show nickname on top: ": "", + "Show nickname on top: ": "在頂部顯示暱稱: ", "Top enabled: ": "頂部啟用: ", "CAPTCHA enabled: ": "CAPTCHA 啟用: ", "Login enabled: ": "啟用登入: ", @@ -164,8 +164,8 @@ "Show more": "顯示更多", "Show less": "顯示較少", "Watch on YouTube": "在 YouTube 上觀看", - "Switch Invidious Instance": "", - "Broken? Try another Invidious Instance": "", + "Switch Invidious Instance": "切換 Invidious 站台", + "Broken? Try another Invidious Instance": "故障了嗎?試試看其他 Invidious 站台吧", "Hide annotations": "隱藏註釋", "Show annotations": "顯示註釋", "Genre: ": "風格: ", @@ -187,7 +187,7 @@ "View more comments on Reddit": "在 Reddit 上檢視更多留言", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "檢視 `x` 則留言", - "": "檢視 `x` 則留言。" + "": "檢視 `x` 則留言" }, "View Reddit comments": "檢視 Reddit 留言", "Hide replies": "隱藏回覆", @@ -421,7 +421,7 @@ "hdr": "HDR", "filter": "篩選條件", "Current version: ": "目前版本: ", - "next_steps_error_message": "", - "next_steps_error_message_refresh": "", - "next_steps_error_message_go_to_youtube": "" + "next_steps_error_message": "之後您應該嘗試: ", + "next_steps_error_message_refresh": "重新整理", + "next_steps_error_message_go_to_youtube": "到 YouTube" } diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index ed3a3d48..ada5b28f 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -5,7 +5,7 @@ require "protodec/utils" require "spec" require "yaml" require "../src/invidious/helpers/*" -require "../src/invidious/channels" +require "../src/invidious/channels/*" require "../src/invidious/comments" require "../src/invidious/playlists" require "../src/invidious/search" diff --git a/src/invidious.cr b/src/invidious.cr index f7c8980a..89292f05 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -27,6 +27,7 @@ require "compress/zip" require "protodec/utils" require "./invidious/helpers/*" require "./invidious/*" +require "./invidious/channels/*" require "./invidious/routes/**" require "./invidious/jobs/**" @@ -1961,9 +1962,9 @@ get "/api/v1/captions/:id" do |env| json.array do captions.each do |caption| json.object do - json.field "label", caption.name.simpleText + json.field "label", caption.name json.field "languageCode", caption.languageCode - json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name.simpleText)}" + json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" end end end @@ -1979,7 +1980,7 @@ get "/api/v1/captions/:id" do |env| if lang caption = captions.select { |caption| caption.languageCode == lang } else - caption = captions.select { |caption| caption.name.simpleText == label } + caption = captions.select { |caption| caption.name == label } end if caption.empty? @@ -1993,7 +1994,7 @@ get "/api/v1/captions/:id" do |env| # Auto-generated captions often have cues that aren't aligned properly with the video, # as well as some other markup that makes it cumbersome, so we try to fix that here - if caption.name.simpleText.includes? "auto-generated" + if caption.name.includes? "auto-generated" caption_xml = YT_POOL.client &.get(url).body caption_xml = XML.parse(caption_xml) diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr deleted file mode 100644 index bbef3d4f..00000000 --- a/src/invidious/channels.cr +++ /dev/null @@ -1,962 +0,0 @@ -struct InvidiousChannel - include DB::Serializable - - property id : String - property author : String - property updated : Time - property deleted : Bool - property subscribed : Time? -end - -struct ChannelVideo - include DB::Serializable - - property id : String - property title : String - property published : Time - property updated : Time - property ucid : String - property author : String - property length_seconds : Int32 = 0 - property live_now : Bool = false - property premiere_timestamp : Time? = nil - property views : Int64? = nil - - def to_json(locale, json : JSON::Builder) - json.object do - json.field "type", "shortVideo" - - json.field "title", self.title - json.field "videoId", self.id - json.field "videoThumbnails" do - generate_thumbnails(json, self.id) - end - - json.field "lengthSeconds", self.length_seconds - - json.field "author", self.author - json.field "authorId", self.ucid - json.field "authorUrl", "/channel/#{self.ucid}" - json.field "published", self.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) - - json.field "viewCount", self.views - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end - - def to_xml(locale, query_params, xml : XML::Builder) - query_params["v"] = self.id - - xml.element("entry") do - xml.element("id") { xml.text "yt:video:#{self.id}" } - xml.element("yt:videoId") { xml.text self.id } - xml.element("yt:channelId") { xml.text self.ucid } - xml.element("title") { xml.text self.title } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}") - - xml.element("author") do - xml.element("name") { xml.text self.author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } - end - - xml.element("content", type: "xhtml") do - xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do - xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do - xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") - end - end - end - - xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") } - xml.element("updated") { xml.text self.updated.to_s("%Y-%m-%dT%H:%M:%S%:z") } - - xml.element("media:group") do - xml.element("media:title") { xml.text self.title } - xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg", - width: "320", height: "180") - end - end - end - - def to_xml(locale, xml : XML::Builder | Nil = nil) - if xml - to_xml(locale, xml) - else - XML.build do |xml| - to_xml(locale, xml) - end - end - end - - def to_tuple - {% begin %} - { - {{*@type.instance_vars.map { |var| var.name }}} - } - {% end %} - end -end - -struct AboutRelatedChannel - include DB::Serializable - - property ucid : String - property author : String - property author_url : String - property author_thumbnail : String -end - -# TODO: Refactor into either SearchChannel or InvidiousChannel -struct AboutChannel - include DB::Serializable - - property ucid : String - property author : String - property auto_generated : Bool - property author_url : String - property author_thumbnail : String - property banner : String? - property description_html : String - property paid : Bool - property total_views : Int64 - property sub_count : Int32 - property joined : Time - property is_family_friendly : Bool - property allowed_regions : Array(String) - property related_channels : Array(AboutRelatedChannel) - property tabs : Array(String) -end - -class ChannelRedirect < Exception - property channel_id : String - - def initialize(@channel_id) - end -end - -def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10) - finished_channel = Channel(String | Nil).new - - spawn do - active_threads = 0 - active_channel = Channel(Nil).new - - channels.each do |ucid| - if active_threads >= max_threads - active_channel.receive - active_threads -= 1 - end - - active_threads += 1 - spawn do - begin - get_channel(ucid, db, refresh, pull_all_videos) - finished_channel.send(ucid) - rescue ex - finished_channel.send(nil) - ensure - active_channel.send(nil) - end - end - end - end - - final = [] of String - channels.size.times do - if ucid = finished_channel.receive - final << ucid - end - end - - return final -end - -def get_channel(id, db, refresh = true, pull_all_videos = true) - if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel) - if refresh && Time.utc - channel.updated > 10.minutes - channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) - channel_array = channel.to_a - args = arg_array(channel_array) - - db.exec("INSERT INTO channels VALUES (#{args}) \ - ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", args: channel_array) - end - else - channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) - channel_array = channel.to_a - args = arg_array(channel_array) - - db.exec("INSERT INTO channels VALUES (#{args})", args: channel_array) - end - - return channel -end - -def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) - LOGGER.debug("fetch_channel: #{ucid}") - LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}") - - LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed") - rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body - LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed") - rss = XML.parse_html(rss) - - author = rss.xpath_node(%q(//feed/title)) - if !author - raise InfoException.new("Deleted or invalid channel") - end - author = author.content - - # Auto-generated channels - # https://support.google.com/youtube/answer/2579942 - if author.ends_with?(" - Topic") || - {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author - auto_generated = true - end - - LOGGER.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}") - - page = 1 - - LOGGER.trace("fetch_channel: #{ucid} : Downloading channel videos page") - initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) - videos = extract_videos(initial_data, author, ucid) - - LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") - rss.xpath_nodes("//feed/entry").each do |entry| - video_id = entry.xpath_node("videoid").not_nil!.content - title = entry.xpath_node("title").not_nil!.content - published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) - updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) - author = entry.xpath_node("author/name").not_nil!.content - ucid = entry.xpath_node("channelid").not_nil!.content - views = entry.xpath_node("group/community/statistics").try &.["views"]?.try &.to_i64? - views ||= 0_i64 - - channel_video = videos.select { |video| video.id == video_id }[0]? - - length_seconds = channel_video.try &.length_seconds - length_seconds ||= 0 - - live_now = channel_video.try &.live_now - live_now ||= false - - premiere_timestamp = channel_video.try &.premiere_timestamp - - video = ChannelVideo.new({ - id: video_id, - title: title, - published: published, - updated: Time.utc, - ucid: ucid, - author: author, - length_seconds: length_seconds, - live_now: live_now, - premiere_timestamp: premiere_timestamp, - views: views, - }) - - LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video") - - # We don't include the 'premiere_timestamp' here because channel pages don't include them, - # meaning the above timestamp is always null - was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ - ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ - updated = $4, ucid = $5, author = $6, length_seconds = $7, \ - live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) - - if was_insert - LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") - db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) - else - LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") - end - end - - if pull_all_videos - page += 1 - - ids = [] of String - - loop do - initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) - videos = extract_videos(initial_data, author, ucid) - - count = videos.size - videos = videos.map { |video| ChannelVideo.new({ - id: video.id, - title: video.title, - published: video.published, - updated: Time.utc, - ucid: video.ucid, - author: video.author, - length_seconds: video.length_seconds, - live_now: video.live_now, - premiere_timestamp: video.premiere_timestamp, - views: video.views, - }) } - - videos.each do |video| - ids << video.id - - # We are notified of Red videos elsewhere (PubSub), which includes a correct published date, - # so since they don't provide a published date here we can safely ignore them. - if Time.utc - video.published > 1.minute - was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ - ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ - updated = $4, ucid = $5, author = $6, length_seconds = $7, \ - live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) - - db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert - end - end - - break if count < 25 - page += 1 - end - end - - channel = InvidiousChannel.new({ - id: ucid, - author: author, - updated: Time.utc, - deleted: false, - subscribed: nil, - }) - - return channel -end - -def fetch_channel_playlists(ucid, author, continuation, sort_by) - if continuation - response_json = request_youtube_api_browse(continuation) - continuationItems = response_json["onResponseReceivedActions"]? - .try &.[0]["appendContinuationItemsAction"]["continuationItems"] - - return [] of SearchItem, nil if !continuationItems - - items = [] of SearchItem - continuationItems.as_a.select(&.as_h.has_key?("gridPlaylistRenderer")).each { |item| - extract_item(item, author, ucid).try { |t| items << t } - } - - continuation = continuationItems.as_a.last["continuationItemRenderer"]? - .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s - else - url = "/channel/#{ucid}/playlists?flow=list&view=1" - - case sort_by - when "last", "last_added" - # - when "oldest", "oldest_created" - url += "&sort=da" - when "newest", "newest_created" - url += "&sort=dd" - else nil # Ignore - end - - response = YT_POOL.client &.get(url) - initial_data = extract_initial_data(response.body) - return [] of SearchItem, nil if !initial_data - - items = extract_items(initial_data, author, ucid) - continuation = response.body.match(/"token":"(?<continuation>[^"]+)"/).try &.["continuation"]? - end - - return items, continuation -end - -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, - }, - }, - } - - 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["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 - - 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["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") - - continuation = object.try { |i| Protodec::Any.cast_json(object) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation -end - -# Used in bypass_captcha_job.cr -def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) - continuation = produce_channel_videos_continuation(ucid, page, auto_generated, sort_by, v2) - return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" -end - -# ## NOTE: DEPRECATED -# Reason -> Unstable -# The Protobuf object must be provided with an id of the last playlist from the current "page" -# in order to fetch the next one accurately -# (if the id isn't included, entries shift around erratically between pages, -# leading to repetitions and skip overs) -# -# Since it's impossible to produce the appropriate Protobuf without an id being provided by the user, -# it's better to stick to continuation tokens provided by the first request and onward -def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated = false) - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:base64" => { - "2:string" => "playlists", - "6:varint" => 2_i64, - "7:varint" => 1_i64, - "12:varint" => 1_i64, - "13:string" => "", - "23:varint" => 0_i64, - }, - }, - } - - if cursor - cursor = Base64.urlsafe_encode(cursor, false) if !auto_generated - object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = cursor - end - - if auto_generated - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x32_i64 - else - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 1_i64 - case sort - when "oldest", "oldest_created" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 2_i64 - when "newest", "newest_created" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 3_i64 - when "last", "last_added" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 4_i64 - else nil # Ignore - end - end - - 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") - - continuation = object.try { |i| Protodec::Any.cast_json(object) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" -end - -# TODO: Add "sort_by" -def fetch_channel_community(ucid, continuation, locale, format, thin_mode) - response = YT_POOL.client &.get("/channel/#{ucid}/community?gl=US&hl=en") - if response.status_code != 200 - response = YT_POOL.client &.get("/user/#{ucid}/community?gl=US&hl=en") - end - - if response.status_code != 200 - raise InfoException.new("This channel does not exist.") - end - - ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"] - - if !continuation || continuation.empty? - initial_data = extract_initial_data(response.body) - body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]? - - if !body - raise InfoException.new("Could not extract community tab.") - end - - body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"] - else - continuation = produce_channel_community_continuation(ucid, continuation) - - headers = HTTP::Headers.new - headers["cookie"] = response.cookies.add_request_headers(headers)["cookie"] - - session_token = response.body.match(/"XSRF_TOKEN":"(?<session_token>[^"]+)"/).try &.["session_token"]? || "" - post_req = { - session_token: session_token, - } - - response = YT_POOL.client &.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, form: post_req) - body = JSON.parse(response.body) - - body = body["response"]["continuationContents"]["itemSectionContinuation"]? || - body["response"]["continuationContents"]["backstageCommentsContinuation"]? - - if !body - raise InfoException.new("Could not extract continuation.") - end - end - - continuation = body["continuations"]?.try &.[0]["nextContinuationData"]["continuation"].as_s - posts = body["contents"].as_a - - if message = posts[0]["messageRenderer"]? - error_message = (message["text"]["simpleText"]? || - message["text"]["runs"]?.try &.[0]?.try &.["text"]?) - .try &.as_s || "" - raise InfoException.new(error_message) - end - - response = JSON.build do |json| - json.object do - json.field "authorId", ucid - json.field "comments" do - json.array do - posts.each do |post| - comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? || - post["backstageCommentsContinuation"]? - - post = post["backstagePostThreadRenderer"]?.try &.["post"]["backstagePostRenderer"]? || - post["commentThreadRenderer"]?.try &.["comment"]["commentRenderer"]? - - next if !post - - content_html = post["contentText"]?.try { |t| parse_content(t) } || "" - author = post["authorText"]?.try &.["simpleText"]? || "" - - json.object do - json.field "author", author - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - author_thumbnail = post["authorThumbnail"]["thumbnails"].as_a[0]["url"].as_s - - qualities.each do |quality| - json.object do - json.field "url", author_thumbnail.gsub(/s\d+-/, "s#{quality}-") - json.field "width", quality - json.field "height", quality - end - end - end - end - - if post["authorEndpoint"]? - json.field "authorId", post["authorEndpoint"]["browseEndpoint"]["browseId"] - json.field "authorUrl", post["authorEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s - else - json.field "authorId", "" - json.field "authorUrl", "" - end - - published_text = post["publishedTimeText"]["runs"][0]["text"].as_s - published = decode_date(published_text.rchop(" (edited)")) - - if published_text.includes?(" (edited)") - json.field "isEdited", true - else - json.field "isEdited", false - end - - like_count = post["actionButtons"]["commentActionButtonsRenderer"]["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"] - .try &.as_s.gsub(/\D/, "").to_i? || 0 - - json.field "content", html_to_content(content_html) - json.field "contentHtml", content_html - - json.field "published", published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) - - json.field "likeCount", like_count - json.field "commentId", post["postId"]? || post["commentId"]? || "" - json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid - - if attachment = post["backstageAttachment"]? - json.field "attachment" do - json.object do - case attachment.as_h - when .has_key?("videoRenderer") - attachment = attachment["videoRenderer"] - json.field "type", "video" - - if !attachment["videoId"]? - error_message = (attachment["title"]["simpleText"]? || - attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?) - - json.field "error", error_message - else - video_id = attachment["videoId"].as_s - - video_title = attachment["title"]["simpleText"]? || attachment["title"]["runs"]?.try &.[0]?.try &.["text"]? - json.field "title", video_title - json.field "videoId", video_id - json.field "videoThumbnails" do - generate_thumbnails(json, video_id) - end - - json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s) - - author_info = attachment["ownerText"]["runs"][0].as_h - - json.field "author", author_info["text"].as_s - json.field "authorId", author_info["navigationEndpoint"]["browseEndpoint"]["browseId"] - json.field "authorUrl", author_info["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"] - - # TODO: json.field "authorThumbnails", "channelThumbnailSupportedRenderers" - # TODO: json.field "authorVerified", "ownerBadges" - - published = decode_date(attachment["publishedTimeText"]["simpleText"].as_s) - - json.field "published", published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) - - view_count = attachment["viewCountText"]?.try &.["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64 - - json.field "viewCount", view_count - json.field "viewCountText", translate(locale, "`x` views", number_to_short_text(view_count)) - end - when .has_key?("backstageImageRenderer") - attachment = attachment["backstageImageRenderer"] - json.field "type", "image" - - json.field "imageThumbnails" do - json.array do - thumbnail = attachment["image"]["thumbnails"][0].as_h - width = thumbnail["width"].as_i - height = thumbnail["height"].as_i - aspect_ratio = (width.to_f / height.to_f) - url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") - - qualities = {320, 560, 640, 1280, 2000} - - qualities.each do |quality| - json.object do - json.field "url", url.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", (quality / aspect_ratio).ceil.to_i - end - end - end - end - # TODO - # when .has_key?("pollRenderer") - # attachment = attachment["pollRenderer"] - # json.field "type", "poll" - else - json.field "type", "unknown" - json.field "error", "Unrecognized attachment type." - end - end - end - end - - if comments && (reply_count = (comments["backstageCommentsRenderer"]["moreText"]["simpleText"]? || - comments["backstageCommentsRenderer"]["moreText"]["runs"]?.try &.[0]?.try &.["text"]?) - .try &.as_s.gsub(/\D/, "").to_i?) - continuation = comments["backstageCommentsRenderer"]["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s - continuation ||= "" - - json.field "replies" do - json.object do - json.field "replyCount", reply_count - json.field "continuation", extract_channel_community_cursor(continuation) - end - end - end - end - end - end - end - - if body["continuations"]? - continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s - json.field "continuation", extract_channel_community_cursor(continuation) - end - end - end - - if format == "html" - response = JSON.parse(response) - content_html = template_youtube_comments(response, locale, thin_mode) - - response = JSON.build do |json| - json.object do - json.field "contentHtml", content_html - end - end - end - - return response -end - -def produce_channel_community_continuation(ucid, cursor) - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:string" => cursor || "", - }, - } - - continuation = object.try { |i| Protodec::Any.cast_json(object) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation -end - -def extract_channel_community_cursor(continuation) - object = URI.decode_www_form(continuation) - .try { |i| Base64.decode(i) } - .try { |i| IO::Memory.new(i) } - .try { |i| Protodec::Any.parse(i) } - .try { |i| i["80226972:0:embedded"]["3:1:base64"].as_h } - - if object["53:2:embedded"]?.try &.["3:0:embedded"]? - object["53:2:embedded"]["3:0:embedded"]["2:0:string"] = object["53:2:embedded"]["3:0:embedded"] - .try { |i| i["2:0:base64"].as_h } - .try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i, padding: false) } - - object["53:2:embedded"]["3:0:embedded"].as_h.delete("2:0:base64") - end - - cursor = Protodec::Any.cast_json(object) - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - - cursor -end - -def get_about_info(ucid, locale) - result = YT_POOL.client &.get("/channel/#{ucid}/about?gl=US&hl=en") - if result.status_code != 200 - result = YT_POOL.client &.get("/user/#{ucid}/about?gl=US&hl=en") - end - - if md = result.headers["location"]?.try &.match(/\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/) - raise ChannelRedirect.new(channel_id: md["ucid"]) - end - - if result.status_code != 200 - raise InfoException.new("This channel does not exist.") - end - - about = XML.parse_html(result.body) - if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")])) - raise InfoException.new("This channel does not exist.") - end - - initdata = extract_initial_data(result.body) - if initdata.empty? - error_message = about.xpath_node(%q(//div[@class="yt-alert-content"])).try &.content.strip - error_message ||= translate(locale, "Could not get channel info.") - raise InfoException.new(error_message) - end - - if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]? - raise ChannelRedirect.new(channel_id: browse_endpoint["browseId"].to_s) - end - - auto_generated = false - # Check for special auto generated gaming channels - if !initdata.has_key?("metadata") - auto_generated = true - end - - if auto_generated - author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s - author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s - author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s - - # Raises a KeyError on failure. - banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? - banner = banners.try &.[-1]?.try &.["url"].as_s? - - description = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]["simpleText"].as_s - description_html = HTML.escape(description).gsub("\n", "<br>") - - paid = false - is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool - allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map { |a| a.as_s } - - related_channels = [] of AboutRelatedChannel - else - author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s - author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s - author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s - - ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s - - # Raises a KeyError on failure. - banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? - banner = banners.try &.[-1]?.try &.["url"].as_s? - - # if banner.includes? "channels/c4/default_banner" - # banner = nil - # end - - description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || "" - description_html = HTML.escape(description).gsub("\n", "<br>") - - paid = about.xpath_node(%q(//meta[@itemprop="paid"])).not_nil!["content"] == "True" - is_family_friendly = about.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).not_nil!["content"] == "True" - allowed_regions = about.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).not_nil!["content"].split(",") - - related_channels = initdata["contents"]["twoColumnBrowseResultsRenderer"] - .["secondaryContents"]?.try &.["browseSecondaryContentsRenderer"]["contents"][0]? - .try &.["verticalChannelSectionRenderer"]?.try &.["items"]?.try &.as_a.map do |node| - renderer = node["miniChannelRenderer"]? - related_id = renderer.try &.["channelId"]?.try &.as_s? - related_id ||= "" - - related_title = renderer.try &.["title"]?.try &.["simpleText"]?.try &.as_s? - related_title ||= "" - - related_author_url = renderer.try &.["navigationEndpoint"]?.try &.["commandMetadata"]?.try &.["webCommandMetadata"]? - .try &.["url"]?.try &.as_s? - related_author_url ||= "" - - related_author_thumbnails = renderer.try &.["thumbnail"]?.try &.["thumbnails"]?.try &.as_a? - related_author_thumbnails ||= [] of JSON::Any - - related_author_thumbnail = "" - if related_author_thumbnails.size > 0 - related_author_thumbnail = related_author_thumbnails[-1]["url"]?.try &.as_s? - related_author_thumbnail ||= "" - end - - AboutRelatedChannel.new({ - ucid: related_id, - author: related_title, - author_url: related_author_url, - author_thumbnail: related_author_thumbnail, - }) - end - related_channels ||= [] of AboutRelatedChannel - end - - total_views = 0_i64 - joined = Time.unix(0) - tabs = [] of String - - tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?.try &.as_a? - if !tabs_json.nil? - # Retrieve information from the tabs array. The index we are looking for varies between channels. - tabs_json.each do |node| - # Try to find the about section which is located in only one of the tabs. - channel_about_meta = node["tabRenderer"]?.try &.["content"]?.try &.["sectionListRenderer"]? - .try &.["contents"]?.try &.[0]?.try &.["itemSectionRenderer"]?.try &.["contents"]? - .try &.[0]?.try &.["channelAboutFullMetadataRenderer"]? - - if !channel_about_meta.nil? - total_views = channel_about_meta["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D/, "").to_i64? || 0_i64 - - # The joined text is split to several sub strings. The reduce joins those strings before parsing the date. - joined = channel_about_meta["joinedDateText"]?.try &.["runs"]?.try &.as_a.reduce("") { |acc, node| acc + node["text"].as_s } - .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0) - - # Normal Auto-generated channels - # https://support.google.com/youtube/answer/2579942 - # For auto-generated channels, channel_about_meta only has ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"] - if (channel_about_meta["primaryLinks"]?.try &.size || 0) == 1 && (channel_about_meta["primaryLinks"][0]?) && - (channel_about_meta["primaryLinks"][0]["title"]?.try &.["simpleText"]?.try &.as_s? || "") == "Auto-generated by YouTube" - auto_generated = true - end - end - end - tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map { |node| node["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 - - AboutChannel.new({ - ucid: ucid, - author: author, - auto_generated: auto_generated, - author_url: author_url, - author_thumbnail: author_thumbnail, - banner: banner, - description_html: description_html, - paid: paid, - total_views: total_views, - sub_count: sub_count, - joined: joined, - is_family_friendly: is_family_friendly, - allowed_regions: allowed_regions, - related_channels: related_channels, - tabs: tabs, - }) -end - -def get_channel_videos_response(ucid, page = 1, auto_generated = nil, sort_by = "newest") - continuation = produce_channel_videos_continuation(ucid, page, - auto_generated: auto_generated, sort_by: sort_by, v2: true) - - return request_youtube_api_browse(continuation) -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 - - return videos.size, videos -end - -def get_latest_videos(ucid) - initial_data = get_channel_videos_response(ucid) - author = initial_data["metadata"]?.try &.["channelMetadataRenderer"]?.try &.["title"]?.try &.as_s - - return extract_videos(initial_data, author, ucid) -end diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr new file mode 100644 index 00000000..8b0ecfbc --- /dev/null +++ b/src/invidious/channels/about.cr @@ -0,0 +1,192 @@ +# TODO: Refactor into either SearchChannel or InvidiousChannel +struct AboutChannel + include DB::Serializable + + property ucid : String + property author : String + property auto_generated : Bool + property author_url : String + property author_thumbnail : String + property banner : String? + property description_html : String + property paid : Bool + property total_views : Int64 + property sub_count : Int32 + property joined : Time + property is_family_friendly : Bool + property allowed_regions : Array(String) + property related_channels : Array(AboutRelatedChannel) + property tabs : Array(String) +end + +struct AboutRelatedChannel + include DB::Serializable + + property ucid : String + property author : String + property author_url : String + property author_thumbnail : String +end + +def get_about_info(ucid, locale) + result = YT_POOL.client &.get("/channel/#{ucid}/about?gl=US&hl=en") + if result.status_code != 200 + result = YT_POOL.client &.get("/user/#{ucid}/about?gl=US&hl=en") + end + + if md = result.headers["location"]?.try &.match(/\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/) + raise ChannelRedirect.new(channel_id: md["ucid"]) + end + + if result.status_code != 200 + raise InfoException.new("This channel does not exist.") + end + + about = XML.parse_html(result.body) + if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")])) + raise InfoException.new("This channel does not exist.") + end + + initdata = extract_initial_data(result.body) + if initdata.empty? + error_message = about.xpath_node(%q(//div[@class="yt-alert-content"])).try &.content.strip + error_message ||= translate(locale, "Could not get channel info.") + raise InfoException.new(error_message) + end + + if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]? + raise ChannelRedirect.new(channel_id: browse_endpoint["browseId"].to_s) + end + + auto_generated = false + # Check for special auto generated gaming channels + if !initdata.has_key?("metadata") + auto_generated = true + end + + if auto_generated + author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s + author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s + author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s + + # Raises a KeyError on failure. + banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? + banner = banners.try &.[-1]?.try &.["url"].as_s? + + description = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]["simpleText"].as_s + description_html = HTML.escape(description).gsub("\n", "<br>") + + paid = false + is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool + allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map { |a| a.as_s } + + related_channels = [] of AboutRelatedChannel + else + author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s + author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s + author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s + + ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s + + # Raises a KeyError on failure. + banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? + banner = banners.try &.[-1]?.try &.["url"].as_s? + + # if banner.includes? "channels/c4/default_banner" + # banner = nil + # end + + description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || "" + description_html = HTML.escape(description).gsub("\n", "<br>") + + paid = about.xpath_node(%q(//meta[@itemprop="paid"])).not_nil!["content"] == "True" + is_family_friendly = about.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).not_nil!["content"] == "True" + allowed_regions = about.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).not_nil!["content"].split(",") + + related_channels = initdata["contents"]["twoColumnBrowseResultsRenderer"] + .["secondaryContents"]?.try &.["browseSecondaryContentsRenderer"]["contents"][0]? + .try &.["verticalChannelSectionRenderer"]?.try &.["items"]?.try &.as_a.map do |node| + renderer = node["miniChannelRenderer"]? + related_id = renderer.try &.["channelId"]?.try &.as_s? + related_id ||= "" + + related_title = renderer.try &.["title"]?.try &.["simpleText"]?.try &.as_s? + related_title ||= "" + + related_author_url = renderer.try &.["navigationEndpoint"]?.try &.["commandMetadata"]?.try &.["webCommandMetadata"]? + .try &.["url"]?.try &.as_s? + related_author_url ||= "" + + related_author_thumbnails = renderer.try &.["thumbnail"]?.try &.["thumbnails"]?.try &.as_a? + related_author_thumbnails ||= [] of JSON::Any + + related_author_thumbnail = "" + if related_author_thumbnails.size > 0 + related_author_thumbnail = related_author_thumbnails[-1]["url"]?.try &.as_s? + related_author_thumbnail ||= "" + end + + AboutRelatedChannel.new({ + ucid: related_id, + author: related_title, + author_url: related_author_url, + author_thumbnail: related_author_thumbnail, + }) + end + related_channels ||= [] of AboutRelatedChannel + end + + total_views = 0_i64 + joined = Time.unix(0) + + tabs = [] of String + + tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?.try &.as_a? + if !tabs_json.nil? + # Retrieve information from the tabs array. The index we are looking for varies between channels. + tabs_json.each do |node| + # Try to find the about section which is located in only one of the tabs. + channel_about_meta = node["tabRenderer"]?.try &.["content"]?.try &.["sectionListRenderer"]? + .try &.["contents"]?.try &.[0]?.try &.["itemSectionRenderer"]?.try &.["contents"]? + .try &.[0]?.try &.["channelAboutFullMetadataRenderer"]? + + if !channel_about_meta.nil? + total_views = channel_about_meta["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D/, "").to_i64? || 0_i64 + + # The joined text is split to several sub strings. The reduce joins those strings before parsing the date. + joined = channel_about_meta["joinedDateText"]?.try &.["runs"]?.try &.as_a.reduce("") { |acc, node| acc + node["text"].as_s } + .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0) + + # Normal Auto-generated channels + # https://support.google.com/youtube/answer/2579942 + # For auto-generated channels, channel_about_meta only has ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"] + if (channel_about_meta["primaryLinks"]?.try &.size || 0) == 1 && (channel_about_meta["primaryLinks"][0]?) && + (channel_about_meta["primaryLinks"][0]["title"]?.try &.["simpleText"]?.try &.as_s? || "") == "Auto-generated by YouTube" + auto_generated = true + end + end + end + tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map { |node| node["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 + + AboutChannel.new({ + ucid: ucid, + author: author, + auto_generated: auto_generated, + author_url: author_url, + author_thumbnail: author_thumbnail, + banner: banner, + description_html: description_html, + paid: paid, + total_views: total_views, + sub_count: sub_count, + joined: joined, + is_family_friendly: is_family_friendly, + allowed_regions: allowed_regions, + related_channels: related_channels, + tabs: tabs, + }) +end diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr new file mode 100644 index 00000000..a6ab4015 --- /dev/null +++ b/src/invidious/channels/channels.cr @@ -0,0 +1,310 @@ +struct InvidiousChannel + include DB::Serializable + + property id : String + property author : String + property updated : Time + property deleted : Bool + property subscribed : Time? +end + +struct ChannelVideo + include DB::Serializable + + property id : String + property title : String + property published : Time + property updated : Time + property ucid : String + property author : String + property length_seconds : Int32 = 0 + property live_now : Bool = false + property premiere_timestamp : Time? = nil + property views : Int64? = nil + + def to_json(locale, json : JSON::Builder) + json.object do + json.field "type", "shortVideo" + + json.field "title", self.title + json.field "videoId", self.id + json.field "videoThumbnails" do + generate_thumbnails(json, self.id) + end + + json.field "lengthSeconds", self.length_seconds + + json.field "author", self.author + json.field "authorId", self.ucid + json.field "authorUrl", "/channel/#{self.ucid}" + json.field "published", self.published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) + + json.field "viewCount", self.views + end + end + + def to_json(locale, json : JSON::Builder | Nil = nil) + if json + to_json(locale, json) + else + JSON.build do |json| + to_json(locale, json) + end + end + end + + def to_xml(locale, query_params, xml : XML::Builder) + query_params["v"] = self.id + + xml.element("entry") do + xml.element("id") { xml.text "yt:video:#{self.id}" } + xml.element("yt:videoId") { xml.text self.id } + xml.element("yt:channelId") { xml.text self.ucid } + xml.element("title") { xml.text self.title } + xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}") + + xml.element("author") do + xml.element("name") { xml.text self.author } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } + end + + xml.element("content", type: "xhtml") do + xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do + xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do + xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") + end + end + end + + xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") } + xml.element("updated") { xml.text self.updated.to_s("%Y-%m-%dT%H:%M:%S%:z") } + + xml.element("media:group") do + xml.element("media:title") { xml.text self.title } + xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg", + width: "320", height: "180") + end + end + end + + def to_xml(locale, xml : XML::Builder | Nil = nil) + if xml + to_xml(locale, xml) + else + XML.build do |xml| + to_xml(locale, xml) + end + end + end + + def to_tuple + {% begin %} + { + {{*@type.instance_vars.map { |var| var.name }}} + } + {% end %} + end +end + +class ChannelRedirect < Exception + property channel_id : String + + def initialize(@channel_id) + end +end + +def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10) + finished_channel = Channel(String | Nil).new + + spawn do + active_threads = 0 + active_channel = Channel(Nil).new + + channels.each do |ucid| + if active_threads >= max_threads + active_channel.receive + active_threads -= 1 + end + + active_threads += 1 + spawn do + begin + get_channel(ucid, db, refresh, pull_all_videos) + finished_channel.send(ucid) + rescue ex + finished_channel.send(nil) + ensure + active_channel.send(nil) + end + end + end + end + + final = [] of String + channels.size.times do + if ucid = finished_channel.receive + final << ucid + end + end + + return final +end + +def get_channel(id, db, refresh = true, pull_all_videos = true) + if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel) + if refresh && Time.utc - channel.updated > 10.minutes + channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) + channel_array = channel.to_a + args = arg_array(channel_array) + + db.exec("INSERT INTO channels VALUES (#{args}) \ + ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", args: channel_array) + end + else + channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) + channel_array = channel.to_a + args = arg_array(channel_array) + + db.exec("INSERT INTO channels VALUES (#{args})", args: channel_array) + end + + return channel +end + +def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) + LOGGER.debug("fetch_channel: #{ucid}") + LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}") + + LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed") + rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body + LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed") + rss = XML.parse_html(rss) + + author = rss.xpath_node(%q(//feed/title)) + if !author + raise InfoException.new("Deleted or invalid channel") + end + author = author.content + + # Auto-generated channels + # https://support.google.com/youtube/answer/2579942 + if author.ends_with?(" - Topic") || + {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author + auto_generated = true + end + + LOGGER.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}") + + page = 1 + + LOGGER.trace("fetch_channel: #{ucid} : Downloading channel videos page") + initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) + videos = extract_videos(initial_data, author, ucid) + + LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") + rss.xpath_nodes("//feed/entry").each do |entry| + video_id = entry.xpath_node("videoid").not_nil!.content + title = entry.xpath_node("title").not_nil!.content + published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) + updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) + author = entry.xpath_node("author/name").not_nil!.content + ucid = entry.xpath_node("channelid").not_nil!.content + views = entry.xpath_node("group/community/statistics").try &.["views"]?.try &.to_i64? + views ||= 0_i64 + + channel_video = videos.select { |video| video.id == video_id }[0]? + + length_seconds = channel_video.try &.length_seconds + length_seconds ||= 0 + + live_now = channel_video.try &.live_now + live_now ||= false + + premiere_timestamp = channel_video.try &.premiere_timestamp + + video = ChannelVideo.new({ + id: video_id, + title: title, + published: published, + updated: Time.utc, + ucid: ucid, + author: author, + length_seconds: length_seconds, + live_now: live_now, + premiere_timestamp: premiere_timestamp, + views: views, + }) + + LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video") + + # We don't include the 'premiere_timestamp' here because channel pages don't include them, + # meaning the above timestamp is always null + was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ + ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ + updated = $4, ucid = $5, author = $6, length_seconds = $7, \ + live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) + + if was_insert + LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") + db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ + feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) + else + LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") + end + end + + if pull_all_videos + page += 1 + + ids = [] of String + + loop do + initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) + videos = extract_videos(initial_data, author, ucid) + + count = videos.size + videos = videos.map { |video| ChannelVideo.new({ + id: video.id, + title: video.title, + published: video.published, + updated: Time.utc, + ucid: video.ucid, + author: video.author, + length_seconds: video.length_seconds, + live_now: video.live_now, + premiere_timestamp: video.premiere_timestamp, + views: video.views, + }) } + + videos.each do |video| + ids << video.id + + # We are notified of Red videos elsewhere (PubSub), which includes a correct published date, + # so since they don't provide a published date here we can safely ignore them. + if Time.utc - video.published > 1.minute + was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ + ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ + updated = $4, ucid = $5, author = $6, length_seconds = $7, \ + live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) + + db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ + feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert + end + end + + break if count < 25 + page += 1 + end + end + + channel = InvidiousChannel.new({ + id: ucid, + author: author, + updated: Time.utc, + deleted: false, + subscribed: nil, + }) + + return channel +end diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr new file mode 100644 index 00000000..97ab30ec --- /dev/null +++ b/src/invidious/channels/community.cr @@ -0,0 +1,275 @@ +# TODO: Add "sort_by" +def fetch_channel_community(ucid, continuation, locale, format, thin_mode) + response = YT_POOL.client &.get("/channel/#{ucid}/community?gl=US&hl=en") + if response.status_code != 200 + response = YT_POOL.client &.get("/user/#{ucid}/community?gl=US&hl=en") + end + + if response.status_code != 200 + raise InfoException.new("This channel does not exist.") + end + + ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"] + + if !continuation || continuation.empty? + initial_data = extract_initial_data(response.body) + body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]? + + if !body + raise InfoException.new("Could not extract community tab.") + end + + body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"] + else + continuation = produce_channel_community_continuation(ucid, continuation) + + headers = HTTP::Headers.new + headers["cookie"] = response.cookies.add_request_headers(headers)["cookie"] + + session_token = response.body.match(/"XSRF_TOKEN":"(?<session_token>[^"]+)"/).try &.["session_token"]? || "" + post_req = { + session_token: session_token, + } + + response = YT_POOL.client &.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, form: post_req) + body = JSON.parse(response.body) + + body = body["response"]["continuationContents"]["itemSectionContinuation"]? || + body["response"]["continuationContents"]["backstageCommentsContinuation"]? + + if !body + raise InfoException.new("Could not extract continuation.") + end + end + + continuation = body["continuations"]?.try &.[0]["nextContinuationData"]["continuation"].as_s + posts = body["contents"].as_a + + if message = posts[0]["messageRenderer"]? + error_message = (message["text"]["simpleText"]? || + message["text"]["runs"]?.try &.[0]?.try &.["text"]?) + .try &.as_s || "" + raise InfoException.new(error_message) + end + + response = JSON.build do |json| + json.object do + json.field "authorId", ucid + json.field "comments" do + json.array do + posts.each do |post| + comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? || + post["backstageCommentsContinuation"]? + + post = post["backstagePostThreadRenderer"]?.try &.["post"]["backstagePostRenderer"]? || + post["commentThreadRenderer"]?.try &.["comment"]["commentRenderer"]? + + next if !post + + content_html = post["contentText"]?.try { |t| parse_content(t) } || "" + author = post["authorText"]?.try &.["simpleText"]? || "" + + json.object do + json.field "author", author + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + author_thumbnail = post["authorThumbnail"]["thumbnails"].as_a[0]["url"].as_s + + qualities.each do |quality| + json.object do + json.field "url", author_thumbnail.gsub(/s\d+-/, "s#{quality}-") + json.field "width", quality + json.field "height", quality + end + end + end + end + + if post["authorEndpoint"]? + json.field "authorId", post["authorEndpoint"]["browseEndpoint"]["browseId"] + json.field "authorUrl", post["authorEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s + else + json.field "authorId", "" + json.field "authorUrl", "" + end + + published_text = post["publishedTimeText"]["runs"][0]["text"].as_s + published = decode_date(published_text.rchop(" (edited)")) + + if published_text.includes?(" (edited)") + json.field "isEdited", true + else + json.field "isEdited", false + end + + like_count = post["actionButtons"]["commentActionButtonsRenderer"]["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"] + .try &.as_s.gsub(/\D/, "").to_i? || 0 + + json.field "content", html_to_content(content_html) + json.field "contentHtml", content_html + + json.field "published", published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) + + json.field "likeCount", like_count + json.field "commentId", post["postId"]? || post["commentId"]? || "" + json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid + + if attachment = post["backstageAttachment"]? + json.field "attachment" do + json.object do + case attachment.as_h + when .has_key?("videoRenderer") + attachment = attachment["videoRenderer"] + json.field "type", "video" + + if !attachment["videoId"]? + error_message = (attachment["title"]["simpleText"]? || + attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?) + + json.field "error", error_message + else + video_id = attachment["videoId"].as_s + + video_title = attachment["title"]["simpleText"]? || attachment["title"]["runs"]?.try &.[0]?.try &.["text"]? + json.field "title", video_title + json.field "videoId", video_id + json.field "videoThumbnails" do + generate_thumbnails(json, video_id) + end + + json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s) + + author_info = attachment["ownerText"]["runs"][0].as_h + + json.field "author", author_info["text"].as_s + json.field "authorId", author_info["navigationEndpoint"]["browseEndpoint"]["browseId"] + json.field "authorUrl", author_info["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"] + + # TODO: json.field "authorThumbnails", "channelThumbnailSupportedRenderers" + # TODO: json.field "authorVerified", "ownerBadges" + + published = decode_date(attachment["publishedTimeText"]["simpleText"].as_s) + + json.field "published", published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) + + view_count = attachment["viewCountText"]?.try &.["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64 + + json.field "viewCount", view_count + json.field "viewCountText", translate(locale, "`x` views", number_to_short_text(view_count)) + end + when .has_key?("backstageImageRenderer") + attachment = attachment["backstageImageRenderer"] + json.field "type", "image" + + json.field "imageThumbnails" do + json.array do + thumbnail = attachment["image"]["thumbnails"][0].as_h + width = thumbnail["width"].as_i + height = thumbnail["height"].as_i + aspect_ratio = (width.to_f / height.to_f) + url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") + + qualities = {320, 560, 640, 1280, 2000} + + qualities.each do |quality| + json.object do + json.field "url", url.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", (quality / aspect_ratio).ceil.to_i + end + end + end + end + # TODO + # when .has_key?("pollRenderer") + # attachment = attachment["pollRenderer"] + # json.field "type", "poll" + else + json.field "type", "unknown" + json.field "error", "Unrecognized attachment type." + end + end + end + end + + if comments && (reply_count = (comments["backstageCommentsRenderer"]["moreText"]["simpleText"]? || + comments["backstageCommentsRenderer"]["moreText"]["runs"]?.try &.[0]?.try &.["text"]?) + .try &.as_s.gsub(/\D/, "").to_i?) + continuation = comments["backstageCommentsRenderer"]["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s + continuation ||= "" + + json.field "replies" do + json.object do + json.field "replyCount", reply_count + json.field "continuation", extract_channel_community_cursor(continuation) + end + end + end + end + end + end + end + + if body["continuations"]? + continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s + json.field "continuation", extract_channel_community_cursor(continuation) + end + end + end + + if format == "html" + response = JSON.parse(response) + content_html = template_youtube_comments(response, locale, thin_mode) + + response = JSON.build do |json| + json.object do + json.field "contentHtml", content_html + end + end + end + + return response +end + +def produce_channel_community_continuation(ucid, cursor) + object = { + "80226972:embedded" => { + "2:string" => ucid, + "3:string" => cursor || "", + }, + } + + continuation = object.try { |i| Protodec::Any.cast_json(object) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation +end + +def extract_channel_community_cursor(continuation) + object = URI.decode_www_form(continuation) + .try { |i| Base64.decode(i) } + .try { |i| IO::Memory.new(i) } + .try { |i| Protodec::Any.parse(i) } + .try { |i| i["80226972:0:embedded"]["3:1:base64"].as_h } + + if object["53:2:embedded"]?.try &.["3:0:embedded"]? + object["53:2:embedded"]["3:0:embedded"]["2:0:string"] = object["53:2:embedded"]["3:0:embedded"] + .try { |i| i["2:0:base64"].as_h } + .try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i, padding: false) } + + object["53:2:embedded"]["3:0:embedded"].as_h.delete("2:0:base64") + end + + cursor = Protodec::Any.cast_json(object) + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + + cursor +end diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr new file mode 100644 index 00000000..222ec2b1 --- /dev/null +++ b/src/invidious/channels/playlists.cr @@ -0,0 +1,93 @@ +def fetch_channel_playlists(ucid, author, continuation, sort_by) + if continuation + response_json = request_youtube_api_browse(continuation) + continuationItems = response_json["onResponseReceivedActions"]? + .try &.[0]["appendContinuationItemsAction"]["continuationItems"] + + return [] of SearchItem, nil if !continuationItems + + items = [] of SearchItem + continuationItems.as_a.select(&.as_h.has_key?("gridPlaylistRenderer")).each { |item| + extract_item(item, author, ucid).try { |t| items << t } + } + + continuation = continuationItems.as_a.last["continuationItemRenderer"]? + .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s + else + url = "/channel/#{ucid}/playlists?flow=list&view=1" + + case sort_by + when "last", "last_added" + # + when "oldest", "oldest_created" + url += "&sort=da" + when "newest", "newest_created" + url += "&sort=dd" + else nil # Ignore + end + + response = YT_POOL.client &.get(url) + initial_data = extract_initial_data(response.body) + return [] of SearchItem, nil if !initial_data + + items = extract_items(initial_data, author, ucid) + continuation = response.body.match(/"token":"(?<continuation>[^"]+)"/).try &.["continuation"]? + end + + return items, continuation +end + +# ## NOTE: DEPRECATED +# Reason -> Unstable +# The Protobuf object must be provided with an id of the last playlist from the current "page" +# in order to fetch the next one accurately +# (if the id isn't included, entries shift around erratically between pages, +# leading to repetitions and skip overs) +# +# Since it's impossible to produce the appropriate Protobuf without an id being provided by the user, +# it's better to stick to continuation tokens provided by the first request and onward +def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated = false) + object = { + "80226972:embedded" => { + "2:string" => ucid, + "3:base64" => { + "2:string" => "playlists", + "6:varint" => 2_i64, + "7:varint" => 1_i64, + "12:varint" => 1_i64, + "13:string" => "", + "23:varint" => 0_i64, + }, + }, + } + + if cursor + cursor = Base64.urlsafe_encode(cursor, false) if !auto_generated + object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = cursor + end + + if auto_generated + object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x32_i64 + else + object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 1_i64 + case sort + when "oldest", "oldest_created" + object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 2_i64 + when "newest", "newest_created" + object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 3_i64 + when "last", "last_added" + object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 4_i64 + else nil # Ignore + end + end + + 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") + + continuation = object.try { |i| Protodec::Any.cast_json(object) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" +end diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr new file mode 100644 index 00000000..cc291e9e --- /dev/null +++ b/src/invidious/channels/videos.cr @@ -0,0 +1,89 @@ +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, + }, + }, + } + + 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["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 + + 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["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") + + continuation = object.try { |i| Protodec::Any.cast_json(object) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation +end + +def get_channel_videos_response(ucid, page = 1, auto_generated = nil, sort_by = "newest") + continuation = produce_channel_videos_continuation(ucid, page, + auto_generated: auto_generated, sort_by: sort_by, v2: true) + + return request_youtube_api_browse(continuation) +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 + + return videos.size, videos +end + +def get_latest_videos(ucid) + initial_data = get_channel_videos_response(ucid) + author = initial_data["metadata"]?.try &.["channelMetadataRenderer"]?.try &.["title"]?.try &.as_s + + return extract_videos(initial_data, author, ucid) +end + +# Used in bypass_captcha_job.cr +def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) + continuation = produce_channel_videos_continuation(ucid, page, auto_generated, sort_by, v2) + return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" +end diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 21d8b210..3466ad59 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -312,6 +312,8 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) author_thumbnail = "" end + author_name = HTML.escape(child["author"].as_s) + html << <<-END_HTML <div class="pure-g" style="width:100%"> <div class="channel-profile pure-u-4-24 pure-u-md-2-24"> @@ -320,7 +322,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) <div class="pure-u-20-24 pure-u-md-22-24"> <p> <b> - <a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{child["author"]}</a> + <a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{author_name}</a> </b> <p style="white-space:pre-wrap">#{child["contentHtml"]}</p> END_HTML diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 7353f2d9..d332ad37 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -700,22 +700,12 @@ def proxy_file(response, env) end end -# See https://github.com/kemalcr/kemal/pull/576 -class HTTP::Server::Response::Output - def close - return if closed? - - unless response.wrote_headers? - response.content_length = @out_count - end - - ensure_headers_written - - super - - if @chunked - @io << "0\r\n\r\n" +class HTTP::Server::Response + class Output + private def unbuffered_flush @io.flush + rescue ex : IO::Error + unbuffered_close end end end diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index dd46feab..d5e06b25 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -1,31 +1,42 @@ LOCALES = { - "ar" => load_locale("ar"), - "de" => load_locale("de"), - "el" => load_locale("el"), - "en-US" => load_locale("en-US"), - "eo" => load_locale("eo"), - "es" => load_locale("es"), - "fa" => load_locale("fa"), - "fi" => load_locale("fi"), - "fr" => load_locale("fr"), - "he" => load_locale("he"), - "hr" => load_locale("hr"), - "id" => load_locale("id"), - "is" => load_locale("is"), - "it" => load_locale("it"), - "ja" => load_locale("ja"), - "nb-NO" => load_locale("nb-NO"), - "nl" => load_locale("nl"), - "pl" => load_locale("pl"), - "pt-BR" => load_locale("pt-BR"), - "pt-PT" => load_locale("pt-PT"), - "ro" => load_locale("ro"), - "ru" => load_locale("ru"), - "sv-SE" => load_locale("sv-SE"), - "tr" => load_locale("tr"), - "uk" => load_locale("uk"), - "zh-CN" => load_locale("zh-CN"), - "zh-TW" => load_locale("zh-TW"), + "ar" => load_locale("ar"), # Arabic + "bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh) + "cs" => load_locale("cs"), # Czech + "da" => load_locale("da"), # Danish + "de" => load_locale("de"), # German + "el" => load_locale("el"), # Greek + "en-US" => load_locale("en-US"), # English (US) + "eo" => load_locale("eo"), # Esperanto + "es" => load_locale("es"), # Spanish + "eu" => load_locale("eu"), # Basque + "fa" => load_locale("fa"), # Persian + "fi" => load_locale("fi"), # Finnish + "fr" => load_locale("fr"), # French + "he" => load_locale("he"), # Hebrew + "hr" => load_locale("hr"), # Croatian + "hu-HU" => load_locale("hu-HU"), # Hungarian + "id" => load_locale("id"), # Indonesian + "is" => load_locale("is"), # Icelandic + "it" => load_locale("it"), # Italian + "ja" => load_locale("ja"), # Japanese + "lt" => load_locale("lt"), # Lithuanian + "nb-NO" => load_locale("nb-NO"), # Norwegian Bokmål + "nl" => load_locale("nl"), # Dutch + "pl" => load_locale("pl"), # Polish + "pt-BR" => load_locale("pt-BR"), # Portuguese (Brazil) + "pt-PT" => load_locale("pt-PT"), # Portuguese (Portugal) + "ro" => load_locale("ro"), # Romanian + "ru" => load_locale("ru"), # Russian + "si" => load_locale("si"), # Sinhala + "sk" => load_locale("sk"), # Slovak + "sr" => load_locale("sr"), # Serbian + "sr_Cyrl" => load_locale("sr_Cyrl"), # Serbian (cyrillic) + "sv-SE" => load_locale("sv-SE"), # Swedish + "tr" => load_locale("tr"), # Turkish + "uk" => load_locale("uk"), # Ukrainian + "vi" => load_locale("vi"), # Vietnamese + "zh-CN" => load_locale("zh-CN"), # Chinese (Simplified) + "zh-TW" => load_locale("zh-TW"), # Chinese (Traditional) } def load_locale(name) diff --git a/src/invidious/helpers/youtube_api.cr b/src/invidious/helpers/youtube_api.cr index e27d4088..734fddcd 100644 --- a/src/invidious/helpers/youtube_api.cr +++ b/src/invidious/helpers/youtube_api.cr @@ -25,12 +25,14 @@ end #################################################################### # request_youtube_api_browse(continuation) -# request_youtube_api_browse(browse_id, params) +# request_youtube_api_browse(browse_id, params, region) # # Requests the youtubei/v1/browse endpoint with the required headers -# and POST data in order to get a JSON reply in english US that can +# and POST data in order to get a JSON reply in english that can # be easily parsed. # +# The region can be provided, default is US. +# # The requested data can either be: # # - A continuation token (ctoken). Depending on this token's @@ -49,11 +51,11 @@ def request_youtube_api_browse(continuation : String) return _youtube_api_post_json("/youtubei/v1/browse", data) end -def request_youtube_api_browse(browse_id : String, params : String) +def request_youtube_api_browse(browse_id : String, params : String, region : String = "US") # JSON Request data, required by the API data = { "browseId" => browse_id, - "context" => make_youtube_api_context("US"), + "context" => make_youtube_api_context(region), } # Append the additionnal parameters if those were provided diff --git a/src/invidious/jobs/bypass_captcha_job.cr b/src/invidious/jobs/bypass_captcha_job.cr index 87cf7688..71f8a938 100644 --- a/src/invidious/jobs/bypass_captcha_job.cr +++ b/src/invidious/jobs/bypass_captcha_job.cr @@ -2,7 +2,11 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob def begin loop do begin - {"/watch?v=zj82_v2R6ts&gl=US&hl=en&has_verified=1&bpctr=9999999999", produce_channel_videos_url(ucid: "UCK87Lox575O_HCHBWaBSyGA")}.each do |path| + random_video = PG_DB.query_one?("select id, ucid from (select id, ucid from channel_videos limit 1000) as s ORDER BY RANDOM() LIMIT 1", as: {id: String, ucid: String}) + if !random_video + random_video = {id: "zj82_v2R6ts", ucid: "UCK87Lox575O_HCHBWaBSyGA"} + end + {"/watch?v=#{random_video["id"]}&gl=US&hl=en&has_verified=1&bpctr=9999999999", produce_channel_videos_url(ucid: random_video["ucid"])}.each do |path| response = YT_POOL.client &.get(path) if response.body.includes?("To continue with your YouTube experience, please fill out the form below.") html = XML.parse_html(response.body) diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 5db32788..5e1e9431 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -165,11 +165,11 @@ class Invidious::Routes::Embed < Invidious::Routes::BaseRoute captions = video.captions preferred_captions = captions.select { |caption| - params.preferred_captions.includes?(caption.name.simpleText) || + params.preferred_captions.includes?(caption.name) || params.preferred_captions.includes?(caption.languageCode.split("-")[0]) } preferred_captions.sort_by! { |caption| - (params.preferred_captions.index(caption.name.simpleText) || + (params.preferred_captions.index(caption.name) || params.preferred_captions.index(caption.languageCode.split("-")[0])).not_nil! } captions = captions - preferred_captions diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index d0338882..c6c7c154 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -150,11 +150,11 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute captions = video.captions preferred_captions = captions.select { |caption| - params.preferred_captions.includes?(caption.name.simpleText) || + params.preferred_captions.includes?(caption.name) || params.preferred_captions.includes?(caption.languageCode.split("-")[0]) } preferred_captions.sort_by! { |caption| - (params.preferred_captions.index(caption.name.simpleText) || + (params.preferred_captions.index(caption.name) || params.preferred_captions.index(caption.languageCode.split("-")[0])).not_nil! } captions = captions - preferred_captions diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index 910a99d8..2ab1e7ba 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -2,31 +2,19 @@ def fetch_trending(trending_type, region, locale) region ||= "US" region = region.upcase - trending = "" plid = nil - if trending_type && trending_type != "Default" - if trending_type == "Music" - trending_type = 1 - elsif trending_type == "Gaming" - trending_type = 2 - elsif trending_type == "Movies" - trending_type = 3 - end - - response = YT_POOL.client &.get("/feed/trending?gl=#{region}&hl=en").body - - initial_data = extract_initial_data(response) - url = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"][trending_type]["tabRenderer"]["endpoint"]["commandMetadata"]["webCommandMetadata"]["url"] - url = "#{url}&gl=#{region}&hl=en" - - trending = YT_POOL.client &.get(url).body - plid = extract_plid(url) - else - trending = YT_POOL.client &.get("/feed/trending?gl=#{region}&hl=en").body + if trending_type == "Music" + params = "4gINGgt5dG1hX2NoYXJ0cw%3D%3D" + elsif trending_type == "Gaming" + params = "4gIcGhpnYW1pbmdfY29ycHVzX21vc3RfcG9wdWxhcg%3D%3D" + elsif trending_type == "Movies" + params = "4gIKGgh0cmFpbGVycw%3D%3D" + else # Default + params = "" end - initial_data = extract_initial_data(trending) + initial_data = request_youtube_api_browse("FEtrending", params: params, region: region) trending = extract_videos(initial_data) return {trending, plid} diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 116aafc7..27c54b14 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -425,9 +425,9 @@ struct Video json.array do self.captions.each do |caption| json.object do - json.field "label", caption.name.simpleText + json.field "label", caption.name json.field "languageCode", caption.languageCode - json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name.simpleText)}" + json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" end end end @@ -706,8 +706,12 @@ struct Video def captions : Array(Caption) return @captions.as(Array(Caption)) if @captions captions = info["captions"]?.try &.["playerCaptionsTracklistRenderer"]?.try &.["captionTracks"]?.try &.as_a.map do |caption| - caption = Caption.from_json(caption.to_json) - caption.name.simpleText = caption.name.simpleText.split(" - ")[0] + name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"] + languageCode = caption["languageCode"].to_s + baseUrl = caption["baseUrl"].to_s + + caption = Caption.new(name.to_s, languageCode, baseUrl) + caption.name = caption.name.split(" - ")[0] caption end captions ||= [] of Caption @@ -782,18 +786,19 @@ struct Video end end -struct CaptionName - include JSON::Serializable +struct Caption + property name + property languageCode + property baseUrl - property simpleText : String -end + getter name : String + getter languageCode : String + getter baseUrl : String -struct Caption - include JSON::Serializable + setter name - property name : CaptionName - property baseUrl : String - property languageCode : String + def initialize(@name, @languageCode, @baseUrl) + end end class VideoRedirect < Exception @@ -989,9 +994,33 @@ def fetch_video(id, region) # Try to pull streams from embed URL if info["reason"]? - embed_page = YT_POOL.client &.get("/embed/#{id}").body - sts = embed_page.match(/"sts"\s*:\s*(?<sts>\d+)/).try &.["sts"]? || "" - embed_info = HTTP::Params.parse(YT_POOL.client &.get("/get_video_info?html5=1&video_id=#{id}&eurl=https://youtube.googleapis.com/v/#{id}&gl=US&hl=en&sts=#{sts}").body) + required_parameters = { + "video_id" => id, + "eurl" => "https://youtube.googleapis.com/v/#{id}", + "html5" => "1", + "gl" => "US", + "hl" => "en", + } + if info["reason"].as_s.includes?("inappropriate") + # The html5, c and cver parameters are required in order to extract age-restricted videos + # See https://github.com/yt-dlp/yt-dlp/commit/4e6767b5f2e2523ebd3dd1240584ead53e8c8905 + required_parameters.merge!({ + "c" => "TVHTML5", + "cver" => "6.20180913", + }) + + # In order to actually extract video info without error, the `x-youtube-client-version` + # has to be set to the same version as `cver` above. + additional_headers = HTTP::Headers{"x-youtube-client-version" => "6.20180913"} + else + embed_page = YT_POOL.client &.get("/embed/#{id}").body + sts = embed_page.match(/"sts"\s*:\s*(?<sts>\d+)/).try &.["sts"]? || "" + required_parameters["sts"] = sts + additional_headers = HTTP::Headers{} of String => String + end + + embed_info = HTTP::Params.parse(YT_POOL.client &.get("/get_video_info?#{URI::Params.encode(required_parameters)}", + headers: additional_headers).body) if embed_info["player_response"]? player_response = JSON.parse(embed_info["player_response"]) diff --git a/src/invidious/views/authorize_token.ecr b/src/invidious/views/authorize_token.ecr index 8ea99010..2dc948d9 100644 --- a/src/invidious/views/authorize_token.ecr +++ b/src/invidious/views/authorize_token.ecr @@ -9,13 +9,13 @@ <%= translate(locale, "Token") %> </h3> </div> - <div class="pure-u-1-3" style="text-align:center"> - <h3> + <div class="pure-u-1-3"> + <h3 style="text-align:center"> <a href="/token_manager"><%= translate(locale, "Token manager") %></a> </h3> </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> + <div class="pure-u-1-3"> + <h3 style="text-align:right"> <a href="/preferences"><%= translate(locale, "Preferences") %></a> </h3> </div> diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 21038394..09cfb76e 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -1,6 +1,9 @@ +<% ucid = channel.ucid %> +<% author = HTML.escape(channel.author) %> + <% content_for "header" do %> -<title><%= channel.author %> - Invidious</title> -<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= channel.ucid %>" /> +<title><%= author %> - Invidious</title> +<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= ucid %>" /> <% end %> <% if channel.banner %> @@ -17,30 +20,30 @@ <div class="pure-u-2-3"> <div class="channel-profile"> <img src="/ggpht<%= URI.parse(channel.author_thumbnail).request_target %>"> - <span><%= channel.author %></span> + <span><%= author %></span> </div> </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> - <a href="/feed/channel/<%= channel.ucid %>"><i class="icon ion-logo-rss"></i></a> + <div class="pure-u-1-3"> + <h3 style="text-align:right"> + <a href="/feed/channel/<%= ucid %>"><i class="icon ion-logo-rss"></i></a> </h3> </div> </div> <div class="h-box"> - <p><span style="white-space:pre-wrap"><%= channel.description_html %></span></p> + <div id="descriptionWrapper"> + <p><span style="white-space:pre-wrap"><%= channel.description_html %></span></p> + </div> </div> <div class="h-box"> - <% ucid = channel.ucid %> - <% author = channel.author %> <% sub_count_text = number_to_short_text(channel.sub_count) %> <%= rendered "components/subscribe_widget" %> </div> <div class="pure-g h-box"> <div class="pure-u-1-3"> - <a href="https://www.youtube.com/channel/<%= channel.ucid %>"><%= translate(locale, "View channel on YouTube") %></a> + <a href="https://www.youtube.com/channel/<%= ucid %>"><%= translate(locale, "View channel on YouTube") %></a> <div class="pure-u-1 pure-md-1-3"> <a href="/redirect?referer=<%= env.get?("current_page") %>"><%= translate(locale, "Switch Invidious Instance") %></a> </div> @@ -53,12 +56,12 @@ <% if channel.auto_generated %> <b><%= translate(locale, "Playlists") %></b> <% else %> - <a href="/channel/<%= channel.ucid %>/playlists"><%= translate(locale, "Playlists") %></a> + <a href="/channel/<%= ucid %>/playlists"><%= translate(locale, "Playlists") %></a> <% end %> </div> <div class="pure-u-1 pure-md-1-3"> <% if channel.tabs.includes? "community" %> - <a href="/channel/<%= channel.ucid %>/community"><%= translate(locale, "Community") %></a> + <a href="/channel/<%= ucid %>/community"><%= translate(locale, "Community") %></a> <% end %> </div> </div> @@ -70,7 +73,7 @@ <% if sort_by == sort %> <b><%= translate(locale, sort) %></b> <% else %> - <a href="/channel/<%= channel.ucid %>?page=<%= page %>&sort_by=<%= sort %>"> + <a href="/channel/<%= ucid %>?page=<%= page %>&sort_by=<%= sort %>"> <%= translate(locale, sort) %> </a> <% end %> @@ -85,17 +88,15 @@ </div> <div class="pure-g"> - <% items.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% items.each do |item| %> + <%= rendered "components/item" %> +<% end %> </div> <div class="pure-g h-box"> <div class="pure-u-1 pure-u-lg-1-5"> <% if page > 1 %> - <a href="/channel/<%= channel.ucid %>?page=<%= page - 1 %><% if sort_by != "newest" %>&sort_by=<%= HTML.escape(sort_by) %><% end %>"> + <a href="/channel/<%= ucid %>?page=<%= page - 1 %><% if sort_by != "newest" %>&sort_by=<%= HTML.escape(sort_by) %><% end %>"> <%= translate(locale, "Previous page") %> </a> <% end %> @@ -103,7 +104,7 @@ <div class="pure-u-1 pure-u-lg-3-5"></div> <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> <% if count == 60 %> - <a href="/channel/<%= channel.ucid %>?page=<%= page + 1 %><% if sort_by != "newest" %>&sort_by=<%= HTML.escape(sort_by) %><% end %>"> + <a href="/channel/<%= ucid %>?page=<%= page + 1 %><% if sort_by != "newest" %>&sort_by=<%= HTML.escape(sort_by) %><% end %>"> <%= translate(locale, "Next page") %> </a> <% end %> diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index b0092e5f..15d8ed1e 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -1,5 +1,8 @@ +<% ucid = channel.ucid %> +<% author = HTML.escape(channel.author) %> + <% content_for "header" do %> -<title><%= channel.author %> - Invidious</title> +<title><%= author %> - Invidious</title> <% end %> <% if channel.banner %> @@ -16,23 +19,23 @@ <div class="pure-u-2-3"> <div class="channel-profile"> <img src="/ggpht<%= URI.parse(channel.author_thumbnail).request_target %>"> - <span><%= channel.author %></span> + <span><%= author %></span> </div> </div> <div class="pure-u-1-3" style="text-align:right"> - <h3> + <h3 style="text-align:right"> <a href="/feed/channel/<%= channel.ucid %>"><i class="icon ion-logo-rss"></i></a> </h3> </div> </div> <div class="h-box"> - <p><span style="white-space:pre-wrap"><%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content %></span></p> + <div id="descriptionWrapper"> + <p><span style="white-space:pre-wrap"><%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content %></span></p> + </div> </div> <div class="h-box"> - <% ucid = channel.ucid %> - <% author = channel.author %> <% sub_count_text = number_to_short_text(channel.sub_count) %> <%= rendered "components/subscribe_widget" %> </div> @@ -77,7 +80,7 @@ <script id="community_data" type="application/json"> <%= { - "ucid" => channel.ucid, + "ucid" => ucid, "youtube_comments_text" => HTML.escape(translate(locale, "View YouTube comments")), "comments_text" => HTML.escape(translate(locale, "View `x` comments", "{commentCount}")), "hide_replies_text" => HTML.escape(translate(locale, "Hide replies")), diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 6f027bee..68aa1812 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -2,13 +2,13 @@ <div class="h-box"> <% case item when %> <% when SearchChannel %> - <a style="width:100%" href="/channel/<%= item.ucid %>"> + <a href="/channel/<%= item.ucid %>"> <% if !env.get("preferences").as(Preferences).thin_mode %> <center> <img style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/> </center> <% end %> - <p><%= item.author %></p> + <p dir="auto"><%= HTML.escape(item.author) %></p> </a> <p><%= translate(locale, "`x` subscribers", number_with_separator(item.subscriber_count)) %></p> <% if !item.auto_generated %><p><%= translate(locale, "`x` videos", number_with_separator(item.video_count)) %></p><% end %> @@ -27,15 +27,13 @@ <p class="length"><%= number_with_separator(item.video_count) %> videos</p> </div> <% end %> - <p><%= item.title %></p> + <p dir="auto"><%= HTML.escape(item.title) %></p> + </a> + <a href="/channel/<%= item.ucid %>"> + <p dir="auto"><b><%= HTML.escape(item.author) %></b></p> </a> - <p> - <b> - <a style="width:100%" href="/channel/<%= item.ucid %>"><%= item.author %></a> - </b> - </p> <% when MixVideo %> - <a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.rdid %>"> + <a href="/watch?v=<%= item.id %>&list=<%= item.rdid %>"> <% if !env.get("preferences").as(Preferences).thin_mode %> <div class="thumbnail"> <img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> @@ -44,13 +42,11 @@ <% end %> </div> <% end %> - <p><%= HTML.escape(item.title) %></p> + <p dir="auto"><%= HTML.escape(item.title) %></p> + </a> + <a href="/channel/<%= item.ucid %>"> + <p dir="auto"><b><%= HTML.escape(item.author) %></b></p> </a> - <p> - <b> - <a style="width:100%" href="/channel/<%= item.ucid %>"><%= item.author %></a> - </b> - </p> <% when PlaylistVideo %> <a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>"> <% if !env.get("preferences").as(Preferences).thin_mode %> @@ -76,30 +72,33 @@ <% end %> </div> <% end %> - <p><a href="/watch?v=<%= item.id %>"><%= HTML.escape(item.title) %></a></p> + <p dir="auto"><%= HTML.escape(item.title) %></p> </a> - <p> - <b> - <a style="width:100%" href="/channel/<%= item.ucid %>"><%= item.author %></a> - </b> - </p> - <h5 class="pure-g"> - <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %> - <div class="pure-u-2-3"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></div> - <% elsif Time.utc - item.published > 1.minute %> - <div class="pure-u-2-3"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></div> - <% else %> - <div class="pure-u-2-3"></div> - <% end %> + <div class="video-card-row flexible"> + <div class="flex-left"><a href="/channel/<%= item.ucid %>"> + <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %></p> + </a></div> + </div> + + <div class="video-card-row flexible"> + <div class="flex-left"> + <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %> + <p dir="auto"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></p> + <% elsif Time.utc - item.published > 1.minute %> + <p dir="auto"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></p> + <% end %> + </div> - <div class="pure-u-1-3" style="text-align:right"> - <%= item.responds_to?(:views) && item.views ? translate(locale, "`x` views", number_to_short_text(item.views || 0)) : "" %> + <% if item.responds_to?(:views) && item.views %> + <div class="flex-right"> + <p dir="auto"><%= translate(locale, "`x` views", number_to_short_text(item.views || 0)) %></p> </div> - </h5> + <% end %> + </div> <% else %> - <% if !env.get("preferences").as(Preferences).thin_mode %> - <a style="width:100%" href="/watch?v=<%= item.id %>"> + <a style="width:100%" href="/watch?v=<%= item.id %>"> + <% if !env.get("preferences").as(Preferences).thin_mode %> <div class="thumbnail"> <img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> <% if env.get? "show_watched" %> @@ -129,44 +128,49 @@ <% end %> <% if item.responds_to?(:live_now) && item.live_now %> - <p class="length"><i class="icon ion-ios-play-circle"></i> <%= translate(locale, "LIVE") %></p> + <p class="length" dir="auto"><i class="icon ion-ios-play-circle"></i> <%= translate(locale, "LIVE") %></p> <% elsif item.length_seconds != 0 %> <p class="length"><%= recode_length_seconds(item.length_seconds) %></p> <% end %> </div> - </a> - <% end %> - <p><a href="/watch?v=<%= item.id %>"><%= HTML.escape(item.title) %></a></p> - <div style="display: flex"> - <b style="flex: 1;"> - <a style="width:100%" href="/channel/<%= item.ucid %>"><%= item.author %></a> - </b> - <div class="icon-buttons"> - <a title="<%=translate(locale, "Watch on YouTube")%>" href="https://www.youtube.com/watch?v=<%= item.id %>"> - <i class="icon ion-logo-youtube"></i> - </a> - <a title="<%=translate(locale, "Audio mode")%>" href="/watch?v=<%= item.id %>&listen=1"> - <i class="icon ion-md-headset"></i> - </a> - <a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=<%=HTML.escape("watch?v=#{item.id}")%>"> - <i class="icon ion-md-jet"></i> - </a> + <% end %> + <p dir="auto"><%= HTML.escape(item.title) %></p> + </a> + + <div class="video-card-row flexible"> + <div class="flex-left"><a href="/channel/<%= item.ucid %>"> + <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %></p> + </a></div> + <div class="flex-right"> + <div class="icon-buttons"> + <a title="<%=translate(locale, "Watch on YouTube")%>" href="https://www.youtube.com/watch?v=<%= item.id %>"> + <i class="icon ion-logo-youtube"></i> + </a> + <a title="<%=translate(locale, "Audio mode")%>" href="/watch?v=<%= item.id %>&listen=1"> + <i class="icon ion-md-headset"></i> + </a> + <a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=<%=HTML.escape("watch?v=#{item.id}")%>"> + <i class="icon ion-md-jet"></i> + </a> + </div> </div> </div> - <h5 class="pure-g"> - <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %> - <div class="pure-u-2-3"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></div> - <% elsif Time.utc - item.published > 1.minute %> - <div class="pure-u-2-3"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></div> - <% else %> - <div class="pure-u-2-3"></div> - <% end %> + <div class="video-card-row flexible"> + <div class="flex-left"> + <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %> + <p class="video-data" dir="auto"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></p> + <% elsif Time.utc - item.published > 1.minute %> + <p class="video-data" dir="auto"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></p> + <% end %> + </div> - <div class="pure-u-1-3" style="text-align:right"> - <%= item.responds_to?(:views) && item.views ? translate(locale, "`x` views", number_to_short_text(item.views || 0)) : "" %> + <% if item.responds_to?(:views) && item.views %> + <div class="flex-right"> + <p class="video-data" dir="auto"><%= translate(locale, "`x` views", number_to_short_text(item.views || 0)) %></p> </div> - </h5> + <% end %> + </div> <% end %> </div> </div> diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index cff3e60a..03252418 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -10,28 +10,33 @@ <% audio_streams.each_with_index do |fmt, i| %> <source src="/latest_version?id=<%= video.id %>&itag=<%= fmt["itag"] %><% if params.local %>&local=true<% end %>" type='<%= fmt["mimeType"] %>' label="<%= fmt["bitrate"] %>k" selected="<%= i == 0 ? true : false %>"> <% end %> - <% else %> + <% else %> <% if params.quality == "dash" %> <source src="/api/manifest/dash/id/<%= video.id %>?local=true&unique_res=1" type='application/dash+xml' label="dash"> <% end %> - <% fmt_stream.each_with_index do |fmt, i| %> - <% if params.quality %> - <source src="/latest_version?id=<%= video.id %>&itag=<%= fmt["itag"] %><% if params.local %>&local=true<% end %>" type='<%= fmt["mimeType"] %>' label="<%= fmt["quality"] %>" selected="<%= params.quality == fmt["quality"] %>"> - <% else %> - <source src="/latest_version?id=<%= video.id %>&itag=<%= fmt["itag"] %><% if params.local %>&local=true<% end %>" type='<%= fmt["mimeType"] %>' label="<%= fmt["quality"] %>" selected="<%= i == 0 ? true : false %>"> - <% end %> + <% + fmt_stream.each_with_index do |fmt, i| + src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}" + src_url += "&local=true" if params.local + + quality = fmt["quality"] + mimetype = fmt["mimeType"] + + selected = params.quality ? (params.quality == quality) : (i == 0) + %> + <source src="<%= src_url %>" type="<%= mimetype %>" label="<%= quality %>" selected="<%= selected %>"> <% end %> <% end %> <% preferred_captions.each do |caption| %> - <track kind="captions" src="/api/v1/captions/<%= video.id %>?label=<%= caption.name.simpleText %>&hl=<%= env.get("preferences").as(Preferences).locale %>" - label="<%= caption.name.simpleText %>"> + <track kind="captions" src="/api/v1/captions/<%= video.id %>?label=<%= caption.name %>&hl=<%= env.get("preferences").as(Preferences).locale %>" + label="<%= caption.name %>"> <% end %> <% captions.each do |caption| %> - <track kind="captions" src="/api/v1/captions/<%= video.id %>?label=<%= caption.name.simpleText %>&hl=<%= env.get("preferences").as(Preferences).locale %>" - label="<%= caption.name.simpleText %>"> + <track kind="captions" src="/api/v1/captions/<%= video.id %>?label=<%= caption.name %>&hl=<%= env.get("preferences").as(Preferences).locale %>" + label="<%= caption.name %>"> <% end %> <% end %> </video> diff --git a/src/invidious/views/edit_playlist.ecr b/src/invidious/views/edit_playlist.ecr index bd8d6207..5046abc1 100644 --- a/src/invidious/views/edit_playlist.ecr +++ b/src/invidious/views/edit_playlist.ecr @@ -1,14 +1,16 @@ +<% title = HTML.escape(playlist.title) %> + <% content_for "header" do %> -<title><%= playlist.title %> - Invidious</title> +<title><%= title %> - Invidious</title> <link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/playlist/<%= plid %>" /> <% end %> <form class="pure-form" action="/edit_playlist?list=<%= plid %>" method="post"> <div class="pure-g h-box"> <div class="pure-u-2-3"> - <h3><input class="pure-input-1" maxlength="150" name="title" type="text" value="<%= playlist.title %>"></h3> + <h3><input class="pure-input-1" maxlength="150" name="title" type="text" value="<%= title %>"></h3> <b> - <%= playlist.author %> | + <%= HTML.escape(playlist.author) %> | <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> | <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> | <i class="icon <%= {"ion-md-globe", "ion-ios-unlock", "ion-ios-lock"}[playlist.privacy.value] %>"></i> @@ -55,11 +57,9 @@ </div> <div class="pure-g"> - <% videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% videos.each do |item| %> + <%= rendered "components/item" %> +<% end %> </div> <div class="pure-g h-box"> diff --git a/src/invidious/views/history.ecr b/src/invidious/views/history.ecr index fe8c70b9..40584979 100644 --- a/src/invidious/views/history.ecr +++ b/src/invidious/views/history.ecr @@ -6,13 +6,13 @@ <div class="pure-u-1-3"> <h3><%= translate(locale, "`x` videos", %(<span id="count">#{user.watched.size}</span>)) %></h3> </div> - <div class="pure-u-1-3" style="text-align:center"> - <h3> + <div class="pure-u-1-3"> + <h3 style="text-align:center"> <a href="/feed/subscriptions"><%= translate(locale, "`x` subscriptions", %(<span id="count">#{user.subscriptions.size}</span>)) %></a> </h3> </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> + <div class="pure-u-1-3"> + <h3 style="text-align:right"> <a href="/clear_watch_history"><%= translate(locale, "Clear watch history") %></a> </h3> </div> @@ -28,31 +28,27 @@ <script src="/js/watched_widget.js"></script> <div class="pure-g"> - <% watched.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <div class="pure-u-1 pure-u-md-1-4"> - <div class="h-box"> - <a style="width:100%" href="/watch?v=<%= item %>"> - <% if !env.get("preferences").as(Preferences).thin_mode %> - <div class="thumbnail"> - <img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg"/> - <form data-onsubmit="return_false" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post"> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> - <p class="watched"> - <a data-onclick="mark_unwatched" data-id="<%= item %>" href="javascript:void(0)"> - <button type="submit" style="all:unset"> - <i class="icon ion-md-trash"></i> - </button> - </a> - </p> - </form> - </div> - <p></p> - <% end %> - </a> - </div> - </div> - <% end %> + <% watched.each do |item| %> + <div class="pure-u-1 pure-u-md-1-4"> + <div class="h-box"> + <a style="width:100%" href="/watch?v=<%= item %>"> + <% if !env.get("preferences").as(Preferences).thin_mode %> + <div class="thumbnail"> + <img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg"/> + <form data-onsubmit="return_false" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post"> + <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> + <p class="watched"> + <a data-onclick="mark_unwatched" data-id="<%= item %>" href="javascript:void(0)"> + <button type="submit" style="all:unset"><i class="icon ion-md-trash"></i></button> + </a> + </p> + </form> + </div> + <p></p> + <% end %> + </a> + </div> + </div> <% end %> </div> diff --git a/src/invidious/views/login.ecr b/src/invidious/views/login.ecr index b6e8117b..1f6618e8 100644 --- a/src/invidious/views/login.ecr +++ b/src/invidious/views/login.ecr @@ -26,7 +26,7 @@ <form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=google" method="post"> <fieldset> <% if email %> - <input name="email" type="hidden" value="<%= email %>"> + <input name="email" type="hidden" value="<%= HTML.escape(email) %>"> <% else %> <label for="email"><%= translate(locale, "E-mail") %> :</label> <input required class="pure-input-1" name="email" type="email" placeholder="<%= translate(locale, "E-mail") %>"> @@ -62,7 +62,7 @@ <form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=invidious" method="post"> <fieldset> <% if email %> - <input name="email" type="hidden" value="<%= email %>"> + <input name="email" type="hidden" value="<%= HTML.escape(email) %>"> <% else %> <label for="email"><%= translate(locale, "User ID") %> :</label> <input required class="pure-input-1" name="email" type="text" placeholder="<%= translate(locale, "User ID") %>"> diff --git a/src/invidious/views/mix.ecr b/src/invidious/views/mix.ecr index e9c0dcbc..e55b00f8 100644 --- a/src/invidious/views/mix.ecr +++ b/src/invidious/views/mix.ecr @@ -1,22 +1,20 @@ <% content_for "header" do %> -<title><%= mix.title %> - Invidious</title> +<title><%= HTML.escape(mix.title) %> - Invidious</title> <% end %> <div class="pure-g h-box"> <div class="pure-u-2-3"> - <h3><%= mix.title %></h3> + <h3><%= HTML.escape(mix.title) %></h3> </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> + <div class="pure-u-1-3"> + <h3 style="text-align:right"> <a href="/feed/playlist/<%= mix.id %>"><i class="icon ion-logo-rss"></i></a> </h3> </div> </div> <div class="pure-g"> - <% mix.videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% mix.videos.each do |item| %> + <%= rendered "components/item" %> +<% end %> </div> diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index a19dd182..b1fee211 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -1,17 +1,20 @@ +<% title = HTML.escape(playlist.title) %> +<% author = HTML.escape(playlist.author) %> + <% content_for "header" do %> -<title><%= playlist.title %> - Invidious</title> +<title><%= title %> - Invidious</title> <link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/playlist/<%= plid %>" /> <% end %> <div class="pure-g h-box"> <div class="pure-u-2-3"> - <h3><%= playlist.title %></h3> + <h3><%= title %></h3> <% if playlist.is_a? InvidiousPlaylist %> <b> <% if playlist.author == user.try &.email %> - <a href="/view_all_playlists"><%= playlist.author %></a> | + <a href="/view_all_playlists"><%= author %></a> | <% else %> - <%= playlist.author %> | + <%= author %> | <% end %> <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> | <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> | @@ -26,11 +29,12 @@ </b> <% else %> <b> - <a href="/channel/<%= playlist.ucid %>"><%= playlist.author %></a> | + <a href="/channel/<%= playlist.ucid %>"><%= author %></a> | <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> | <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> </b> <% end %> + <% if !playlist.is_a? InvidiousPlaylist %> <div class="pure-u-2-3"> <a href="https://www.youtube.com/playlist?list=<%= playlist.id %>"> @@ -40,7 +44,6 @@ <a href="/redirect?referer=<%= env.get?("current_page") %>"> <%= translate(locale, "Switch Invidious Instance") %> </a> - </div> <% end %> </div> @@ -64,7 +67,9 @@ </div> <div class="h-box"> - <p><%= playlist.description_html %></p> + <div id="descriptionWrapper"> + <p><%= playlist.description_html %></p> + </div> </div> <% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> @@ -91,11 +96,9 @@ <% end %> <div class="pure-g"> - <% videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% videos.each do |item| %> + <%= rendered "components/item" %> +<% end %> </div> <div class="pure-g h-box"> diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index 975ccd6c..d9a17a9b 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -1,5 +1,8 @@ +<% ucid = channel.ucid %> +<% author = HTML.escape(channel.author) %> + <% content_for "header" do %> -<title><%= channel.author %> - Invidious</title> +<title><%= author %> - Invidious</title> <% end %> <% if channel.banner %> @@ -16,23 +19,23 @@ <div class="pure-u-2-3"> <div class="channel-profile"> <img src="/ggpht<%= URI.parse(channel.author_thumbnail).request_target %>"> - <span><%= channel.author %></span> + <span><%= author %></span> </div> </div> <div class="pure-u-1-3" style="text-align:right"> - <h3> - <a href="/feed/channel/<%= channel.ucid %>"><i class="icon ion-logo-rss"></i></a> + <h3 style="text-align:right"> + <a href="/feed/channel/<%= ucid %>"><i class="icon ion-logo-rss"></i></a> </h3> </div> </div> <div class="h-box"> - <p><span style="white-space:pre-wrap"><%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content if !channel.description_html.empty? %></span></p> + <div id="descriptionWrapper"> + <p><span style="white-space:pre-wrap"><%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content if !channel.description_html.empty? %></span></p> + </div> </div> <div class="h-box"> - <% ucid = channel.ucid %> - <% author = channel.author %> <% sub_count_text = number_to_short_text(channel.sub_count) %> <%= rendered "components/subscribe_widget" %> </div> @@ -40,7 +43,7 @@ <div class="pure-g h-box"> <div class="pure-g pure-u-1-3"> <div class="pure-u-1 pure-md-1-3"> - <a href="https://www.youtube.com/channel/<%= channel.ucid %>/playlists"><%= translate(locale, "View channel on YouTube") %></a> + <a href="https://www.youtube.com/channel/<%= ucid %>/playlists"><%= translate(locale, "View channel on YouTube") %></a> </div> <div class="pure-u-1 pure-md-1-3"> @@ -48,7 +51,7 @@ </div> <div class="pure-u-1 pure-md-1-3"> - <a href="/channel/<%= channel.ucid %>"><%= translate(locale, "Videos") %></a> + <a href="/channel/<%= ucid %>"><%= translate(locale, "Videos") %></a> </div> <div class="pure-u-1 pure-md-1-3"> <% if !channel.auto_generated %> @@ -57,7 +60,7 @@ </div> <div class="pure-u-1 pure-md-1-3"> <% if channel.tabs.includes? "community" %> - <a href="/channel/<%= channel.ucid %>/community"><%= translate(locale, "Community") %></a> + <a href="/channel/<%= ucid %>/community"><%= translate(locale, "Community") %></a> <% end %> </div> </div> @@ -69,7 +72,7 @@ <% if sort_by == sort %> <b><%= translate(locale, sort) %></b> <% else %> - <a href="/channel/<%= channel.ucid %>/playlists?sort_by=<%= sort %>"> + <a href="/channel/<%= ucid %>/playlists?sort_by=<%= sort %>"> <%= translate(locale, sort) %> </a> <% end %> @@ -84,18 +87,16 @@ </div> <div class="pure-g"> - <% items.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% items.each do |item| %> + <%= rendered "components/item" %> +<% end %> </div> <div class="pure-g h-box"> <div class="pure-u-1 pure-u-md-4-5"></div> <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> <% if continuation %> - <a href="/channel/<%= channel.ucid %>/playlists?continuation=<%= continuation %><% if sort_by != "last" %>&sort_by=<%= HTML.escape(sort_by) %><% end %>"> + <a href="/channel/<%= ucid %>/playlists?continuation=<%= continuation %><% if sort_by != "last" %>&sort_by=<%= HTML.escape(sort_by) %><% end %>"> <%= translate(locale, "Next page") %> </a> <% end %> diff --git a/src/invidious/views/popular.ecr b/src/invidious/views/popular.ecr index 62abb12a..e77f35b9 100644 --- a/src/invidious/views/popular.ecr +++ b/src/invidious/views/popular.ecr @@ -12,9 +12,7 @@ <%= rendered "components/feed_menu" %> <div class="pure-g"> - <% popular_videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% popular_videos.each do |item| %> + <%= rendered "components/item" %> +<% end %> </div> diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index 15389dce..fd176e41 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -2,6 +2,8 @@ <title><%= search_query.not_nil!.size > 30 ? HTML.escape(query.not_nil![0,30].rstrip(".") + "...") : HTML.escape(query.not_nil!) %> - Invidious</title> <% end %> +<% search_query_encoded = env.get?("search").try { |x| URI.encode(x.as(String), space_to_plus: true) } %> + <!-- Search redirection and filtering UI --> <% if count == 0 %> <h3 style="text-align: center"> @@ -105,7 +107,7 @@ <div class="pure-g h-box v-box"> <div class="pure-u-1 pure-u-lg-1-5"> <% if page > 1 %> - <a href="/search?q=<%= HTML.escape(query.not_nil!) %>&page=<%= page - 1 %>"> + <a href="/search?q=<%= search_query_encoded %>&page=<%= page - 1 %>"> <%= translate(locale, "Previous page") %> </a> <% end %> @@ -113,7 +115,7 @@ <div class="pure-u-1 pure-u-lg-3-5"></div> <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> <% if count >= 20 %> - <a href="/search?q=<%= HTML.escape(query.not_nil!) %>&page=<%= page + 1 %>"> + <a href="/search?q=<%= search_query_encoded %>&page=<%= page + 1 %>"> <%= translate(locale, "Next page") %> </a> <% end %> @@ -121,17 +123,15 @@ </div> <div class="pure-g"> - <% videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> + <% videos.each do |item| %> + <%= rendered "components/item" %> <% end %> </div> <div class="pure-g h-box"> <div class="pure-u-1 pure-u-lg-1-5"> <% if page > 1 %> - <a href="/search?q=<%= HTML.escape(query.not_nil!) %>&page=<%= page - 1 %>"> + <a href="/search?q=<%= search_query_encoded %>&page=<%= page - 1 %>"> <%= translate(locale, "Previous page") %> </a> <% end %> @@ -139,7 +139,7 @@ <div class="pure-u-1 pure-u-lg-3-5"></div> <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> <% if count >= 20 %> - <a href="/search?q=<%= HTML.escape(query.not_nil!) %>&page=<%= page + 1 %>"> + <a href="/search?q=<%= search_query_encoded %>&page=<%= page + 1 %>"> <%= translate(locale, "Next page") %> </a> <% end %> diff --git a/src/invidious/views/subscription_manager.ecr b/src/invidious/views/subscription_manager.ecr index 6cddcd6c..acf015f5 100644 --- a/src/invidious/views/subscription_manager.ecr +++ b/src/invidious/views/subscription_manager.ecr @@ -10,15 +10,15 @@ </a> </h3> </div> - <div class="pure-u-1-3" style="text-align:center"> - <h3> + <div class="pure-u-1-3"> + <h3 style="text-align:center"> <a href="/feed/history"> <%= translate(locale, "Watch history") %> </a> </h3> </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> + <div class="pure-u-1-3"> + <h3 style="text-align:right"> <a href="/data_control?referer=<%= URI.encode_www_form(referer) %>"> <%= translate(locale, "Import/export") %> </a> @@ -31,7 +31,7 @@ <div class="pure-g<% if channel.deleted %> deleted <% end %>"> <div class="pure-u-2-5"> <h3 style="padding-left:0.5em"> - <a href="/channel/<%= channel.id %>"><%= channel.author %></a> + <a href="/channel/<%= channel.id %>"><%= HTML.escape(channel.author) %></a> </h3> </div> <div class="pure-u-2-5"></div> diff --git a/src/invidious/views/subscriptions.ecr b/src/invidious/views/subscriptions.ecr index af1d4fbc..97184e2b 100644 --- a/src/invidious/views/subscriptions.ecr +++ b/src/invidious/views/subscriptions.ecr @@ -11,13 +11,13 @@ <a href="/subscription_manager"><%= translate(locale, "Manage subscriptions") %></a> </h3> </div> - <div class="pure-u-1-3" style="text-align:center"> - <h3> + <div class="pure-u-1-3"> + <h3 style="text-align:center"> <a href="/feed/history"><%= translate(locale, "Watch history") %></a> </h3> </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> + <div class="pure-u-1-3"> + <h3 style="text-align:right"> <a href="/feed/private?token=<%= token %>"><i class="icon ion-logo-rss"></i></a> </h3> </div> @@ -34,11 +34,9 @@ <% end %> <div class="pure-g"> - <% notifications.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% notifications.each do |item| %> + <%= rendered "components/item" %> +<% end %> </div> <div class="h-box"> @@ -55,11 +53,9 @@ <script src="/js/watched_widget.js"></script> <div class="pure-g"> - <% videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% videos.each do |item| %> + <%= rendered "components/item" %> +<% end %> </div> <div class="pure-g h-box"> diff --git a/src/invidious/views/trending.ecr b/src/invidious/views/trending.ecr index 3ec62555..a35c4ee3 100644 --- a/src/invidious/views/trending.ecr +++ b/src/invidious/views/trending.ecr @@ -41,9 +41,7 @@ </div> <div class="pure-g"> - <% trending.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% trending.each do |item| %> + <%= rendered "components/item" %> +<% end %> </div> diff --git a/src/invidious/views/view_all_playlists.ecr b/src/invidious/views/view_all_playlists.ecr index 5ec6aa31..868cfeda 100644 --- a/src/invidious/views/view_all_playlists.ecr +++ b/src/invidious/views/view_all_playlists.ecr @@ -16,11 +16,9 @@ </div> <div class="pure-g"> - <% items_created.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% items_created.each do |item| %> + <%= rendered "components/item" %> +<% end %> </div> <div class="pure-g h-box"> @@ -30,9 +28,7 @@ </div> <div class="pure-g"> - <% items_saved.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% items_saved.each do |item| %> + <%= rendered "components/item" %> +<% end %> </div> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 91e03725..aeb0f476 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -1,10 +1,15 @@ +<% ucid = video.ucid %> +<% title = HTML.escape(video.title) %> +<% author = HTML.escape(video.author) %> + + <% content_for "header" do %> <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:url" content="<%= HOST_URL %>/watch?v=<%= video.id %>"> -<meta property="og:title" content="<%= HTML.escape(video.title) %>"> +<meta property="og:title" content="<%= title %>"> <meta property="og:image" content="/vi/<%= video.id %>/maxres.jpg"> <meta property="og:description" content="<%= video.short_description %>"> <meta property="og:type" content="video.other"> @@ -16,7 +21,7 @@ <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="<%= HTML.escape(video.title) %>"> +<meta name="twitter:title" content="<%= title %>"> <meta name="twitter:description" content="<%= video.short_description %>"> <meta name="twitter:image" content="<%= HOST_URL %>/vi/<%= video.id %>/maxres.jpg"> <meta name="twitter:player" content="<%= HOST_URL %>/embed/<%= video.id %>"> @@ -24,17 +29,17 @@ <meta name="twitter:player:height" content="720"> <link rel="alternate" href="https://www.youtube.com/watch?v=<%= video.id %>"> <%= rendered "components/player_sources" %> -<title><%= HTML.escape(video.title) %> - Invidious</title> +<title><%= title %> - Invidious</title> <!-- Description expansion also updates the 'Show more' button to 'Show less' so we're going to need to do it here in order to allow for translations. --> <style> -#descexpansionbutton + label > a::after { +#descexpansionbutton ~ label > a::after { content: "<%= translate(locale, "Show more") %>" } -#descexpansionbutton:checked + label > a::after { +#descexpansionbutton:checked ~ label > a::after { content: "<%= translate(locale, "Show less") %>" } </style> @@ -69,7 +74,7 @@ we're going to need to do it here in order to allow for translations. <div class="h-box"> <h1> - <%= HTML.escape(video.title) %> + <%= title %> <% if params.listen %> <a title="<%=translate(locale, "Video mode")%>" href="/watch?<%= env.params.query %>&listen=0"> <i class="icon ion-ios-videocam"></i> @@ -134,8 +139,8 @@ we're going to need to do it here in order to allow for translations. <div class="pure-control-group"> <label for="playlist_id"><%= translate(locale, "Add to playlist: ") %></label> <select style="width:100%" name="playlist_id" id="playlist_id"> - <% playlists.each do |plid, title| %> - <option data-plid="<%= plid %>" value="<%= plid %>"><%= title %></option> + <% playlists.each do |plid, playlist_title| %> + <option data-plid="<%= plid %>" value="<%= plid %>"><%= HTML.escape(playlist_title) %></option> <% end %> </select> </div> @@ -178,8 +183,8 @@ we're going to need to do it here in order to allow for translations. </option> <% end %> <% captions.each do |caption| %> - <option value='{"id":"<%= video.id %>","label":"<%= caption.name.simpleText %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= caption.languageCode %>.vtt"}'> - <%= translate(locale, "Subtitles - `x` (.vtt)", caption.name.simpleText) %> + <option value='{"id":"<%= video.id %>","label":"<%= caption.name %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= caption.languageCode %>.vtt"}'> + <%= translate(locale, "Subtitles - `x` (.vtt)", caption.name) %> </option> <% end %> </select> @@ -227,12 +232,10 @@ we're going to need to do it here in order to allow for translations. <% if !video.author_thumbnail.empty? %> <img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>"> <% end %> - <span id="channel-name"><%= video.author %></span> + <span id="channel-name"><%= author %></span> </div> </a> - <% ucid = video.ucid %> - <% author = video.author %> <% sub_count_text = video.sub_count_text %> <%= rendered "components/subscribe_widget" %> @@ -246,15 +249,17 @@ we're going to need to do it here in order to allow for translations. <div id="description-box"> <!-- Description --> <% if video.description.size < 200 || params.extend_desc %> - <%= video.description_html %> + <div id="descriptionWrapper"> + <%= video.description_html %> + </div> <% else %> <input id="descexpansionbutton" type="checkbox"/> - <label for="descexpansionbutton" style="order: 1;"> - <a></a> - </label> <div id="descriptionWrapper"> <%= video.description_html %> </div> + <label for="descexpansionbutton"> + <a></a> + </label> <% end %> </div> |
