summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/container-release.yml35
-rw-r--r--assets/css/default.css42
-rw-r--r--config/config.example.yml824
-rw-r--r--docker/APKBUILD-boringssl46
-rw-r--r--docker/APKBUILD-lsquic43
-rw-r--r--docker/Dockerfile40
-rw-r--r--docker/Dockerfile.arm6466
-rw-r--r--locales/ar.json44
-rw-r--r--locales/bn_BD.json132
-rw-r--r--locales/cs.json233
-rw-r--r--locales/da.json13
-rw-r--r--locales/eo.json32
-rw-r--r--locales/es.json24
-rw-r--r--locales/eu.json115
-rw-r--r--locales/fr.json26
-rw-r--r--locales/he.json36
-rw-r--r--locales/hr.json50
-rw-r--r--locales/hu-HU.json120
-rw-r--r--locales/id.json56
-rw-r--r--locales/lt.json427
-rw-r--r--locales/nb-NO.json38
-rw-r--r--locales/nl.json32
-rw-r--r--locales/pt-BR.json10
-rw-r--r--locales/si.json3
-rw-r--r--locales/sk.json132
-rw-r--r--locales/sr.json5
-rw-r--r--locales/sr_Cyrl.json115
-rw-r--r--locales/tr.json104
-rw-r--r--locales/vi.json427
-rw-r--r--locales/zh-CN.json56
-rw-r--r--locales/zh-TW.json20
-rw-r--r--spec/helpers_spec.cr2
-rw-r--r--src/invidious.cr9
-rw-r--r--src/invidious/channels.cr962
-rw-r--r--src/invidious/channels/about.cr192
-rw-r--r--src/invidious/channels/channels.cr310
-rw-r--r--src/invidious/channels/community.cr275
-rw-r--r--src/invidious/channels/playlists.cr93
-rw-r--r--src/invidious/channels/videos.cr89
-rw-r--r--src/invidious/comments.cr4
-rw-r--r--src/invidious/helpers/helpers.cr20
-rw-r--r--src/invidious/helpers/i18n.cr65
-rw-r--r--src/invidious/helpers/youtube_api.cr10
-rw-r--r--src/invidious/jobs/bypass_captcha_job.cr6
-rw-r--r--src/invidious/routes/embed.cr4
-rw-r--r--src/invidious/routes/watch.cr4
-rw-r--r--src/invidious/trending.cr30
-rw-r--r--src/invidious/videos.cr61
-rw-r--r--src/invidious/views/authorize_token.ecr8
-rw-r--r--src/invidious/views/channel.ecr41
-rw-r--r--src/invidious/views/community.ecr17
-rw-r--r--src/invidious/views/components/item.ecr130
-rw-r--r--src/invidious/views/components/player.ecr27
-rw-r--r--src/invidious/views/edit_playlist.ecr16
-rw-r--r--src/invidious/views/history.ecr54
-rw-r--r--src/invidious/views/login.ecr4
-rw-r--r--src/invidious/views/mix.ecr16
-rw-r--r--src/invidious/views/playlist.ecr27
-rw-r--r--src/invidious/views/playlists.ecr35
-rw-r--r--src/invidious/views/popular.ecr8
-rw-r--r--src/invidious/views/search.ecr16
-rw-r--r--src/invidious/views/subscription_manager.ecr10
-rw-r--r--src/invidious/views/subscriptions.ecr24
-rw-r--r--src/invidious/views/trending.ecr8
-rw-r--r--src/invidious/views/view_all_playlists.ecr16
-rw-r--r--src/invidious/views/watch.ecr39
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 %>&amp;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 %>&amp;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>