summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/CODEOWNERS18
-rw-r--r--.github/workflows/ci.yml3
-rw-r--r--.github/workflows/container-release.yml15
-rw-r--r--README.md4
-rw-r--r--assets/css/default.css6
-rw-r--r--assets/js/watch.js2
-rw-r--r--assets/robots.txt4
-rw-r--r--config/config.example.yml9
-rwxr-xr-xconfig/migrate-scripts/migrate-db-17cf077.sh7
-rwxr-xr-xconfig/migrate-scripts/migrate-db-1c8075c.sh11
-rwxr-xr-xconfig/migrate-scripts/migrate-db-1eca969.sh37
-rwxr-xr-xconfig/migrate-scripts/migrate-db-30e6d29.sh7
-rwxr-xr-xconfig/migrate-scripts/migrate-db-3646395.sh9
-rwxr-xr-xconfig/migrate-scripts/migrate-db-3bcb98e.sh5
-rwxr-xr-xconfig/migrate-scripts/migrate-db-52cb239.sh5
-rwxr-xr-xconfig/migrate-scripts/migrate-db-6e51189.sh7
-rwxr-xr-xconfig/migrate-scripts/migrate-db-701b5ea.sh5
-rwxr-xr-xconfig/migrate-scripts/migrate-db-88b7097.sh5
-rwxr-xr-xconfig/migrate-scripts/migrate-db-8e884fe.sh9
-rw-r--r--config/sql/annotations.sql4
-rw-r--r--config/sql/channel_videos.sql6
-rw-r--r--config/sql/channels.sql6
-rw-r--r--config/sql/nonces.sql6
-rw-r--r--config/sql/playlist_videos.sql4
-rw-r--r--config/sql/playlists.sql4
-rw-r--r--config/sql/session_ids.sql6
-rw-r--r--config/sql/users.sql6
-rw-r--r--config/sql/videos.sql6
-rw-r--r--docker-compose.yml2
-rw-r--r--docker/Dockerfile.arm642
-rwxr-xr-xdocker/init-invidious-db.sh22
-rw-r--r--locales/ar.json14
-rw-r--r--locales/de.json28
-rw-r--r--locales/en-US.json10
-rw-r--r--locales/eo.json10
-rw-r--r--locales/es.json12
-rw-r--r--locales/eu.json4
-rw-r--r--locales/fa.json110
-rw-r--r--locales/hr.json2
-rw-r--r--locales/id.json2
-rw-r--r--locales/ja.json16
-rw-r--r--locales/ko.json12
-rw-r--r--locales/lt.json10
-rw-r--r--locales/pt-BR.json16
-rw-r--r--locales/pt-PT.json156
-rw-r--r--locales/pt.json435
-rw-r--r--locales/ru.json8
-rw-r--r--locales/tr.json10
-rw-r--r--locales/zh-CN.json10
-rw-r--r--locales/zh-TW.json10
-rw-r--r--spec/helpers_spec.cr1
-rw-r--r--src/invidious.cr358
-rw-r--r--src/invidious/helpers/extractors.cr625
-rw-r--r--src/invidious/helpers/helpers.cr180
-rw-r--r--src/invidious/helpers/i18n.cr79
-rw-r--r--src/invidious/helpers/logger.cr14
-rw-r--r--src/invidious/helpers/serialized_yt_data.cr263
-rw-r--r--src/invidious/helpers/utils.cr2
-rw-r--r--src/invidious/playlists.cr49
-rw-r--r--src/invidious/routes/api/manifest.cr2
-rw-r--r--src/invidious/routes/api/v1/misc.cr30
-rw-r--r--src/invidious/routes/channels.cr2
-rw-r--r--src/invidious/routes/embed.cr2
-rw-r--r--src/invidious/routes/feeds.cr2
-rw-r--r--src/invidious/routes/images.cr191
-rw-r--r--src/invidious/routes/login.cr2
-rw-r--r--src/invidious/routes/misc.cr2
-rw-r--r--src/invidious/routes/playlists.cr2
-rw-r--r--src/invidious/routes/preferences.cr4
-rw-r--r--src/invidious/routes/search.cr2
-rw-r--r--src/invidious/routes/watch.cr2
-rw-r--r--src/invidious/routing.cr2
-rw-r--r--src/invidious/search.cr247
-rw-r--r--src/invidious/videos.cr2
-rw-r--r--src/invidious/views/add_playlist_items.ecr4
-rw-r--r--src/invidious/views/channel.ecr4
-rw-r--r--src/invidious/views/components/item.ecr18
-rw-r--r--src/invidious/views/login.ecr15
-rw-r--r--src/invidious/views/playlists.ecr2
-rw-r--r--src/invidious/views/preferences.ecr5
-rw-r--r--src/invidious/views/search.ecr14
-rw-r--r--src/invidious/views/template.ecr57
82 files changed, 2266 insertions, 1035 deletions
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 00000000..7a2c3760
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,18 @@
+# Default and lowest precedence. If none of the below matches, @iv-org/developers would be requested for review.
+* @iv-org/developers
+
+docker-compose.yml @unixfox
+docker/ @unixfox
+kubernetes/ @unixfox
+
+README.md @thefrenchghosty
+config/config.example.yml @thefrenchghosty @SamantazFox @unixfox
+
+scripts/ @syeopite
+shards.lock @syeopite
+shards.yml @syeopite
+
+locales/ @SamantazFox
+src/invidious/helpers/i18n.cr @SamantazFox
+
+src/invidious/helpers/youtube_api.cr @SamantazFox
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3bb4c491..b99ecf18 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,6 +40,7 @@ jobs:
crystal:
- 1.0.0
- 1.1.1
+ - 1.2.0
include:
- crystal: nightly
stable: false
@@ -48,7 +49,7 @@ jobs:
- uses: actions/checkout@v2
- name: Install Crystal
- uses: oprypin/install-crystal@v1.2.4
+ uses: crystal-lang/install-crystal@v1.5.3
with:
crystal: ${{ matrix.crystal }}
diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml
index c60d08fe..77b92c6f 100644
--- a/.github/workflows/container-release.yml
+++ b/.github/workflows/container-release.yml
@@ -23,6 +23,19 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
+
+ - name: Install Crystal
+ uses: oprypin/install-crystal@v1.2.4
+ with:
+ crystal: 1.1.1
+
+ - name: Run lint
+ run: |
+ if ! crystal tool format --check; then
+ crystal tool format
+ git diff
+ exit 1
+ fi
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
@@ -61,4 +74,4 @@ jobs:
labels: quay.expires-after=12w
push: true
tags: quay.io/invidious/invidious:${{ github.sha }}-arm64,quay.io/invidious/invidious:latest-arm64
- build-args: release=1 \ No newline at end of file
+ build-args: release=1
diff --git a/README.md b/README.md
index 3f7fa8a7..8beb4380 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
<h1>Invidious</h1>
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html">
- <img alt="License: AGPLv3+" src="https://shields.io/badge/License-AGPL%20v3+-blue.svg">
+ <img alt="License: AGPLv3" src="https://shields.io/badge/License-AGPL%20v3-blue.svg">
</a>
<a href="https://github.com/iv-org/invidious/actions">
<img alt="Build Status" src="https://github.com/iv-org/invidious/workflows/Invidious%20CI/badge.svg">
@@ -58,7 +58,7 @@
- No JavaScript required
- Light/Dark themes
- Customizable homepage
-- Subscriptions independant from Google
+- Subscriptions independent from Google
- Notifications for all subscribed channels
- Audio-only mode (with background play on mobile)
- Support for Reddit comments
diff --git a/assets/css/default.css b/assets/css/default.css
index ce6c30c9..95c1f55c 100644
--- a/assets/css/default.css
+++ b/assets/css/default.css
@@ -19,6 +19,7 @@ body {
font-size: 1.17em;
font-weight: bold;
vertical-align: middle;
+ border-radius: 50%;
}
.channel-profile > img {
@@ -314,6 +315,11 @@ footer a {
text-decoration: underline;
}
+footer span {
+ margin: 4px 0;
+ display: block;
+}
+
/* keyframes */
@keyframes spin {
diff --git a/assets/js/watch.js b/assets/js/watch.js
index 3909edd4..1579abf4 100644
--- a/assets/js/watch.js
+++ b/assets/js/watch.js
@@ -149,6 +149,8 @@ function get_playlist(plid, retries) {
if (xhr.readyState == 4) {
if (xhr.status == 200) {
playlist.innerHTML = xhr.response.playlistHtml;
+ var nextVideo = document.getElementById(xhr.response.nextVideo);
+ nextVideo.parentNode.parentNode.scrollTop = nextVideo.offsetTop;
if (xhr.response.nextVideo) {
player.on('ended', function () {
diff --git a/assets/robots.txt b/assets/robots.txt
index b7e8f5a9..1f53798b 100644
--- a/assets/robots.txt
+++ b/assets/robots.txt
@@ -1,4 +1,2 @@
User-agent: *
-Disallow: /search
-Disallow: /login
-Disallow: /watch \ No newline at end of file
+Disallow: /
diff --git a/config/config.example.yml b/config/config.example.yml
index d2346719..8bb19fcc 100644
--- a/config/config.example.yml
+++ b/config/config.example.yml
@@ -432,6 +432,15 @@ feed_threads: 1
##
#cache_annotations: false
+##
+## Source code URL. If your instance is running a modfied source
+## code, you MUST publish it somewhere and set this option.
+##
+## Accepted values: a string
+## Default: <none>
+##
+#modified_source_code_url: ""
+
#########################################
diff --git a/config/migrate-scripts/migrate-db-17cf077.sh b/config/migrate-scripts/migrate-db-17cf077.sh
index 5e5bb214..1597311d 100755
--- a/config/migrate-scripts/migrate-db-17cf077.sh
+++ b/config/migrate-scripts/migrate-db-17cf077.sh
@@ -1,4 +1,7 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE channels ADD COLUMN subscribed bool;"
-psql invidious kemal -c "UPDATE channels SET subscribed = false;"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels ADD COLUMN subscribed bool;"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channels SET subscribed = false;"
diff --git a/config/migrate-scripts/migrate-db-1c8075c.sh b/config/migrate-scripts/migrate-db-1c8075c.sh
index 63954397..b6f7b89c 100755
--- a/config/migrate-scripts/migrate-db-1c8075c.sh
+++ b/config/migrate-scripts/migrate-db-1c8075c.sh
@@ -1,7 +1,10 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE channel_videos DROP COLUMN live_now CASCADE"
-psql invidious kemal -c "ALTER TABLE channel_videos DROP COLUMN premiere_timestamp CASCADE"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
-psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN live_now bool"
-psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos DROP COLUMN live_now CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos DROP COLUMN premiere_timestamp CASCADE"
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN live_now bool"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz"
diff --git a/config/migrate-scripts/migrate-db-1eca969.sh b/config/migrate-scripts/migrate-db-1eca969.sh
index f840d924..770a76d3 100755
--- a/config/migrate-scripts/migrate-db-1eca969.sh
+++ b/config/migrate-scripts/migrate-db-1eca969.sh
@@ -1,19 +1,22 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN title CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN views CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN likes CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN dislikes CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN wilson_score CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN published CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN description CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN language CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN author CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN ucid CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN allowed_regions CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN is_family_friendly CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN genre CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN genre_url CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN license CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN sub_count_text CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN author_thumbnail CASCADE"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN title CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN views CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN likes CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN dislikes CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN wilson_score CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN published CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN description CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN language CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN author CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN ucid CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN allowed_regions CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN is_family_friendly CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN genre CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN genre_url CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN license CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN sub_count_text CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN author_thumbnail CASCADE"
diff --git a/config/migrate-scripts/migrate-db-30e6d29.sh b/config/migrate-scripts/migrate-db-30e6d29.sh
index 3a377461..9d0b2d30 100755
--- a/config/migrate-scripts/migrate-db-30e6d29.sh
+++ b/config/migrate-scripts/migrate-db-30e6d29.sh
@@ -1,4 +1,7 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE channels ADD COLUMN deleted bool;"
-psql invidious kemal -c "UPDATE channels SET deleted = false;"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels ADD COLUMN deleted bool;"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channels SET deleted = false;"
diff --git a/config/migrate-scripts/migrate-db-3646395.sh b/config/migrate-scripts/migrate-db-3646395.sh
index 830b85f2..b6efe239 100755
--- a/config/migrate-scripts/migrate-db-3646395.sh
+++ b/config/migrate-scripts/migrate-db-3646395.sh
@@ -1,5 +1,8 @@
#!/bin/sh
-psql invidious kemal < config/sql/session_ids.sql
-psql invidious kemal -c "INSERT INTO session_ids (SELECT unnest(id), email, CURRENT_TIMESTAMP FROM users) ON CONFLICT (id) DO NOTHING"
-psql invidious kemal -c "ALTER TABLE users DROP COLUMN id"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" < config/sql/session_ids.sql
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "INSERT INTO session_ids (SELECT unnest(id), email, CURRENT_TIMESTAMP FROM users) ON CONFLICT (id) DO NOTHING"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE users DROP COLUMN id"
diff --git a/config/migrate-scripts/migrate-db-3bcb98e.sh b/config/migrate-scripts/migrate-db-3bcb98e.sh
index cb9fa6ab..444f65ed 100755
--- a/config/migrate-scripts/migrate-db-3bcb98e.sh
+++ b/config/migrate-scripts/migrate-db-3bcb98e.sh
@@ -1,3 +1,6 @@
#!/bin/sh
-psql invidious kemal < config/sql/annotations.sql
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" < config/sql/annotations.sql
diff --git a/config/migrate-scripts/migrate-db-52cb239.sh b/config/migrate-scripts/migrate-db-52cb239.sh
index db8efeab..da977d97 100755
--- a/config/migrate-scripts/migrate-db-52cb239.sh
+++ b/config/migrate-scripts/migrate-db-52cb239.sh
@@ -1,3 +1,6 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN views bigint;"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN views bigint;"
diff --git a/config/migrate-scripts/migrate-db-6e51189.sh b/config/migrate-scripts/migrate-db-6e51189.sh
index ce728118..9132d3d7 100755
--- a/config/migrate-scripts/migrate-db-6e51189.sh
+++ b/config/migrate-scripts/migrate-db-6e51189.sh
@@ -1,4 +1,7 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN live_now bool;"
-psql invidious kemal -c "UPDATE channel_videos SET live_now = false;"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN live_now bool;"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channel_videos SET live_now = false;"
diff --git a/config/migrate-scripts/migrate-db-701b5ea.sh b/config/migrate-scripts/migrate-db-701b5ea.sh
index 429531a2..46d60c00 100755
--- a/config/migrate-scripts/migrate-db-701b5ea.sh
+++ b/config/migrate-scripts/migrate-db-701b5ea.sh
@@ -1,3 +1,6 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE users ADD COLUMN feed_needs_update boolean"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE users ADD COLUMN feed_needs_update boolean"
diff --git a/config/migrate-scripts/migrate-db-88b7097.sh b/config/migrate-scripts/migrate-db-88b7097.sh
index 6bde8399..146ee92d 100755
--- a/config/migrate-scripts/migrate-db-88b7097.sh
+++ b/config/migrate-scripts/migrate-db-88b7097.sh
@@ -1,3 +1,6 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz;"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz;"
diff --git a/config/migrate-scripts/migrate-db-8e884fe.sh b/config/migrate-scripts/migrate-db-8e884fe.sh
index 1c8dafd1..0d5de828 100755
--- a/config/migrate-scripts/migrate-db-8e884fe.sh
+++ b/config/migrate-scripts/migrate-db-8e884fe.sh
@@ -1,5 +1,8 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE channels DROP COLUMN subscribed"
-psql invidious kemal -c "ALTER TABLE channels ADD COLUMN subscribed timestamptz"
-psql invidious kemal -c "UPDATE channels SET subscribed = '2019-01-01 00:00:00+00'"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels DROP COLUMN subscribed"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels ADD COLUMN subscribed timestamptz"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channels SET subscribed = '2019-01-01 00:00:00+00'"
diff --git a/config/sql/annotations.sql b/config/sql/annotations.sql
index 4ea077e7..3705829d 100644
--- a/config/sql/annotations.sql
+++ b/config/sql/annotations.sql
@@ -2,11 +2,11 @@
-- DROP TABLE public.annotations;
-CREATE TABLE public.annotations
+CREATE TABLE IF NOT EXISTS public.annotations
(
id text NOT NULL,
annotations xml,
CONSTRAINT annotations_id_key UNIQUE (id)
);
-GRANT ALL ON TABLE public.annotations TO kemal;
+GRANT ALL ON TABLE public.annotations TO current_user;
diff --git a/config/sql/channel_videos.sql b/config/sql/channel_videos.sql
index cec57cd4..cd4e0ffd 100644
--- a/config/sql/channel_videos.sql
+++ b/config/sql/channel_videos.sql
@@ -2,7 +2,7 @@
-- DROP TABLE public.channel_videos;
-CREATE TABLE public.channel_videos
+CREATE TABLE IF NOT EXISTS public.channel_videos
(
id text NOT NULL,
title text,
@@ -17,13 +17,13 @@ CREATE TABLE public.channel_videos
CONSTRAINT channel_videos_id_key UNIQUE (id)
);
-GRANT ALL ON TABLE public.channel_videos TO kemal;
+GRANT ALL ON TABLE public.channel_videos TO current_user;
-- Index: public.channel_videos_ucid_idx
-- DROP INDEX public.channel_videos_ucid_idx;
-CREATE INDEX channel_videos_ucid_idx
+CREATE INDEX IF NOT EXISTS channel_videos_ucid_idx
ON public.channel_videos
USING btree
(ucid COLLATE pg_catalog."default");
diff --git a/config/sql/channels.sql b/config/sql/channels.sql
index b5a29b8f..55772da6 100644
--- a/config/sql/channels.sql
+++ b/config/sql/channels.sql
@@ -2,7 +2,7 @@
-- DROP TABLE public.channels;
-CREATE TABLE public.channels
+CREATE TABLE IF NOT EXISTS public.channels
(
id text NOT NULL,
author text,
@@ -12,13 +12,13 @@ CREATE TABLE public.channels
CONSTRAINT channels_id_key UNIQUE (id)
);
-GRANT ALL ON TABLE public.channels TO kemal;
+GRANT ALL ON TABLE public.channels TO current_user;
-- Index: public.channels_id_idx
-- DROP INDEX public.channels_id_idx;
-CREATE INDEX channels_id_idx
+CREATE INDEX IF NOT EXISTS channels_id_idx
ON public.channels
USING btree
(id COLLATE pg_catalog."default");
diff --git a/config/sql/nonces.sql b/config/sql/nonces.sql
index 7b8ce9f2..644ac32a 100644
--- a/config/sql/nonces.sql
+++ b/config/sql/nonces.sql
@@ -2,20 +2,20 @@
-- DROP TABLE public.nonces;
-CREATE TABLE public.nonces
+CREATE TABLE IF NOT EXISTS public.nonces
(
nonce text,
expire timestamp with time zone,
CONSTRAINT nonces_id_key UNIQUE (nonce)
);
-GRANT ALL ON TABLE public.nonces TO kemal;
+GRANT ALL ON TABLE public.nonces TO current_user;
-- Index: public.nonces_nonce_idx
-- DROP INDEX public.nonces_nonce_idx;
-CREATE INDEX nonces_nonce_idx
+CREATE INDEX IF NOT EXISTS nonces_nonce_idx
ON public.nonces
USING btree
(nonce COLLATE pg_catalog."default");
diff --git a/config/sql/playlist_videos.sql b/config/sql/playlist_videos.sql
index b2b8d5c4..eedccbad 100644
--- a/config/sql/playlist_videos.sql
+++ b/config/sql/playlist_videos.sql
@@ -2,7 +2,7 @@
-- DROP TABLE public.playlist_videos;
-CREATE TABLE playlist_videos
+CREATE TABLE IF NOT EXISTS playlist_videos
(
title text,
id text,
@@ -16,4 +16,4 @@ CREATE TABLE playlist_videos
PRIMARY KEY (index,plid)
);
-GRANT ALL ON TABLE public.playlist_videos TO kemal;
+GRANT ALL ON TABLE public.playlist_videos TO current_user;
diff --git a/config/sql/playlists.sql b/config/sql/playlists.sql
index 468496cb..83efce48 100644
--- a/config/sql/playlists.sql
+++ b/config/sql/playlists.sql
@@ -13,7 +13,7 @@ CREATE TYPE public.privacy AS ENUM
-- DROP TABLE public.playlists;
-CREATE TABLE public.playlists
+CREATE TABLE IF NOT EXISTS public.playlists
(
title text,
id text primary key,
@@ -26,4 +26,4 @@ CREATE TABLE public.playlists
index int8[]
);
-GRANT ALL ON public.playlists TO kemal;
+GRANT ALL ON public.playlists TO current_user;
diff --git a/config/sql/session_ids.sql b/config/sql/session_ids.sql
index afbabb67..c493769a 100644
--- a/config/sql/session_ids.sql
+++ b/config/sql/session_ids.sql
@@ -2,7 +2,7 @@
-- DROP TABLE public.session_ids;
-CREATE TABLE public.session_ids
+CREATE TABLE IF NOT EXISTS public.session_ids
(
id text NOT NULL,
email text,
@@ -10,13 +10,13 @@ CREATE TABLE public.session_ids
CONSTRAINT session_ids_pkey PRIMARY KEY (id)
);
-GRANT ALL ON TABLE public.session_ids TO kemal;
+GRANT ALL ON TABLE public.session_ids TO current_user;
-- Index: public.session_ids_id_idx
-- DROP INDEX public.session_ids_id_idx;
-CREATE INDEX session_ids_id_idx
+CREATE INDEX IF NOT EXISTS session_ids_id_idx
ON public.session_ids
USING btree
(id COLLATE pg_catalog."default");
diff --git a/config/sql/users.sql b/config/sql/users.sql
index 0f2cdba2..ad002ec2 100644
--- a/config/sql/users.sql
+++ b/config/sql/users.sql
@@ -2,7 +2,7 @@
-- DROP TABLE public.users;
-CREATE TABLE public.users
+CREATE TABLE IF NOT EXISTS public.users
(
updated timestamp with time zone,
notifications text[],
@@ -16,13 +16,13 @@ CREATE TABLE public.users
CONSTRAINT users_email_key UNIQUE (email)
);
-GRANT ALL ON TABLE public.users TO kemal;
+GRANT ALL ON TABLE public.users TO current_user;
-- Index: public.email_unique_idx
-- DROP INDEX public.email_unique_idx;
-CREATE UNIQUE INDEX email_unique_idx
+CREATE UNIQUE INDEX IF NOT EXISTS email_unique_idx
ON public.users
USING btree
(lower(email) COLLATE pg_catalog."default");
diff --git a/config/sql/videos.sql b/config/sql/videos.sql
index 8def2f83..7040703c 100644
--- a/config/sql/videos.sql
+++ b/config/sql/videos.sql
@@ -2,7 +2,7 @@
-- DROP TABLE public.videos;
-CREATE TABLE public.videos
+CREATE TABLE IF NOT EXISTS public.videos
(
id text NOT NULL,
info text,
@@ -10,13 +10,13 @@ CREATE TABLE public.videos
CONSTRAINT videos_pkey PRIMARY KEY (id)
);
-GRANT ALL ON TABLE public.videos TO kemal;
+GRANT ALL ON TABLE public.videos TO current_user;
-- Index: public.id_idx
-- DROP INDEX public.id_idx;
-CREATE UNIQUE INDEX id_idx
+CREATE UNIQUE INDEX IF NOT EXISTS id_idx
ON public.videos
USING btree
(id COLLATE pg_catalog."default");
diff --git a/docker-compose.yml b/docker-compose.yml
index b94f9813..ea1d2993 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,7 +12,7 @@ services:
POSTGRES_PASSWORD: kemal
POSTGRES_USER: kemal
healthcheck:
- test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
+ test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER $$POSTGRES_DB"]
invidious:
build:
context: .
diff --git a/docker/Dockerfile.arm64 b/docker/Dockerfile.arm64
index 063ba6d2..193ed09d 100644
--- a/docker/Dockerfile.arm64
+++ b/docker/Dockerfile.arm64
@@ -1,5 +1,5 @@
FROM alpine:edge AS builder
-RUN apk add --no-cache 'crystal=1.1.1-r0' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev
+RUN apk add --no-cache 'crystal=1.1.1-r1' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev
ARG release
diff --git a/docker/init-invidious-db.sh b/docker/init-invidious-db.sh
index 3808e673..22b4cc5f 100755
--- a/docker/init-invidious-db.sh
+++ b/docker/init-invidious-db.sh
@@ -1,16 +1,12 @@
#!/bin/bash
set -eou pipefail
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
- CREATE USER postgres;
-EOSQL
-
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channels.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/videos.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channel_videos.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/users.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/session_ids.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/nonces.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/annotations.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlists.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlist_videos.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channels.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/videos.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channel_videos.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/users.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/session_ids.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/nonces.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/annotations.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlists.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlist_videos.sql
diff --git a/locales/ar.json b/locales/ar.json
index 9488e309..457c648b 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -28,7 +28,7 @@
"New passwords must match": "يَجبُ أن تكون كلمتي المرور متطابقتان",
"Cannot change password for Google accounts": "لا يُمكن تغيير كلمة المرور لِحسابات جوجل",
"Authorize token?": "رمز التفويض؟",
- "Authorize token for `x`?": "رمز التفويض لـ `x` ؟",
+ "Authorize token for `x`?": "السماح بالرمز المميز ل 'x'؟",
"Yes": "نعم",
"No": "لا",
"Import and Export Data": "اِستيراد البيانات وتصديرها",
@@ -382,7 +382,7 @@
"News": "الأخبار",
"Movies": "الأفلام",
"Download": "نزّل",
- "Download as: ": "نزله كـ:. ",
+ "Download as: ": "نزله ك:. ",
"%A %B %-d, %Y": "%A %-d %B %Y",
"(edited)": "(تم تعديلة)",
"YouTube comment permalink": "رابط التعليق على اليوتيوب",
@@ -423,5 +423,13 @@
"Current version: ": "الإصدار الحالي: ",
"next_steps_error_message": "بعد ذلك يجب أن تحاول: ",
"next_steps_error_message_refresh": "تحديث",
- "next_steps_error_message_go_to_youtube": "انتقل إلى يوتيوب"
+ "next_steps_error_message_go_to_youtube": "انتقل إلى يوتيوب",
+ "short": "قصير (< 4 دقائق)",
+ "long": "طويل (> 20 دقيقة)",
+ "footer_source_code": "شفرة المصدر",
+ "footer_original_source_code": "شفرة المصدر الأصلية",
+ "footer_modfied_source_code": "شفرة المصدر المعدلة",
+ "adminprefs_modified_source_code_url_label": "URL إلى مستودع التعليمات البرمجية المصدرية المعدلة",
+ "footer_documentation": "التوثيق",
+ "footer_donate": "تبرّع: "
}
diff --git a/locales/de.json b/locales/de.json
index 44725cbc..e438f503 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -77,8 +77,8 @@
"Fallback captions: ": "Ersatzuntertitel: ",
"Show related videos: ": "Ähnliche Videos anzeigen? ",
"Show annotations by default: ": "Standardmäßig Anmerkungen anzeigen? ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
+ "Automatically extend video description: ": "Videobeschreibung automatisch erweitern: ",
+ "Interactive 360 degree videos: ": "Interaktive 360 Grad Videos: ",
"Visual preferences": "Anzeigeeinstellungen",
"Player style: ": "Abspielgeräterstil: ",
"Dark mode: ": "Nachtmodus: ",
@@ -86,8 +86,8 @@
"dark": "Nachtmodus",
"light": "heller Modus",
"Thin mode: ": "Schlanker Modus: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
+ "Miscellaneous preferences": "Sonstige Einstellungen",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatische Instanzweiterleitung (über redirect.invidious.io): ",
"Subscription preferences": "Abonnementeinstellungen",
"Show annotations by default for subscribed channels: ": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ",
"Redirect homepage to feed: ": "Startseite zu Feed umleiten: ",
@@ -117,7 +117,7 @@
"Administrator preferences": "Administrator-Einstellungen",
"Default homepage: ": "Standard-Startseite: ",
"Feed menu: ": "Feed-Menü: ",
- "Show nickname on top: ": "",
+ "Show nickname on top: ": "Nutzernamen oben anzeigen: ",
"Top enabled: ": "Top aktiviert? ",
"CAPTCHA enabled: ": "CAPTCHA aktiviert? ",
"Login enabled: ": "Anmeldung aktiviert: ",
@@ -145,7 +145,7 @@
},
"search": "Suchen",
"Log out": "Abmelden",
- "Released under the AGPLv3 on Github.": "",
+ "Released under the AGPLv3 on Github.": "Auf Github unter der AGPLv3 Lizenz veröffentlicht.",
"Source available here.": "Quellcode verfügbar hier.",
"View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.",
"View privacy policy.": "Datenschutzerklärung einsehen.",
@@ -161,11 +161,11 @@
"Title": "Titel",
"Playlist privacy": "Vertrauliche Wiedergabeliste",
"Editing playlist `x`": "Wiedergabeliste bearbeiten `x`",
- "Show more": "",
- "Show less": "",
+ "Show more": "Mehr anzeigen",
+ "Show less": "Weniger anzeigen",
"Watch on YouTube": "Video auf YouTube ansehen",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
+ "Switch Invidious Instance": "Invidious Instanz wechseln",
+ "Broken? Try another Invidious Instance": "Funktioniert nicht? Probiere eine andere Invidious Instanz aus",
"Hide annotations": "Anmerkungen ausblenden",
"Show annotations": "Anmerkungen anzeigen",
"Genre: ": "Genre: ",
@@ -410,7 +410,7 @@
"channel": "Kanal",
"playlist": "Wiedergabeliste",
"movie": "Film",
- "show": "",
+ "show": "Anzeigen",
"hd": "HD",
"subtitles": "Untertitel / CC",
"creative_commons": "Creative Commons",
@@ -421,7 +421,7 @@
"hdr": "HDR",
"filter": "Filtern",
"Current version: ": "Aktuelle Version: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "next_steps_error_message": "Danach folgendes versuchen: ",
+ "next_steps_error_message_refresh": "Neuladen",
+ "next_steps_error_message_go_to_youtube": "Zu YouTube gehen"
}
diff --git a/locales/en-US.json b/locales/en-US.json
index a1e39777..8d213c7a 100644
--- a/locales/en-US.json
+++ b/locales/en-US.json
@@ -411,6 +411,8 @@
"playlist": "Playlist",
"movie": "Movie",
"show": "Show",
+ "short": "Short (< 4 minutes)",
+ "long": "Long (> 20 minutes)",
"hd": "HD",
"subtitles": "Subtitles/CC",
"creative_commons": "Creative Commons",
@@ -423,5 +425,11 @@
"Current version: ": "Current version: ",
"next_steps_error_message": "After which you should try to: ",
"next_steps_error_message_refresh": "Refresh",
- "next_steps_error_message_go_to_youtube": "Go to YouTube"
+ "next_steps_error_message_go_to_youtube": "Go to YouTube",
+ "footer_donate_page": "Donate",
+ "footer_documentation": "Documentation",
+ "footer_source_code": "Source code",
+ "footer_original_source_code": "Original source code",
+ "footer_modfied_source_code": "Modified Source code",
+ "adminprefs_modified_source_code_url_label": "URL to modified source code repository"
}
diff --git a/locales/eo.json b/locales/eo.json
index 7c2c7482..5c1d3b52 100644
--- a/locales/eo.json
+++ b/locales/eo.json
@@ -423,5 +423,13 @@
"Current version: ": "Nuna versio: ",
"next_steps_error_message": "Poste, vi provu: ",
"next_steps_error_message_refresh": "Reŝargi",
- "next_steps_error_message_go_to_youtube": "Iri al JuTubo"
+ "next_steps_error_message_go_to_youtube": "Iri al JuTubo",
+ "long": "Longa (> 20 minutos)",
+ "short": "Mallonga (< 4 minutos)",
+ "footer_donate": "Doni: ",
+ "footer_documentation": "Dokumentaro",
+ "footer_source_code": "Fontkodo",
+ "adminprefs_modified_source_code_url_label": "URL al modifita deponejo de fontkodo",
+ "footer_modfied_source_code": "Modifita Fontkodo",
+ "footer_original_source_code": "Originala fontkodo"
}
diff --git a/locales/es.json b/locales/es.json
index 1f3f1c9e..7255a8bc 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -341,7 +341,7 @@
"Yoruba": "Yoruba",
"Zulu": "Zulú",
"`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` años",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` año",
"": "`x` años"
},
"`x` months": {
@@ -423,5 +423,13 @@
"Current version: ": "Versión actual: ",
"next_steps_error_message": "Después de lo cual deberías intentar: ",
"next_steps_error_message_refresh": "Recargar",
- "next_steps_error_message_go_to_youtube": "Ir a YouTube"
+ "next_steps_error_message_go_to_youtube": "Ir a YouTube",
+ "short": "Corto (< 4 minutos)",
+ "long": "Largo (> 20 minutos)",
+ "footer_documentation": "Documentación",
+ "footer_original_source_code": "Código fuente original",
+ "adminprefs_modified_source_code_url_label": "URL al repositorio de código fuente modificado",
+ "footer_source_code": "Código fuente",
+ "footer_donate": "Donar: ",
+ "footer_modfied_source_code": "Código fuente modificado"
}
diff --git a/locales/eu.json b/locales/eu.json
index df3f4329..e1f8d1c0 100644
--- a/locales/eu.json
+++ b/locales/eu.json
@@ -1,10 +1,10 @@
{
"`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` harpidedunak",
"": "`x` harpidedun"
},
"`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bideoak",
"": "`x` bideo"
},
"`x` playlists": {
diff --git a/locales/fa.json b/locales/fa.json
index 68a016c4..c7842206 100644
--- a/locales/fa.json
+++ b/locales/fa.json
@@ -8,15 +8,15 @@
"": "`x` ویدیو ها"
},
"`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` لیست های پخش",
- "": "`x` لیست های پخش"
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` سیاههٔ پخش",
+ "": "`x` سیاهه‌های پخش"
},
"LIVE": "زنده",
"Shared `x` ago": "به اشتراک گذاشته شده `x` پیش",
"Unsubscribe": "لغو اشتراک",
"Subscribe": "مشترک شدن",
"View channel on YouTube": "نمایش کانال در یوتیوب",
- "View playlist on YouTube": "نمایش لیست پخش در یوتیوب",
+ "View playlist on YouTube": "نمایش سیاههٔ پخش در یوتیوب",
"newest": "جدید تر",
"oldest": "قدیمی تر",
"popular": "محبوب",
@@ -77,8 +77,8 @@
"Fallback captions: ": "عقب گرد زیرنویس ها: ",
"Show related videos: ": "نمایش ویدیو های مرتبط: ",
"Show annotations by default: ": "نمایش حاشیه نویسی ها به طور پیشفرض: ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
+ "Automatically extend video description: ": "گسترش خودکار توضیحات ویدئو: ",
+ "Interactive 360 degree videos: ": "ویدئوها ۳۶۰ درجه تعاملی: ",
"Visual preferences": "ترجیحات بصری",
"Player style: ": "حالت پخش کننده: ",
"Dark mode: ": "حالت تاریک: ",
@@ -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: ": "CAPTCHA فعال شده: ",
"Login enabled: ": "ورود فعال شده: ",
@@ -145,7 +145,7 @@
},
"search": "جستجو",
"Log out": "خروج",
- "Released under the AGPLv3 on Github.": "",
+ "Released under the AGPLv3 on Github.": "منتشر شده تحت پروانه AGPLv3 روی گیت‌هاب.",
"Source available here.": "منبع اینجا دردسترس است.",
"View JavaScript license information.": "نمایش اطلاعات مجوز جاوا اسکریپت.",
"View privacy policy.": "نمایش سیاست حفظ حریم خصوصی.",
@@ -153,19 +153,19 @@
"Public": "عمومی",
"Unlisted": "لیست نشده",
"Private": "خصوصی",
- "View all playlists": "نمایش همه لیست پخش",
+ "View all playlists": "نمایش همه سیاهه‌های پخش",
"Updated `x` ago": "بروز شده `x` پیش",
- "Delete playlist `x`?": "حذف لیست پخش `x`؟",
- "Delete playlist": "حذف لیست پخش",
- "Create playlist": "ایجاد لیست پخش",
+ "Delete playlist `x`?": "حذف سیاههٔ پخش `x`؟",
+ "Delete playlist": "حذف سیاههٔ پخش",
+ "Create playlist": "ایجاد سیاههٔ پخش",
"Title": "عنوان",
- "Playlist privacy": "حریم خصوصی لیست پخش",
- "Editing playlist `x`": "تغییر لیست پخش `x`",
- "Show more": "",
- "Show less": "",
+ "Playlist privacy": "حریم خصوصی سیاههٔ پخش",
+ "Editing playlist `x`": "تغییر سیاههٔ پخش `x`",
+ "Show more": "نمایش بیش‌تر",
+ "Show less": "نمایش کم‌تر",
"Watch on YouTube": "تماشا در یوتیوب",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
+ "Switch Invidious Instance": "تعویض نمونه اینویدیوس",
+ "Broken? Try another Invidious Instance": "کار نمی‌کند؟ نمونه دیگری از اینویدیوس را امتحان کنید",
"Hide annotations": "مخفی کردن حاشیه نویسی ها",
"Show annotations": "نمایش حاشیه نویسی ها",
"Genre: ": "ژانر: ",
@@ -224,9 +224,9 @@
"": "`x` نقطه ها"
},
"Could not create mix.": "نمیتوان میکس ساخت.",
- "Empty playlist": "لیست پخش خالی",
- "Not a playlist.": "یک لیست پخش نیست.",
- "Playlist does not exist.": "لیست پخش وجود ندارد.",
+ "Empty playlist": "سیاههٔ پخش خالی",
+ "Not a playlist.": "یک سیاههٔ پخش نیست.",
+ "Playlist does not exist.": "سیاههٔ پخش وجود ندارد.",
"Could not pull trending pages.": "نمیتوان صفحه های پر طرفدار را بکشد.",
"Hidden field \"challenge\" is a required field": "فیلد مخفی \"چالش\" یک فیلد ضروری است",
"Hidden field \"token\" is a required field": "فیلد مخفی \"توکن\" یک فیلد ضروری است",
@@ -370,12 +370,12 @@
},
"Fallback comments: ": "نظرات عقب گرد: ",
"Popular": "محبوب",
- "Search": "",
+ "Search": "جستجو",
"Top": "بالا",
"About": "درباره",
"Rating: ": "رتبه دهی: ",
"Language: ": "زبان: ",
- "View as playlist": "نمایش به عنوان لیست پخش",
+ "View as playlist": "نمایش به عنوان سیاههٔ پخش",
"Default": "پیشفرض",
"Music": "موسیقی",
"Gaming": "بازی",
@@ -391,37 +391,37 @@
"Audio mode": "حالت صدا",
"Video mode": "حالت ویدیو",
"Videos": "ویدیو ها",
- "Playlists": "لیست های پخش",
+ "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": "",
+ "relevance": "مرتبط بودن",
+ "rating": "امتیاز",
+ "date": "تاریخ بارگذاری",
+ "views": "تعداد بازدید",
+ "content_type": "نوع",
+ "duration": "مدت",
+ "features": "ویژگی‌ها",
+ "sort": "به ترتیب",
+ "hour": "یک ساعت گذشته",
+ "today": "امروز",
+ "week": "این هفته",
+ "month": "این ماه",
+ "year": "امسال",
+ "video": "ویدئو",
+ "channel": "کانال",
+ "playlist": "سیاههٔ پخش",
+ "movie": "فیلم",
+ "show": "نمایش",
+ "hd": "HD",
+ "subtitles": "زیرنویس",
+ "creative_commons": "کریتیو کامونز",
+ "3d": "سه‌بعدی",
+ "live": "زنده",
+ "4k": "4K",
+ "location": "مکان",
+ "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": "رفتن به یوتیوب"
}
diff --git a/locales/hr.json b/locales/hr.json
index dd6d14a9..6927005f 100644
--- a/locales/hr.json
+++ b/locales/hr.json
@@ -145,7 +145,7 @@
},
"search": "traži",
"Log out": "Odjavi se",
- "Released under the AGPLv3 on Github.": "",
+ "Released under the AGPLv3 on Github.": "Izdano pod licencom AGPLv3 na Github-u.",
"Source available here.": "Izvor je ovdje dostupan.",
"View JavaScript license information.": "Prikaži informacije o JavaScript licenci.",
"View privacy policy.": "Prikaži politiku privatnosti.",
diff --git a/locales/id.json b/locales/id.json
index e15c6aaf..8ef67194 100644
--- a/locales/id.json
+++ b/locales/id.json
@@ -145,7 +145,7 @@
},
"search": "cari",
"Log out": "Keluar",
- "Released under the AGPLv3 on Github.": "",
+ "Released under the AGPLv3 on Github.": "Dirilis di bawah AGPLv3 di Github.",
"Source available here.": "Sumber tersedia di sini.",
"View JavaScript license information.": "Tampilkan informasi lisensi JavaScript.",
"View privacy policy.": "Lihat kebijakan privasi.",
diff --git a/locales/ja.json b/locales/ja.json
index c4f78f96..4c2e692d 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -78,7 +78,7 @@
"Show related videos: ": "関連動画を表示: ",
"Show annotations by default: ": "デフォルトでアノテーションを表示: ",
"Automatically extend video description: ": "動画の説明文を自動的に拡張: ",
- "Interactive 360 degree videos: ": "インタラクティブ360°動画: ",
+ "Interactive 360 degree videos: ": "対話的な360°動画: ",
"Visual preferences": "外観設定",
"Player style: ": "プレイヤースタイル: ",
"Dark mode: ": "ダークモード: ",
@@ -137,7 +137,7 @@
},
"Import/export": "インポート/エクスポート",
"unsubscribe": "登録解除",
- "revoke": "revoke",
+ "revoke": "取り消す",
"Subscriptions": "登録チャンネル",
"`x` unseen notifications": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個の未読通知",
@@ -145,7 +145,7 @@
},
"search": "検索",
"Log out": "ログアウト",
- "Released under the AGPLv3 on Github.": "Github 上で AGPLv3 の下で公開されています",
+ "Released under the AGPLv3 on Github.": "Github 上で AGPLv3 の元で公開されています",
"Source available here.": "ソースはここで閲覧可能です。",
"View JavaScript license information.": "JavaScript ライセンス情報",
"View privacy policy.": "プライバシーポリシー",
@@ -423,5 +423,13 @@
"Current version: ": "現在のバージョン: ",
"next_steps_error_message": "下記のものを試して下さい: ",
"next_steps_error_message_refresh": "再読込",
- "next_steps_error_message_go_to_youtube": "YouTubeへ"
+ "next_steps_error_message_go_to_youtube": "YouTubeへ",
+ "short": "4 分未満",
+ "footer_donate": "寄金: ",
+ "footer_documentation": "文書",
+ "footer_source_code": "ソースコード",
+ "footer_original_source_code": "ソースコード(元)",
+ "footer_modfied_source_code": "ソースコード(編集)",
+ "adminprefs_modified_source_code_url_label": "編集したソースコードのレポジトリーURL",
+ "long": "20 分以上"
}
diff --git a/locales/ko.json b/locales/ko.json
index 94f781d4..27fdc683 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -136,7 +136,7 @@
"Delete playlist": "재생목록 삭제",
"Delete playlist `x`?": "재생목록 `x` 를 삭제 하시겠습니까?",
"Updated `x` ago": "`x` 전에 업데이트됨",
- "Released under the AGPLv3 on Github.": "",
+ "Released under the AGPLv3 on Github.": "Github에 AGPLv3 으로 배포됩니다.",
"View all playlists": "모든 재생목록 보기",
"Private": "비공개",
"Unlisted": "목록에 없음",
@@ -423,5 +423,13 @@
"today": "오늘",
"hour": "지난 1시간",
"sort": "정렬기준",
- "features": "기능별"
+ "features": "기능별",
+ "short": "4분 미만",
+ "long": "20분 초과",
+ "footer_donate": "후원: ",
+ "footer_documentation": "문서",
+ "footer_source_code": "소스 코드",
+ "footer_original_source_code": "원본 소스 코드",
+ "footer_modfied_source_code": "수정된 소스 코드",
+ "adminprefs_modified_source_code_url_label": "수정된 소스 코드 저장소의 URL"
}
diff --git a/locales/lt.json b/locales/lt.json
index e8e84dcf..c1842e54 100644
--- a/locales/lt.json
+++ b/locales/lt.json
@@ -423,5 +423,13 @@
"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"
+ "next_steps_error_message_go_to_youtube": "Eiti į YouTube",
+ "short": "Trumpas (< 4 minučių)",
+ "long": "Ilgas (> 20 minučių)",
+ "footer_documentation": "Dokumentacija",
+ "footer_source_code": "Pirminis kodas",
+ "footer_donate": "Paaukoti: ",
+ "footer_original_source_code": "Pradinis pirminis kodas",
+ "adminprefs_modified_source_code_url_label": "URL į pakeisto pirminio kodo repozitoriją",
+ "footer_modfied_source_code": "Pakeistas pirminis kodas"
}
diff --git a/locales/pt-BR.json b/locales/pt-BR.json
index f1ffb7a8..870bf070 100644
--- a/locales/pt-BR.json
+++ b/locales/pt-BR.json
@@ -341,31 +341,31 @@
"Yoruba": "Iorubá",
"Zulu": "Zulu",
"`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` anos",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ano",
"": "`x` anos"
},
"`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` meses",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mês",
"": "`x` meses"
},
"`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semanas",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semana",
"": "`x` semanas"
},
"`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dias",
- "": "`x` dias"
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dia",
+ "": "`x` dia"
},
"`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` horas",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hora",
"": "`x` horas"
},
"`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutos",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuto",
"": "`x` minutos"
},
"`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundos",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundo",
"": "`x` segundos"
},
"Fallback comments: ": "Comentários alternativos: ",
diff --git a/locales/pt-PT.json b/locales/pt-PT.json
index a5e4bca8..08220d43 100644
--- a/locales/pt-PT.json
+++ b/locales/pt-PT.json
@@ -26,12 +26,12 @@
"Clear watch history?": "Limpar histórico de reprodução?",
"New password": "Nova palavra-chave",
"New passwords must match": "As novas palavra-chaves devem corresponder",
- "Cannot change password for Google accounts": "Não é possível alterar a palavra-passe para contas do Google",
+ "Cannot change password for Google accounts": "Não é possível alterar a palavra-chave para contas do Google",
"Authorize token?": "Autorizar token?",
"Authorize token for `x`?": "Autorizar token para `x`?",
"Yes": "Sim",
"No": "Não",
- "Import and Export Data": "Importar e Exportar Dados",
+ "Import and Export Data": "Importar e exportar dados",
"Import": "Importar",
"Import Invidious data": "Importar dados do Invidious",
"Import YouTube subscriptions": "Importar subscrições do YouTube",
@@ -42,20 +42,20 @@
"Export subscriptions as OPML": "Exportar subscrições como OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar subscrições como OPML (para NewPipe e FreeTube)",
"Export data as JSON": "Exportar dados como JSON",
- "Delete account?": "Apagar conta?",
+ "Delete account?": "Eliminar conta?",
"History": "Histórico",
"An alternative front-end to YouTube": "Uma interface alternativa ao YouTube",
"JavaScript license information": "Informação de licença do JavaScript",
"source": "código-fonte",
"Log in": "Iniciar sessão",
- "Log in/register": "Iniciar sessão/Registar",
+ "Log in/register": "Iniciar sessão/registar",
"Log in with Google": "Iniciar sessão com o Google",
"User ID": "Utilizador",
"Password": "Palavra-chave",
"Time (h:mm:ss):": "Tempo (h:mm:ss):",
"Text CAPTCHA": "Texto CAPTCHA",
"Image CAPTCHA": "Imagem CAPTCHA",
- "Sign In": "Iniciar Sessão",
+ "Sign In": "Iniciar sessão",
"Register": "Registar",
"E-mail": "E-mail",
"Google verification code": "Código de verificação do Google",
@@ -63,7 +63,7 @@
"Player preferences": "Preferências do reprodutor",
"Always loop: ": "Repetir sempre: ",
"Autoplay: ": "Reprodução automática: ",
- "Play next by default: ": "Sempre reproduzir próximo: ",
+ "Play next by default: ": "Reproduzir sempre o próximo: ",
"Autoplay next video: ": "Reproduzir próximo vídeo automaticamente: ",
"Listen by default: ": "Apenas áudio: ",
"Proxy videos: ": "Usar proxy nos vídeos: ",
@@ -76,9 +76,9 @@
"Default captions: ": "Legendas predefinidas: ",
"Fallback captions: ": "Legendas alternativas: ",
"Show related videos: ": "Mostrar vídeos relacionados: ",
- "Show annotations by default: ": "Mostrar sempre anotações: ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
+ "Show annotations by default: ": "Mostrar anotações sempre: ",
+ "Automatically extend video description: ": "Estender automaticamente a descrição do vídeo: ",
+ "Interactive 360 degree videos: ": "Vídeos interativos de 360 graus: ",
"Visual preferences": "Preferências visuais",
"Player style: ": "Estilo do reprodutor: ",
"Dark mode: ": "Modo escuro: ",
@@ -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 (solução de último recurso para redirect.invidious.io): ",
"Subscription preferences": "Preferências de subscrições",
"Show annotations by default for subscribed channels: ": "Mostrar sempre anotações aos canais subscritos: ",
"Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ",
@@ -108,22 +108,22 @@
"`x` is live": "`x` está em direto",
"Data preferences": "Preferências de dados",
"Clear watch history": "Limpar histórico de reprodução",
- "Import/export data": "Importar/Exportar dados",
+ "Import/export data": "Importar / exportar dados",
"Change password": "Alterar palavra-chave",
"Manage subscriptions": "Gerir as subscrições",
"Manage tokens": "Gerir tokens",
"Watch history": "Histórico de reprodução",
- "Delete account": "Apagar conta",
+ "Delete account": "Eliminar conta",
"Administrator preferences": "Preferências de administrador",
"Default homepage: ": "Página inicial predefinida: ",
"Feed menu: ": "Menu de subscrições: ",
- "Show nickname on top: ": "",
- "Top enabled: ": "Top ativado: ",
+ "Show nickname on top: ": "Mostrar nome de utilizador em cima: ",
+ "Top enabled: ": "Destaques ativados: ",
"CAPTCHA enabled: ": "CAPTCHA ativado: ",
"Login enabled: ": "Iniciar sessão ativado: ",
"Registration enabled: ": "Registar ativado: ",
"Report statistics: ": "Relatório de estatísticas: ",
- "Save preferences": "Gravar preferências",
+ "Save preferences": "Guardar preferências",
"Subscription manager": "Gerir subscrições",
"Token manager": "Gerir tokens",
"Token": "Token",
@@ -135,17 +135,17 @@
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens",
"": "`x` tokens"
},
- "Import/export": "Importar/Exportar",
- "unsubscribe": "Anular subscrição",
+ "Import/export": "Importar / exportar",
+ "unsubscribe": "anular subscrição",
"revoke": "revogar",
"Subscriptions": "Subscrições",
"`x` unseen notifications": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` notificações não vistas",
"": "`x` notificações não vistas"
},
- "search": "Pesquisar",
+ "search": "pesquisar",
"Log out": "Terminar sessão",
- "Released under the AGPLv3 on Github.": "",
+ "Released under the AGPLv3 on Github.": "Lançado sob a AGPLv3 no Github.",
"Source available here.": "Código-fonte disponível aqui.",
"View JavaScript license information.": "Ver informações da licença do JavaScript.",
"View privacy policy.": "Ver a política de privacidade.",
@@ -155,17 +155,17 @@
"Private": "Privado",
"View all playlists": "Ver todas as listas de reprodução",
"Updated `x` ago": "Atualizado `x` atrás",
- "Delete playlist `x`?": "Apagar a lista de reprodução 'x'?",
- "Delete playlist": "Apagar lista de reprodução",
+ "Delete playlist `x`?": "Eliminar a lista de reprodução 'x'?",
+ "Delete playlist": "Eliminar lista de reprodução",
"Create playlist": "Criar lista de reprodução",
"Title": "Título",
"Playlist privacy": "Privacidade da lista de reprodução",
"Editing playlist `x`": "A editar lista de reprodução 'x'",
- "Show more": "",
- "Show less": "",
+ "Show more": "Mostrar mais",
+ "Show less": "Mostrar menos",
"Watch on YouTube": "Ver no YouTube",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
+ "Switch Invidious Instance": "Mudar a instância do Invidious",
+ "Broken? Try another Invidious Instance": "Falhou? Tente outra Instância do Invidious",
"Hide annotations": "Ocultar anotações",
"Show annotations": "Mostrar anotações",
"Genre: ": "Género: ",
@@ -182,7 +182,7 @@
},
"Premieres in `x`": "Estreias em 'x'",
"Premieres `x`": "Estreias '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.": "Oi! Parece que JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar mais tempo para carregar.",
+ "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Olá! Parece que o JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar mais tempo para carregar.",
"View YouTube comments": "Ver comentários do YouTube",
"View more comments on Reddit": "Ver mais comentários no Reddit",
"View `x` comments": {
@@ -194,9 +194,9 @@
"Show replies": "Mostrar respostas",
"Incorrect password": "Palavra-chave incorreta",
"Quota exceeded, try again in a few hours": "Cota excedida. Tente novamente dentro de algumas horas",
- "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Não é possível iniciar sessão, certifique-se de que a autenticação de dois fatores (Autenticador ou SMS) está ativada.",
+ "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Não é possível iniciar a sessão, certifique-se que a autenticação de dois fatores (Autenticador ou SMS) está ativada.",
"Invalid TFA code": "Código TFA inválido",
- "Login failed. This may be because two-factor authentication is not turned on for your account.": "Falhou o início de sessão. Isto pode ser devido a dois fatores de autenticação não está ativado para sua conta.",
+ "Login failed. This may be because two-factor authentication is not turned on for your account.": "Falhou o início de sessão. Isto pode ser devido a não ter ativado na sua conta a autenticação de dois fatores (2FA).",
"Wrong answer": "Resposta errada",
"Erroneous CAPTCHA": "CAPTCHA inválido",
"CAPTCHA is a required field": "CAPTCHA é um campo obrigatório",
@@ -209,7 +209,7 @@
"Please log in": "Por favor, inicie sessão",
"Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`",
"channel:`x`": "canal:'x'",
- "Deleted or invalid channel": "Canal apagado ou inválido",
+ "Deleted or invalid channel": "Canal eliminado ou inválido",
"This channel does not exist.": "Este canal não existe.",
"Could not get channel info.": "Não foi possível obter as informações do canal.",
"Could not fetch comments": "Não foi possível obter os comentários",
@@ -223,11 +223,11 @@
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` pontos",
"": "`x` pontos"
},
- "Could not create mix.": "Não foi possível criar mistura.",
+ "Could not create mix.": "Não foi possível criar a mistura.",
"Empty playlist": "Lista de reprodução vazia",
"Not a playlist.": "Não é uma lista de reprodução.",
"Playlist does not exist.": "A lista de reprodução não existe.",
- "Could not pull trending pages.": "Não foi possível obter páginas de tendências.",
+ "Could not pull trending pages.": "Não foi possível obter as páginas de tendências.",
"Hidden field \"challenge\" is a required field": "O campo oculto \"desafio\" é obrigatório",
"Hidden field \"token\" is a required field": "O campo oculto \"token\" é um campo obrigatório",
"Erroneous challenge": "Desafio inválido",
@@ -250,8 +250,8 @@
"Burmese": "Birmanês",
"Catalan": "Catalão",
"Cebuano": "Cebuano",
- "Chinese (Simplified)": "Chinês (Simplificado)",
- "Chinese (Traditional)": "Chinês (Tradicional)",
+ "Chinese (Simplified)": "Chinês (simplificado)",
+ "Chinese (Traditional)": "Chinês (tradicional)",
"Corsican": "Corso",
"Croatian": "Croata",
"Czech": "Checo",
@@ -341,87 +341,87 @@
"Yoruba": "Ioruba",
"Zulu": "Zulu",
"`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` anos",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ano",
"": "`x` anos"
},
"`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` meses",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mês",
"": "`x` meses"
},
"`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semanas",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` seman",
"": "`x` semanas"
},
"`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dias",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dia",
"": "`x` dias"
},
"`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` horas",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hora",
"": "`x` horas"
},
"`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutos",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuto",
"": "`x` minutos"
},
"`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundos",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundo",
"": "`x` segundos"
},
"Fallback comments: ": "Comentários alternativos: ",
"Popular": "Popular",
- "Search": "",
- "Top": "Top",
+ "Search": "Pesquisar",
+ "Top": "Destaques",
"About": "Sobre",
"Rating: ": "Avaliação: ",
"Language: ": "Idioma: ",
"View as playlist": "Ver como lista de reprodução",
- "Default": "Predefinição",
+ "Default": "Predefinido",
"Music": "Música",
"Gaming": "Jogos",
"News": "Notícias",
"Movies": "Filmes",
- "Download": "Transferir",
- "Download as: ": "Transferir como: ",
+ "Download": "Descarregar",
+ "Download as: ": "Descarregar como: ",
"%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(editado)",
- "YouTube comment permalink": "Hiperligação permanente ao comentário do YouTube",
- "permalink": "ligação permanente",
+ "YouTube comment permalink": "Hiperligação permanente do comentário no YouTube",
+ "permalink": "hiperligação permanente",
"`x` marked it with a ❤": "`x` foi marcado como ❤",
"Audio mode": "Modo de áudio",
"Video mode": "Modo de vídeo",
"Videos": "Vídeos",
"Playlists": "Listas de reprodução",
"Community": "Comunidade",
- "relevance": "",
- "rating": "",
- "date": "",
- "views": "",
- "content_type": "",
- "duration": "",
- "features": "",
- "sort": "",
- "hour": "",
- "today": "",
- "week": "",
- "month": "",
- "year": "",
- "video": "",
- "channel": "",
- "playlist": "",
- "movie": "",
- "show": "",
- "hd": "",
- "subtitles": "",
- "creative_commons": "",
- "3d": "",
- "live": "",
- "4k": "",
- "location": "",
- "hdr": "",
- "filter": "",
+ "relevance": "Relevância",
+ "rating": "Avaliação",
+ "date": "Data de envio",
+ "views": "Visualizações",
+ "content_type": "Tipo",
+ "duration": "Duração",
+ "features": "Funcionalidades",
+ "sort": "Ordenar por",
+ "hour": "Última hora",
+ "today": "Hoje",
+ "week": "Esta semana",
+ "month": "Este mês",
+ "year": "Este ano",
+ "video": "Vídeo",
+ "channel": "Canal",
+ "playlist": "Lista de reprodução",
+ "movie": "Filme",
+ "show": "Espetáculo",
+ "hd": "HD",
+ "subtitles": "Legendas",
+ "creative_commons": "Creative Commons",
+ "3d": "3D",
+ "live": "Em direto",
+ "4k": "4K",
+ "location": "Localização",
+ "hdr": "HDR",
+ "filter": "Filtro",
"Current version: ": "Versão atual: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "next_steps_error_message": "Pode tentar as seguintes opções: ",
+ "next_steps_error_message_refresh": "Atualizar",
+ "next_steps_error_message_go_to_youtube": "Ir ao YouTube"
}
diff --git a/locales/pt.json b/locales/pt.json
new file mode 100644
index 00000000..918ab4c0
--- /dev/null
+++ b/locales/pt.json
@@ -0,0 +1,435 @@
+{
+ "show": "Espetáculo",
+ "views": "Visualizações",
+ "date": "Data de envio",
+ "rating": "Avaliação",
+ "relevance": "Relevância",
+ "Broken? Try another Invidious Instance": "Falhou? Tente outra Instância do Invidious",
+ "Switch Invidious Instance": "Mudar a instância do Invidious",
+ "Show less": "Mostrar menos",
+ "Show more": "Mostrar mais",
+ "Released under the AGPLv3 on Github.": "Lançado sob a AGPLv3 no Github.",
+ "Show nickname on top: ": "Mostrar nome de utilizador em cima: ",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Redirecionamento de instância automática (solução de último recurso para redirect.invidious.io): ",
+ "Miscellaneous preferences": "Preferências diversas",
+ "Interactive 360 degree videos: ": "Vídeos interativos de 360 graus: ",
+ "Automatically extend video description: ": "Estender automaticamente a descrição do vídeo: ",
+ "next_steps_error_message_go_to_youtube": "Ir ao YouTube",
+ "next_steps_error_message": "Pode tentar as seguintes opções: ",
+ "next_steps_error_message_refresh": "Atualizar",
+ "filter": "Filtro",
+ "hdr": "HDR",
+ "location": "Localização",
+ "4k": "4K",
+ "live": "Em direto",
+ "3d": "3D",
+ "creative_commons": "Creative Commons",
+ "subtitles": "Legendas",
+ "hd": "HD",
+ "movie": "Filme",
+ "playlist": "Lista de reprodução",
+ "channel": "Canal",
+ "video": "Vídeo",
+ "year": "Este ano",
+ "month": "Este mês",
+ "week": "Esta semana",
+ "today": "Hoje",
+ "hour": "Última hora",
+ "sort": "Ordenar por",
+ "features": "Funcionalidades",
+ "duration": "Duração",
+ "content_type": "Tipo",
+ "permalink": "hiperligação permanente",
+ "YouTube comment permalink": "Hiperligação permanente do comentário no YouTube",
+ "Download as: ": "Descarregar como: ",
+ "Download": "Descarregar",
+ "Default": "Predefinido",
+ "Top": "Destaques",
+ "Search": "Pesquisar",
+ "`x` seconds": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundo",
+ "": "`x` segundos"
+ },
+ "`x` minutes": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuto",
+ "": "`x` minutos"
+ },
+ "`x` hours": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hora",
+ "": "`x` horas"
+ },
+ "`x` days": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dia",
+ "": "`x` dias"
+ },
+ "`x` weeks": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` seman",
+ "": "`x` semanas"
+ },
+ "`x` months": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mês",
+ "": "`x` meses"
+ },
+ "`x` years": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ano",
+ "": "`x` anos"
+ },
+ "Chinese (Traditional)": "Chinês (tradicional)",
+ "Chinese (Simplified)": "Chinês (simplificado)",
+ "Could not pull trending pages.": "Não foi possível obter as páginas de tendências.",
+ "Could not create mix.": "Não foi possível criar a mistura.",
+ "Deleted or invalid channel": "Canal eliminado ou inválido",
+ "Login failed. This may be because two-factor authentication is not turned on for your account.": "Falhou o início de sessão. Isto pode ser devido a não ter ativado na sua conta a autenticação de dois fatores (2FA).",
+ "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Não é possível iniciar a sessão, certifique-se que a autenticação de dois fatores (Autenticador ou SMS) está ativada.",
+ "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Olá! Parece que o JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar mais tempo para carregar.",
+ "Delete playlist": "Eliminar lista de reprodução",
+ "Delete playlist `x`?": "Eliminar a lista de reprodução 'x'?",
+ "search": "pesquisar",
+ "unsubscribe": "anular subscrição",
+ "Import/export": "Importar / exportar",
+ "Save preferences": "Guardar preferências",
+ "Top enabled: ": "Destaques ativados: ",
+ "Delete account": "Eliminar conta",
+ "Import/export data": "Importar / exportar dados",
+ "Show annotations by default: ": "Mostrar anotações sempre: ",
+ "Play next by default: ": "Reproduzir sempre o próximo: ",
+ "Sign In": "Iniciar sessão",
+ "Log in/register": "Iniciar sessão/registar",
+ "Delete account?": "Eliminar conta?",
+ "Import and Export Data": "Importar e exportar dados",
+ "Cannot change password for Google accounts": "Não é possível alterar a palavra-chave para contas do Google",
+ "Filipino": "Filipino",
+ "Estonian": "Estónio",
+ "Esperanto": "Esperanto",
+ "Dutch": "Holandês",
+ "Danish": "Dinamarquês",
+ "Czech": "Checo",
+ "Croatian": "Croata",
+ "Corsican": "Corso",
+ "Cebuano": "Cebuano",
+ "Catalan": "Catalão",
+ "Burmese": "Birmanês",
+ "Bulgarian": "Búlgaro",
+ "Bosnian": "Bósnio",
+ "Belarusian": "Bielorrusso",
+ "Basque": "Basco",
+ "Bangla": "Bangla",
+ "Azerbaijani": "Azerbaijano",
+ "Armenian": "Arménio",
+ "Arabic": "Árabe",
+ "Amharic": "Amárico",
+ "Albanian": "Albanês",
+ "Afrikaans": "Africano",
+ "English (auto-generated)": "Inglês (auto-gerado)",
+ "English": "Inglês",
+ "Token is expired, please try again": "Token expirou, tente novamente",
+ "No such user": "Utilizador inválido",
+ "Erroneous token": "Token inválido",
+ "Erroneous challenge": "Desafio inválido",
+ "Hidden field \"token\" is a required field": "O campo oculto \"token\" é um campo obrigatório",
+ "Hidden field \"challenge\" is a required field": "O campo oculto \"desafio\" é obrigatório",
+ "Playlist does not exist.": "A lista de reprodução não existe.",
+ "Not a playlist.": "Não é uma lista de reprodução.",
+ "Empty playlist": "Lista de reprodução vazia",
+ "`x` points": {
+ "": "`x` pontos",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pontos"
+ },
+ "Load more": "Carregar mais",
+ "`x` ago": "`x` atrás",
+ "View `x` replies": {
+ "": "Ver `x` respostas",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` respostas"
+ },
+ "Could not fetch comments": "Não foi possível obter os comentários",
+ "Could not get channel info.": "Não foi possível obter as informações do canal.",
+ "This channel does not exist.": "Este canal não existe.",
+ "channel:`x`": "canal:'x'",
+ "Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`",
+ "Please log in": "Por favor, inicie sessão",
+ "Password cannot be longer than 55 characters": "A palavra-chave não pode ser superior a 55 caracteres",
+ "Password cannot be empty": "A palavra-chave não pode estar vazia",
+ "Please sign in using 'Log in with Google'": "Por favor, inicie sessão usando 'Iniciar sessão com o Google'",
+ "Wrong username or password": "Nome de utilizador ou palavra-chave incorreto",
+ "Password is a required field": "Palavra-chave é um campo obrigatório",
+ "User ID is a required field": "O nome de utilizador é um campo obrigatório",
+ "CAPTCHA is a required field": "CAPTCHA é um campo obrigatório",
+ "Erroneous CAPTCHA": "CAPTCHA inválido",
+ "Wrong answer": "Resposta errada",
+ "Invalid TFA code": "Código TFA inválido",
+ "Quota exceeded, try again in a few hours": "Cota excedida. Tente novamente dentro de algumas horas",
+ "Incorrect password": "Palavra-chave incorreta",
+ "Show replies": "Mostrar respostas",
+ "Hide replies": "Ocultar respostas",
+ "View Reddit comments": "Ver comentários do Reddit",
+ "View `x` comments": {
+ "": "Ver `x` comentários",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentários"
+ },
+ "View more comments on Reddit": "Ver mais comentários no Reddit",
+ "View YouTube comments": "Ver comentários do YouTube",
+ "Premieres `x`": "Estreias 'x'",
+ "Premieres in `x`": "Estreias em 'x'",
+ "`x` views": {
+ "": "`x` visualizações",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizações"
+ },
+ "Shared `x`": "Partilhado `x`",
+ "Blacklisted regions: ": "Regiões bloqueadas: ",
+ "Whitelisted regions: ": "Regiões permitidas: ",
+ "Engagement: ": "Compromisso: ",
+ "Wilson score: ": "Pontuação de Wilson: ",
+ "Family friendly? ": "Filtrar conteúdo impróprio: ",
+ "License: ": "Licença: ",
+ "Genre: ": "Género: ",
+ "Show annotations": "Mostrar anotações",
+ "Hide annotations": "Ocultar anotações",
+ "Watch on YouTube": "Ver no YouTube",
+ "Editing playlist `x`": "A editar lista de reprodução 'x'",
+ "Playlist privacy": "Privacidade da lista de reprodução",
+ "Title": "Título",
+ "Create playlist": "Criar lista de reprodução",
+ "Updated `x` ago": "Atualizado `x` atrás",
+ "View all playlists": "Ver todas as listas de reprodução",
+ "Private": "Privado",
+ "Unlisted": "Não listado",
+ "Public": "Público",
+ "Trending": "Tendências",
+ "View privacy policy.": "Ver a política de privacidade.",
+ "View JavaScript license information.": "Ver informações da licença do JavaScript.",
+ "Source available here.": "Código-fonte disponível aqui.",
+ "Log out": "Terminar sessão",
+ "`x` unseen notifications": {
+ "": "`x` notificações não vistas",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notificações não vistas"
+ },
+ "Subscriptions": "Subscrições",
+ "revoke": "revogar",
+ "`x` tokens": {
+ "": "`x` tokens",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens"
+ },
+ "`x` subscriptions": {
+ "": "`x` subscrições",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscrições"
+ },
+ "Token": "Token",
+ "Token manager": "Gerir tokens",
+ "Subscription manager": "Gerir subscrições",
+ "Report statistics: ": "Relatório de estatísticas: ",
+ "Registration enabled: ": "Registar ativado: ",
+ "Login enabled: ": "Iniciar sessão ativado: ",
+ "CAPTCHA enabled: ": "CAPTCHA ativado: ",
+ "Feed menu: ": "Menu de subscrições: ",
+ "Default homepage: ": "Página inicial predefinida: ",
+ "Administrator preferences": "Preferências de administrador",
+ "Watch history": "Histórico de reprodução",
+ "Manage tokens": "Gerir tokens",
+ "Manage subscriptions": "Gerir as subscrições",
+ "Change password": "Alterar palavra-chave",
+ "Clear watch history": "Limpar histórico de reprodução",
+ "Data preferences": "Preferências de dados",
+ "`x` is live": "`x` está em direto",
+ "`x` uploaded a video": "`x` publicou um novo vídeo",
+ "Enable web notifications": "Ativar notificações pela web",
+ "Only show notifications (if there are any): ": "Mostrar apenas notificações (se existirem): ",
+ "Only show unwatched: ": "Mostrar apenas vídeos não visualizados: ",
+ "Only show latest unwatched video from channel: ": "Mostrar apenas vídeos mais recentes não visualizados do canal: ",
+ "Only show latest video from channel: ": "Mostrar apenas o vídeo mais recente do canal: ",
+ "channel name - reverse": "nome do canal - inverso",
+ "channel name": "nome do canal",
+ "alphabetically - reverse": "alfabeticamente - inverso",
+ "alphabetically": "alfabeticamente",
+ "published - reverse": "publicado - inverso",
+ "published": "publicado",
+ "Sort videos by: ": "Ordenar vídeos por: ",
+ "Number of videos shown in feed: ": "Quantidade de vídeos nas subscrições: ",
+ "Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ",
+ "Show annotations by default for subscribed channels: ": "Mostrar sempre anotações aos canais subscritos: ",
+ "Subscription preferences": "Preferências de subscrições",
+ "Thin mode: ": "Modo compacto: ",
+ "light": "claro",
+ "dark": "escuro",
+ "Theme: ": "Tema: ",
+ "Dark mode: ": "Modo escuro: ",
+ "Player style: ": "Estilo do reprodutor: ",
+ "Visual preferences": "Preferências visuais",
+ "Show related videos: ": "Mostrar vídeos relacionados: ",
+ "Fallback captions: ": "Legendas alternativas: ",
+ "Default captions: ": "Legendas predefinidas: ",
+ "reddit": "reddit",
+ "youtube": "YouTube",
+ "Default comments: ": "Preferência dos comentários: ",
+ "Player volume: ": "Volume da reprodução: ",
+ "Preferred video quality: ": "Qualidade de vídeo preferida: ",
+ "Default speed: ": "Velocidade preferida: ",
+ "Proxy videos: ": "Usar proxy nos vídeos: ",
+ "Listen by default: ": "Apenas áudio: ",
+ "Autoplay next video: ": "Reproduzir próximo vídeo automaticamente: ",
+ "Autoplay: ": "Reprodução automática: ",
+ "Always loop: ": "Repetir sempre: ",
+ "Player preferences": "Preferências do reprodutor",
+ "Preferences": "Preferências",
+ "Google verification code": "Código de verificação do Google",
+ "E-mail": "E-mail",
+ "Register": "Registar",
+ "Image CAPTCHA": "Imagem CAPTCHA",
+ "Text CAPTCHA": "Texto CAPTCHA",
+ "Time (h:mm:ss):": "Tempo (h:mm:ss):",
+ "Password": "Palavra-chave",
+ "User ID": "Utilizador",
+ "Log in with Google": "Iniciar sessão com o Google",
+ "Log in": "Iniciar sessão",
+ "source": "código-fonte",
+ "JavaScript license information": "Informação de licença do JavaScript",
+ "An alternative front-end to YouTube": "Uma interface alternativa ao YouTube",
+ "History": "Histórico",
+ "Export data as JSON": "Exportar dados como JSON",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar subscrições como OPML (para NewPipe e FreeTube)",
+ "Export subscriptions as OPML": "Exportar subscrições como OPML",
+ "Export": "Exportar",
+ "Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)",
+ "Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)",
+ "Import FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)",
+ "Import YouTube subscriptions": "Importar subscrições do YouTube",
+ "Import Invidious data": "Importar dados do Invidious",
+ "Import": "Importar",
+ "No": "Não",
+ "Yes": "Sim",
+ "Authorize token for `x`?": "Autorizar token para `x`?",
+ "Authorize token?": "Autorizar token?",
+ "New passwords must match": "As novas palavra-chaves devem corresponder",
+ "New password": "Nova palavra-chave",
+ "Clear watch history?": "Limpar histórico de reprodução?",
+ "Previous page": "Página anterior",
+ "Next page": "Próxima página",
+ "last": "últimos",
+ "Current version: ": "Versão atual: ",
+ "Community": "Comunidade",
+ "Playlists": "Listas de reprodução",
+ "Videos": "Vídeos",
+ "Video mode": "Modo de vídeo",
+ "Audio mode": "Modo de áudio",
+ "`x` marked it with a ❤": "`x` foi marcado como ❤",
+ "(edited)": "(editado)",
+ "%A %B %-d, %Y": "%A %B %-d, %Y",
+ "Movies": "Filmes",
+ "News": "Notícias",
+ "Gaming": "Jogos",
+ "Music": "Música",
+ "View as playlist": "Ver como lista de reprodução",
+ "Language: ": "Idioma: ",
+ "Rating: ": "Avaliação: ",
+ "About": "Sobre",
+ "Popular": "Popular",
+ "Fallback comments: ": "Comentários alternativos: ",
+ "Zulu": "Zulu",
+ "Yoruba": "Ioruba",
+ "Yiddish": "Iídiche",
+ "Xhosa": "Xhosa",
+ "Western Frisian": "Frísio Ocidental",
+ "Welsh": "Galês",
+ "Vietnamese": "Vietnamita",
+ "Uzbek": "Uzbeque",
+ "Urdu": "Urdu",
+ "Ukrainian": "Ucraniano",
+ "Turkish": "Turco",
+ "Thai": "Tailandês",
+ "Telugu": "Telugu",
+ "Tamil": "Tâmil",
+ "Tajik": "Tajique",
+ "Swedish": "Sueco",
+ "Swahili": "Suaíli",
+ "Sundanese": "Sudanês",
+ "Spanish (Latin America)": "Espanhol (América Latina)",
+ "Spanish": "Espanhol",
+ "Southern Sotho": "Sotho do Sul",
+ "Somali": "Somali",
+ "Slovenian": "Esloveno",
+ "Slovak": "Eslovaco",
+ "Sinhala": "Cingalês",
+ "Sindhi": "Sindhi",
+ "Shona": "Shona",
+ "Serbian": "Sérvio",
+ "Scottish Gaelic": "Gaélico escocês",
+ "Samoan": "Samoano",
+ "Russian": "Russo",
+ "Romanian": "Romeno",
+ "Punjabi": "Punjabi",
+ "Portuguese": "Português",
+ "Polish": "Polaco",
+ "Persian": "Persa",
+ "Pashto": "Pashto",
+ "Nyanja": "Nyanja",
+ "Norwegian Bokmål": "Bokmål norueguês",
+ "Nepali": "Nepalês",
+ "Mongolian": "Mongol",
+ "Marathi": "Marathi",
+ "Maori": "Maori",
+ "Maltese": "Maltês",
+ "Malayalam": "Malaiala",
+ "Malay": "Malaio",
+ "Malagasy": "Malgaxe",
+ "Macedonian": "Macedónio",
+ "Luxembourgish": "Luxemburguês",
+ "Lithuanian": "Lituano",
+ "Latvian": "Letão",
+ "Latin": "Latim",
+ "Lao": "Laosiano",
+ "Kyrgyz": "Quirguiz",
+ "Kurdish": "Curdo",
+ "Korean": "Coreano",
+ "Khmer": "Khmer",
+ "Kazakh": "Cazaque",
+ "Kannada": "Canarim",
+ "Javanese": "Javanês",
+ "Japanese": "Japonês",
+ "Italian": "Italiano",
+ "Irish": "Irlandês",
+ "Indonesian": "Indonésio",
+ "Igbo": "Igbo",
+ "Icelandic": "Islandês",
+ "Hungarian": "Húngaro",
+ "Hmong": "Hmong",
+ "Hindi": "Hindi",
+ "Hebrew": "Hebraico",
+ "Hawaiian": "Havaiano",
+ "Hausa": "Hauçá",
+ "Haitian Creole": "Crioulo haitiano",
+ "Gujarati": "Guzerate",
+ "Greek": "Grego",
+ "German": "Alemão",
+ "Georgian": "Georgiano",
+ "Galician": "Galego",
+ "French": "Francês",
+ "Finnish": "Finlandês",
+ "popular": "popular",
+ "oldest": "mais antigos",
+ "newest": "mais recentes",
+ "View playlist on YouTube": "Ver lista de reprodução no YouTube",
+ "View channel on YouTube": "Ver canal no YouTube",
+ "Subscribe": "Subscrever",
+ "Unsubscribe": "Anular subscrição",
+ "Shared `x` ago": "Partilhado `x` atrás",
+ "LIVE": "Em direto",
+ "`x` playlists": {
+ "": "`x` listas de reprodução",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` listas de reprodução"
+ },
+ "`x` videos": {
+ "": "`x` vídeos",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videos"
+ },
+ "`x` subscribers": {
+ "": "`x` subscritores",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscritores"
+ },
+ "short": "Curto (< 4 minutos)",
+ "long": "Longo (> 20 minutos)",
+ "footer_source_code": "Código-fonte",
+ "footer_original_source_code": "Código-fonte original",
+ "adminprefs_modified_source_code_url_label": "URL do repositório do código-fonte alterado",
+ "footer_donate": "Fazer um donativo: ",
+ "footer_documentation": "Documentação",
+ "footer_modfied_source_code": "Código-fonte alterado"
+}
diff --git a/locales/ru.json b/locales/ru.json
index d26cd058..f5026908 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -145,7 +145,7 @@
},
"search": "поиск",
"Log out": "Выйти",
- "Released under the AGPLv3 on Github.": "",
+ "Released under the AGPLv3 on Github.": "Выпущено под лицензией AGPLv3 на Github.",
"Source available here.": "Исходный код доступен здесь.",
"View JavaScript license information.": "Посмотреть информацию по лицензии JavaScript.",
"View privacy policy.": "Посмотреть политику конфиденциальности.",
@@ -399,7 +399,7 @@
"views": "Просмотры",
"content_type": "Тип",
"duration": "Длительность",
- "features": "",
+ "features": "Функции",
"sort": "Сортировать по",
"hour": "Последний час",
"today": "Сегодня",
@@ -423,5 +423,7 @@
"Current version: ": "Текущая версия: ",
"next_steps_error_message": "После чего следует попробовать: ",
"next_steps_error_message_refresh": "Обновить",
- "next_steps_error_message_go_to_youtube": "Перейти на YouTube"
+ "next_steps_error_message_go_to_youtube": "Перейти на YouTube",
+ "short": "Короткие (< 4 минут)",
+ "long": "Длинные (> 20 минут)"
}
diff --git a/locales/tr.json b/locales/tr.json
index 493f1295..26c6abdd 100644
--- a/locales/tr.json
+++ b/locales/tr.json
@@ -423,5 +423,13 @@
"Current version: ": "Şu anki sürüm: ",
"next_steps_error_message": "Bundan sonra şunları denemelisiniz: ",
"next_steps_error_message_refresh": "Yenile",
- "next_steps_error_message_go_to_youtube": "YouTube'a git"
+ "next_steps_error_message_go_to_youtube": "YouTube'a git",
+ "short": "Kısa (4 dakikadan az)",
+ "long": "Uzun (20 dakikadan fazla)",
+ "footer_donate": "Bağış yap: ",
+ "footer_documentation": "Belgelendirme",
+ "footer_source_code": "Kaynak kodları",
+ "footer_original_source_code": "Orijinal kaynak kodları",
+ "footer_modfied_source_code": "Değiştirilmiş kaynak kodları",
+ "adminprefs_modified_source_code_url_label": "Değiştirilmiş kaynak kodları deposunun URL'si"
}
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index 5f89f964..918b7c68 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -423,5 +423,13 @@
"Current version: ": "当前版本: ",
"next_steps_error_message": "在此之后你应尝试: ",
"next_steps_error_message_refresh": "刷新",
- "next_steps_error_message_go_to_youtube": "转到 YouTube"
+ "next_steps_error_message_go_to_youtube": "转到 YouTube",
+ "short": "短(少于4分钟)",
+ "long": "长(多于 20 分钟)",
+ "footer_donate": "捐赠: ",
+ "footer_documentation": "文档",
+ "footer_source_code": "源代码",
+ "footer_modfied_source_code": "修改的源代码",
+ "adminprefs_modified_source_code_url_label": "更改的源代码仓库网址",
+ "footer_original_source_code": "原始源代码"
}
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index 96e04594..51de7090 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -423,5 +423,13 @@
"Current version: ": "目前版本: ",
"next_steps_error_message": "之後您應該嘗試: ",
"next_steps_error_message_refresh": "重新整理",
- "next_steps_error_message_go_to_youtube": "到 YouTube"
+ "next_steps_error_message_go_to_youtube": "到 YouTube",
+ "short": "短(小於4分鐘)",
+ "long": "長(多於20分鐘)",
+ "footer_donate": "抖內: ",
+ "footer_documentation": "文件",
+ "footer_source_code": "原始碼",
+ "footer_original_source_code": "原本的原始碼",
+ "footer_modfied_source_code": "修改後的原始碼",
+ "adminprefs_modified_source_code_url_label": "修改後的原始碼倉庫 URL"
}
diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr
index ada5b28f..b17c8d73 100644
--- a/spec/helpers_spec.cr
+++ b/spec/helpers_spec.cr
@@ -6,6 +6,7 @@ require "spec"
require "yaml"
require "../src/invidious/helpers/*"
require "../src/invidious/channels/*"
+require "../src/invidious/videos"
require "../src/invidious/comments"
require "../src/invidious/playlists"
require "../src/invidious/search"
diff --git a/src/invidious.cr b/src/invidious.cr
index 5ad2dd91..18ec0b97 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -67,7 +67,7 @@ SOFTWARE = {
"branch" => "#{CURRENT_BRANCH}",
}
-YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size, timeout: 2.0, use_quic: CONFIG.use_quic)
+YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size, use_quic: CONFIG.use_quic)
# CLI
Kemal.config.extra_options do |parser|
@@ -153,10 +153,6 @@ if CONFIG.popular_enabled
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
end
-if CONFIG.captcha_key
- Invidious::Jobs.register Invidious::Jobs::BypassCaptchaJob.new
-end
-
connection_channel = Channel({Bool, Channel(PQ::Notification)}).new(32)
Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(connection_channel, CONFIG.database_url)
@@ -316,80 +312,89 @@ before_all do |env|
env.set "current_page", URI.encode_www_form(current_page)
end
-Invidious::Routing.get "/", Invidious::Routes::Misc, :home
-Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy
-Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses
-
-Invidious::Routing.get "/channel/:ucid", Invidious::Routes::Channels, :home
-Invidious::Routing.get "/channel/:ucid/home", Invidious::Routes::Channels, :home
-Invidious::Routing.get "/channel/:ucid/videos", Invidious::Routes::Channels, :videos
-Invidious::Routing.get "/channel/:ucid/playlists", Invidious::Routes::Channels, :playlists
-Invidious::Routing.get "/channel/:ucid/community", Invidious::Routes::Channels, :community
-Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about
-
-["", "/videos", "/playlists", "/community", "/about"].each do |path|
- # /c/LinusTechTips
- Invidious::Routing.get "/c/:user#{path}", Invidious::Routes::Channels, :brand_redirect
- # /user/linustechtips | Not always the same as /c/
- Invidious::Routing.get "/user/:user#{path}", Invidious::Routes::Channels, :brand_redirect
- # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow
- Invidious::Routing.get "/attribution_link#{path}", Invidious::Routes::Channels, :brand_redirect
- # /profile?user=linustechtips
- Invidious::Routing.get "/profile/#{path}", Invidious::Routes::Channels, :profile
-end
-
-Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle
-Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect
-Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect
-Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect
-Invidious::Routing.get "/v/:id", Invidious::Routes::Watch, :redirect
-Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect
-Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_redirect
-
-Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect
-Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show
-
-Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new
-Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create
-Invidious::Routing.get "/subscribe_playlist", Invidious::Routes::Playlists, :subscribe
-Invidious::Routing.get "/delete_playlist", Invidious::Routes::Playlists, :delete_page
-Invidious::Routing.post "/delete_playlist", Invidious::Routes::Playlists, :delete
-Invidious::Routing.get "/edit_playlist", Invidious::Routes::Playlists, :edit
-Invidious::Routing.post "/edit_playlist", Invidious::Routes::Playlists, :update
-Invidious::Routing.get "/add_playlist_items", Invidious::Routes::Playlists, :add_playlist_items_page
-Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax
-Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show
-Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix
-
-Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch
-Invidious::Routing.get "/results", Invidious::Routes::Search, :results
-Invidious::Routing.get "/search", Invidious::Routes::Search, :search
-
-Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page
-Invidious::Routing.post "/login", Invidious::Routes::Login, :login
-Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout
-
-Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show
-Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update
-Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
-
-# Feeds
-Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect
-Invidious::Routing.get "/feed/playlists", Invidious::Routes::Feeds, :playlists
-Invidious::Routing.get "/feed/popular", Invidious::Routes::Feeds, :popular
-Invidious::Routing.get "/feed/trending", Invidious::Routes::Feeds, :trending
-Invidious::Routing.get "/feed/subscriptions", Invidious::Routes::Feeds, :subscriptions
-Invidious::Routing.get "/feed/history", Invidious::Routes::Feeds, :history
-
-# RSS Feeds
-Invidious::Routing.get "/feed/channel/:ucid", Invidious::Routes::Feeds, :rss_channel
-Invidious::Routing.get "/feed/private", Invidious::Routes::Feeds, :rss_private
-Invidious::Routing.get "/feed/playlist/:plid", Invidious::Routes::Feeds, :rss_playlist
-Invidious::Routing.get "/feeds/videos.xml", Invidious::Routes::Feeds, :rss_videos
-
-# Support push notifications via PubSubHubbub
-Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get
-Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post
+{% unless flag?(:api_only) %}
+ Invidious::Routing.get "/", Invidious::Routes::Misc, :home
+ Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy
+ Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses
+
+ Invidious::Routing.get "/channel/:ucid", Invidious::Routes::Channels, :home
+ Invidious::Routing.get "/channel/:ucid/home", Invidious::Routes::Channels, :home
+ Invidious::Routing.get "/channel/:ucid/videos", Invidious::Routes::Channels, :videos
+ Invidious::Routing.get "/channel/:ucid/playlists", Invidious::Routes::Channels, :playlists
+ Invidious::Routing.get "/channel/:ucid/community", Invidious::Routes::Channels, :community
+ Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about
+
+ ["", "/videos", "/playlists", "/community", "/about"].each do |path|
+ # /c/LinusTechTips
+ Invidious::Routing.get "/c/:user#{path}", Invidious::Routes::Channels, :brand_redirect
+ # /user/linustechtips | Not always the same as /c/
+ Invidious::Routing.get "/user/:user#{path}", Invidious::Routes::Channels, :brand_redirect
+ # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow
+ Invidious::Routing.get "/attribution_link#{path}", Invidious::Routes::Channels, :brand_redirect
+ # /profile?user=linustechtips
+ Invidious::Routing.get "/profile/#{path}", Invidious::Routes::Channels, :profile
+ end
+
+ Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle
+ Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect
+ Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect
+ Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect
+ Invidious::Routing.get "/v/:id", Invidious::Routes::Watch, :redirect
+ Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect
+ Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_redirect
+
+ Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect
+ Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show
+
+ Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new
+ Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create
+ Invidious::Routing.get "/subscribe_playlist", Invidious::Routes::Playlists, :subscribe
+ Invidious::Routing.get "/delete_playlist", Invidious::Routes::Playlists, :delete_page
+ Invidious::Routing.post "/delete_playlist", Invidious::Routes::Playlists, :delete
+ Invidious::Routing.get "/edit_playlist", Invidious::Routes::Playlists, :edit
+ Invidious::Routing.post "/edit_playlist", Invidious::Routes::Playlists, :update
+ Invidious::Routing.get "/add_playlist_items", Invidious::Routes::Playlists, :add_playlist_items_page
+ Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax
+ Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show
+ Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix
+
+ Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch
+ Invidious::Routing.get "/results", Invidious::Routes::Search, :results
+ Invidious::Routing.get "/search", Invidious::Routes::Search, :search
+
+ Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page
+ Invidious::Routing.post "/login", Invidious::Routes::Login, :login
+ Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout
+
+ Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show
+ Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update
+ Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
+
+ # Feeds
+ Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect
+ Invidious::Routing.get "/feed/playlists", Invidious::Routes::Feeds, :playlists
+ Invidious::Routing.get "/feed/popular", Invidious::Routes::Feeds, :popular
+ Invidious::Routing.get "/feed/trending", Invidious::Routes::Feeds, :trending
+ Invidious::Routing.get "/feed/subscriptions", Invidious::Routes::Feeds, :subscriptions
+ Invidious::Routing.get "/feed/history", Invidious::Routes::Feeds, :history
+
+ # RSS Feeds
+ Invidious::Routing.get "/feed/channel/:ucid", Invidious::Routes::Feeds, :rss_channel
+ Invidious::Routing.get "/feed/private", Invidious::Routes::Feeds, :rss_private
+ Invidious::Routing.get "/feed/playlist/:plid", Invidious::Routes::Feeds, :rss_playlist
+ Invidious::Routing.get "/feeds/videos.xml", Invidious::Routes::Feeds, :rss_videos
+
+ # Support push notifications via PubSubHubbub
+ Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get
+ Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post
+{% end %}
+
+Invidious::Routing.get "/ggpht/*", Invidious::Routes::Images, :ggpht
+Invidious::Routing.options "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :options_storyboard
+Invidious::Routing.get "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :get_storyboard
+Invidious::Routing.get "/s_p/:id/:name", Invidious::Routes::Images, :s_p_image
+Invidious::Routing.get "/yts/img/:name", Invidious::Routes::Images, :yts_image
+Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails
# API routes (macro)
define_v1_api_routes()
@@ -1275,194 +1280,6 @@ post "/api/v1/auth/notifications" do |env|
create_notification_stream(env, topics, connection_channel)
end
-get "/ggpht/*" do |env|
- url = env.request.path.lchop("/ggpht")
-
- headers = HTTP::Headers{":authority" => "yt3.ggpht.com"}
- REQUEST_HEADERS_WHITELIST.each do |header|
- if env.request.headers[header]?
- headers[header] = env.request.headers[header]
- end
- end
-
- begin
- YT_POOL.client &.get(url, headers) do |response|
- env.response.status_code = response.status_code
- response.headers.each do |key, value|
- if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
- env.response.headers[key] = value
- end
- end
-
- env.response.headers["Access-Control-Allow-Origin"] = "*"
-
- if response.status_code >= 300
- env.response.headers.delete("Transfer-Encoding")
- break
- end
-
- proxy_file(response, env)
- end
- rescue ex
- end
-end
-
-options "/sb/:authority/:id/:storyboard/:index" do |env|
- env.response.headers["Access-Control-Allow-Origin"] = "*"
- env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS"
- env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range"
-end
-
-get "/sb/:authority/:id/:storyboard/:index" do |env|
- authority = env.params.url["authority"]
- id = env.params.url["id"]
- storyboard = env.params.url["storyboard"]
- index = env.params.url["index"]
-
- url = "/sb/#{id}/#{storyboard}/#{index}?#{env.params.query}"
-
- headers = HTTP::Headers.new
-
- headers[":authority"] = "#{authority}.ytimg.com"
-
- REQUEST_HEADERS_WHITELIST.each do |header|
- if env.request.headers[header]?
- headers[header] = env.request.headers[header]
- end
- end
-
- begin
- YT_POOL.client &.get(url, headers) do |response|
- env.response.status_code = response.status_code
- response.headers.each do |key, value|
- if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
- env.response.headers[key] = value
- end
- end
-
- env.response.headers["Connection"] = "close"
- env.response.headers["Access-Control-Allow-Origin"] = "*"
-
- if response.status_code >= 300
- env.response.headers.delete("Transfer-Encoding")
- break
- end
-
- proxy_file(response, env)
- end
- rescue ex
- end
-end
-
-get "/s_p/:id/:name" do |env|
- id = env.params.url["id"]
- name = env.params.url["name"]
-
- url = env.request.resource
-
- headers = HTTP::Headers{":authority" => "i9.ytimg.com"}
- REQUEST_HEADERS_WHITELIST.each do |header|
- if env.request.headers[header]?
- headers[header] = env.request.headers[header]
- end
- end
-
- begin
- YT_POOL.client &.get(url, headers) do |response|
- env.response.status_code = response.status_code
- response.headers.each do |key, value|
- if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
- env.response.headers[key] = value
- end
- end
-
- env.response.headers["Access-Control-Allow-Origin"] = "*"
-
- if response.status_code >= 300 && response.status_code != 404
- env.response.headers.delete("Transfer-Encoding")
- break
- end
-
- proxy_file(response, env)
- end
- rescue ex
- end
-end
-
-get "/yts/img/:name" do |env|
- headers = HTTP::Headers.new
- REQUEST_HEADERS_WHITELIST.each do |header|
- if env.request.headers[header]?
- headers[header] = env.request.headers[header]
- end
- end
-
- begin
- YT_POOL.client &.get(env.request.resource, headers) do |response|
- env.response.status_code = response.status_code
- response.headers.each do |key, value|
- if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
- env.response.headers[key] = value
- end
- end
-
- env.response.headers["Access-Control-Allow-Origin"] = "*"
-
- if response.status_code >= 300 && response.status_code != 404
- env.response.headers.delete("Transfer-Encoding")
- break
- end
-
- proxy_file(response, env)
- end
- rescue ex
- end
-end
-
-get "/vi/:id/:name" do |env|
- id = env.params.url["id"]
- name = env.params.url["name"]
-
- headers = HTTP::Headers{":authority" => "i.ytimg.com"}
-
- if name == "maxres.jpg"
- build_thumbnails(id).each do |thumb|
- if YT_POOL.client &.head("/vi/#{id}/#{thumb[:url]}.jpg", headers).status_code == 200
- name = thumb[:url] + ".jpg"
- break
- end
- end
- end
- url = "/vi/#{id}/#{name}"
-
- REQUEST_HEADERS_WHITELIST.each do |header|
- if env.request.headers[header]?
- headers[header] = env.request.headers[header]
- end
- end
-
- begin
- YT_POOL.client &.get(url, headers) do |response|
- env.response.status_code = response.status_code
- response.headers.each do |key, value|
- if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
- env.response.headers[key] = value
- end
- end
-
- env.response.headers["Access-Control-Allow-Origin"] = "*"
-
- if response.status_code >= 300 && response.status_code != 404
- env.response.headers.delete("Transfer-Encoding")
- break
- end
-
- proxy_file(response, env)
- end
- rescue ex
- end
-end
-
get "/Captcha" do |env|
headers = HTTP::Headers{":authority" => "accounts.google.com"}
response = YT_POOL.client &.get(env.request.resource, headers)
@@ -1551,4 +1368,11 @@ Kemal.config.logger = LOGGER
Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding
Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port
Kemal.config.app_name = "Invidious"
+
+# Use in kemal's production mode.
+# Users can also set the KEMAL_ENV environmental variable for this to be set automatically.
+{% if flag?(:release) || flag?(:production) %}
+ Kemal.config.env = "production" if !ENV.has_key?("KEMAL_ENV")
+{% end %}
+
Kemal.run
diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr
new file mode 100644
index 00000000..0277d43b
--- /dev/null
+++ b/src/invidious/helpers/extractors.cr
@@ -0,0 +1,625 @@
+# This file contains helper methods to parse the Youtube API json data into
+# neat little packages we can use
+
+# Tuple of Parsers/Extractors so we can easily cycle through them.
+private ITEM_CONTAINER_EXTRACTOR = {
+ Extractors::YouTubeTabs,
+ Extractors::SearchResults,
+ Extractors::Continuation,
+}
+
+private ITEM_PARSERS = {
+ Parsers::VideoRendererParser,
+ Parsers::ChannelRendererParser,
+ Parsers::GridPlaylistRendererParser,
+ Parsers::PlaylistRendererParser,
+ Parsers::CategoryRendererParser,
+}
+
+record AuthorFallback, name : String, id : String
+
+# Namespace for logic relating to parsing InnerTube data into various datastructs.
+#
+# Each of the parsers in this namespace are accessed through the #process() method
+# which validates the given data as applicable to itself. If it is applicable the given
+# data is passed to the private `#parse()` method which returns a datastruct of the given
+# type. Otherwise, nil is returned.
+private module Parsers
+ # Parses a InnerTube videoRenderer into a SearchVideo. Returns nil when the given object isn't a videoRenderer
+ #
+ # A videoRenderer renders a video to click on within the YouTube and Invidious UI. It is **not**
+ # the watchable video itself.
+ #
+ # See specs for example.
+ #
+ # `videoRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc.
+ #
+ module VideoRendererParser
+ def self.process(item : JSON::Any, author_fallback : AuthorFallback)
+ if item_contents = (item["videoRenderer"]? || item["gridVideoRenderer"]?)
+ return self.parse(item_contents, author_fallback)
+ end
+ end
+
+ private def self.parse(item_contents, author_fallback)
+ video_id = item_contents["videoId"].as_s
+ title = extract_text(item_contents["title"]?) || ""
+
+ # Extract author information
+ if author_info = item_contents.dig?("ownerText", "runs", 0)
+ author = author_info["text"].as_s
+ author_id = HelperExtractors.get_browse_id(author_info)
+ else
+ author = author_fallback.name
+ author_id = author_fallback.id
+ end
+
+ # For live videos (and possibly recently premiered videos) there is no published information.
+ # Instead, in its place is the amount of people currently watching. This behavior should be replicated
+ # on Invidious once all features of livestreams are supported. On an unrelated note, defaulting to the current
+ # time for publishing isn't a good idea.
+ published = item_contents.dig?("publishedTimeText", "simpleText").try { |t| decode_date(t.as_s) } || Time.local
+
+ # Typically views are stored under a "simpleText" in the "viewCountText". However, for
+ # livestreams and premiered it is stored under a "runs" array: [{"text":123}, {"text": "watching"}]
+ # When view count is disabled the "viewCountText" is not present on InnerTube data.
+ # TODO change default value to nil and typical encoding type to tuple storing type (watchers, views, etc)
+ # and count
+ view_count = item_contents.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64
+ description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || ""
+
+ # The length information *should* only always exist in "lengthText". However, the legacy Invidious code
+ # extracts from "thumbnailOverlays" when it doesn't. More testing is needed to see if this is
+ # actually needed
+ if length_container = item_contents["lengthText"]?
+ length_seconds = decode_length_seconds(length_container["simpleText"].as_s)
+ elsif length_container = item_contents["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?)
+ # This needs to only go down the `simpleText` path (if possible). If more situations came up that requires
+ # a specific pathway then we should add an argument to extract_text that'll make this possible
+ length_seconds = length_container.dig?("thumbnailOverlayTimeStatusRenderer", "text", "simpleText")
+
+ if length_seconds
+ length_seconds = decode_length_seconds(length_seconds.as_s)
+ else
+ length_seconds = 0
+ end
+ else
+ length_seconds = 0
+ end
+
+ live_now = false
+ paid = false
+ premium = false
+
+ premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) }
+
+ item_contents["badges"]?.try &.as_a.each do |badge|
+ b = badge["metadataBadgeRenderer"]
+ case b["label"].as_s
+ when "LIVE NOW"
+ live_now = true
+ when "New", "4K", "CC"
+ # TODO
+ when "Premium"
+ # TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"]
+ premium = true
+ else nil # Ignore
+ end
+ end
+
+ SearchVideo.new({
+ title: title,
+ id: video_id,
+ author: author,
+ ucid: author_id,
+ published: published,
+ views: view_count,
+ description_html: description_html,
+ length_seconds: length_seconds,
+ live_now: live_now,
+ premium: premium,
+ premiere_timestamp: premiere_timestamp,
+ })
+ end
+
+ def self.parser_name
+ return {{@type.name}}
+ end
+ end
+
+ # Parses a InnerTube channelRenderer into a SearchChannel. Returns nil when the given object isn't a channelRenderer
+ #
+ # A channelRenderer renders a channel to click on within the YouTube and Invidious UI. It is **not**
+ # the channel page itself.
+ #
+ # See specs for example.
+ #
+ # `channelRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc.
+ #
+ module ChannelRendererParser
+ def self.process(item : JSON::Any, author_fallback : AuthorFallback)
+ if item_contents = (item["channelRenderer"]? || item["gridChannelRenderer"]?)
+ return self.parse(item_contents, author_fallback)
+ end
+ end
+
+ private def self.parse(item_contents, author_fallback)
+ author = extract_text(item_contents["title"]) || author_fallback.name
+ author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id
+
+ author_thumbnail = HelperExtractors.get_thumbnails(item_contents)
+ # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube.
+ # Always simpleText
+ # TODO change default value to nil
+ subscriber_count = item_contents.dig?("subscriberCountText", "simpleText")
+ .try { |s| short_text_to_number(s.as_s.split(" ")[0]) } || 0
+
+ # Auto-generated channels doesn't have videoCountText
+ # Taken from: https://github.com/iv-org/invidious/pull/2228#discussion_r717620922
+ auto_generated = item_contents["videoCountText"]?.nil?
+
+ video_count = HelperExtractors.get_video_count(item_contents)
+ description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || ""
+
+ SearchChannel.new({
+ author: author,
+ ucid: author_id,
+ author_thumbnail: author_thumbnail,
+ subscriber_count: subscriber_count,
+ video_count: video_count,
+ description_html: description_html,
+ auto_generated: auto_generated,
+ })
+ end
+
+ def self.parser_name
+ return {{@type.name}}
+ end
+ end
+
+ # Parses a InnerTube gridPlaylistRenderer into a SearchPlaylist. Returns nil when the given object isn't a gridPlaylistRenderer
+ #
+ # A gridPlaylistRenderer renders a playlist, that is located in a grid, to click on within the YouTube and Invidious UI.
+ # It is **not** the playlist itself.
+ #
+ # See specs for example.
+ #
+ # `gridPlaylistRenderer`s can be found on the playlist-tabs of channels and expanded categories.
+ #
+ module GridPlaylistRendererParser
+ def self.process(item : JSON::Any, author_fallback : AuthorFallback)
+ if item_contents = item["gridPlaylistRenderer"]?
+ return self.parse(item_contents, author_fallback)
+ end
+ end
+
+ private def self.parse(item_contents, author_fallback)
+ title = extract_text(item_contents["title"]) || ""
+ plid = item_contents["playlistId"]?.try &.as_s || ""
+
+ video_count = HelperExtractors.get_video_count(item_contents)
+ playlist_thumbnail = HelperExtractors.get_thumbnails(item_contents)
+
+ SearchPlaylist.new({
+ title: title,
+ id: plid,
+ author: author_fallback.name,
+ ucid: author_fallback.id,
+ video_count: video_count,
+ videos: [] of SearchPlaylistVideo,
+ thumbnail: playlist_thumbnail,
+ })
+ end
+
+ def self.parser_name
+ return {{@type.name}}
+ end
+ end
+
+ # Parses a InnerTube playlistRenderer into a SearchPlaylist. Returns nil when the given object isn't a playlistRenderer
+ #
+ # A playlistRenderer renders a playlist to click on within the YouTube and Invidious UI. It is **not** the playlist itself.
+ #
+ # See specs for example.
+ #
+ # `playlistRenderer`s can be found almost everywhere on YouTube. In categories, search results, recommended, etc.
+ #
+ module PlaylistRendererParser
+ def self.process(item : JSON::Any, author_fallback : AuthorFallback)
+ if item_contents = item["playlistRenderer"]?
+ return self.parse(item_contents, author_fallback)
+ end
+ end
+
+ private def self.parse(item_contents, author_fallback)
+ title = item_contents["title"]["simpleText"]?.try &.as_s || ""
+ plid = item_contents["playlistId"]?.try &.as_s || ""
+
+ video_count = HelperExtractors.get_video_count(item_contents)
+ playlist_thumbnail = HelperExtractors.get_thumbnails_plural(item_contents)
+
+ author_info = item_contents.dig?("shortBylineText", "runs", 0)
+ author = author_info.try &.["text"].as_s || author_fallback.name
+ author_id = author_info.try { |x| HelperExtractors.get_browse_id(x) } || author_fallback.id
+
+ videos = item_contents["videos"]?.try &.as_a.map do |v|
+ v = v["childVideoRenderer"]
+ v_title = v.dig?("title", "simpleText").try &.as_s || ""
+ v_id = v["videoId"]?.try &.as_s || ""
+ v_length_seconds = v.dig?("lengthText", "simpleText").try { |t| decode_length_seconds(t.as_s) } || 0
+ SearchPlaylistVideo.new({
+ title: v_title,
+ id: v_id,
+ length_seconds: v_length_seconds,
+ })
+ end || [] of SearchPlaylistVideo
+
+ # TODO: item_contents["publishedTimeText"]?
+
+ SearchPlaylist.new({
+ title: title,
+ id: plid,
+ author: author,
+ ucid: author_id,
+ video_count: video_count,
+ videos: videos,
+ thumbnail: playlist_thumbnail,
+ })
+ end
+
+ def self.parser_name
+ return {{@type.name}}
+ end
+ end
+
+ # Parses a InnerTube shelfRenderer into a Category. Returns nil when the given object isn't a shelfRenderer
+ #
+ # A shelfRenderer renders divided sections on YouTube. IE "People also watched" in search results and
+ # the various organizational sections in the channel home page. A separate one (richShelfRenderer) is used
+ # for YouTube home. A shelfRenderer can also sometimes be expanded to show more content within it.
+ #
+ # See specs for example.
+ #
+ # `shelfRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc.
+ #
+ module CategoryRendererParser
+ def self.process(item : JSON::Any, author_fallback : AuthorFallback)
+ if item_contents = item["shelfRenderer"]?
+ return self.parse(item_contents, author_fallback)
+ end
+ end
+
+ private def self.parse(item_contents, author_fallback)
+ title = extract_text(item_contents["title"]?) || ""
+ url = item_contents.dig?("endpoint", "commandMetadata", "webCommandMetadata", "url")
+ .try &.as_s
+
+ # Sometimes a category can have badges.
+ badges = [] of Tuple(String, String) # (Badge style, label)
+ item_contents["badges"]?.try &.as_a.each do |badge|
+ badge = badge["metadataBadgeRenderer"]
+ badges << {badge["style"].as_s, badge["label"].as_s}
+ end
+
+ # Category description
+ description_html = item_contents["subtitle"]?.try { |desc| parse_content(desc) } || ""
+
+ # Content parsing
+ contents = [] of SearchItem
+
+ # InnerTube recognizes some "special" categories, which are organized differently.
+ if special_category_container = item_contents["content"]?
+ if content_container = special_category_container["horizontalListRenderer"]?
+ elsif content_container = special_category_container["expandedShelfContentsRenderer"]?
+ elsif content_container = special_category_container["verticalListRenderer"]?
+ else
+ # Anything else, such as `horizontalMovieListRenderer` is currently unsupported.
+ return
+ end
+ else
+ # "Normal" category.
+ content_container = item_contents["contents"]
+ end
+
+ raw_contents = content_container["items"]?.try &.as_a
+ if !raw_contents.nil?
+ raw_contents.each do |item|
+ result = extract_item(item)
+ if !result.nil?
+ contents << result
+ end
+ end
+ end
+
+ Category.new({
+ title: title,
+ contents: contents,
+ description_html: description_html,
+ url: url,
+ badges: badges,
+ })
+ end
+
+ def self.parser_name
+ return {{@type.name}}
+ end
+ end
+end
+
+# The following are the extractors for extracting an array of items from
+# the internal Youtube API's JSON response. The result is then packaged into
+# a structure we can more easily use via the parsers above. Their internals are
+# identical to the item parsers.
+
+# Namespace for logic relating to extracting InnerTube's initial response to items we can parse.
+#
+# Each of the extractors in this namespace are accessed through the #process() method
+# which validates the given data as applicable to itself. If it is applicable the given
+# data is passed to the private `#extract()` method which returns an array of
+# parsable items. Otherwise, nil is returned.
+#
+# NOTE perhaps the result from here should be abstracted into a struct in order to
+# get additional metadata regarding the container of the item(s).
+private module Extractors
+ # Extracts items from the selected YouTube tab.
+ #
+ # YouTube tabs are typically stored under "twoColumnBrowseResultsRenderer"
+ # and is structured like this:
+ #
+ # "twoColumnBrowseResultsRenderer": {
+ # {"tabs": [
+ # {"tabRenderer": {
+ # "endpoint": {...}
+ # "title": "Playlists",
+ # "selected": true,
+ # "content": {...},
+ # ...
+ # }}
+ # ]}
+ # }]
+ #
+ module YouTubeTabs
+ def self.process(initial_data : Hash(String, JSON::Any))
+ if target = initial_data["twoColumnBrowseResultsRenderer"]?
+ self.extract(target)
+ end
+ end
+
+ private def self.extract(target)
+ raw_items = [] of JSON::Any
+ content = extract_selected_tab(target["tabs"])["content"]
+
+ content["sectionListRenderer"]["contents"].as_a.each do |renderer_container|
+ renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0]
+
+ # Category extraction
+ if items_container = renderer_container_contents["shelfRenderer"]?
+ raw_items << renderer_container_contents
+ next
+ elsif items_container = renderer_container_contents["gridRenderer"]?
+ else
+ items_container = renderer_container_contents
+ end
+
+ items_container["items"]?.try &.as_a.each do |item|
+ raw_items << item
+ end
+ end
+
+ return raw_items
+ end
+
+ def self.extractor_name
+ return {{@type.name}}
+ end
+ end
+
+ # Extracts items from the InnerTube response for search results
+ #
+ # Search results are typically stored under "twoColumnSearchResultsRenderer"
+ # and is structured like this:
+ #
+ # "twoColumnSearchResultsRenderer": {
+ # {"primaryContents": {
+ # {"sectionListRenderer": {
+ # "contents": [...],
+ # ...,
+ # "subMenu": {...},
+ # "hideBottomSeparator": true,
+ # "targetId": "search-feed"
+ # }}
+ # }}
+ # }
+ #
+ module SearchResults
+ def self.process(initial_data : Hash(String, JSON::Any))
+ if target = initial_data["twoColumnSearchResultsRenderer"]?
+ self.extract(target)
+ end
+ end
+
+ private def self.extract(target)
+ raw_items = [] of Array(JSON::Any)
+
+ target.dig("primaryContents", "sectionListRenderer", "contents").as_a.each do |node|
+ if node = node["itemSectionRenderer"]?
+ raw_items << node["contents"].as_a
+ end
+ end
+
+ return raw_items.flatten
+ end
+
+ def self.extractor_name
+ return {{@type.name}}
+ end
+ end
+
+ # Extracts continuation items from a InnerTube response
+ #
+ # Continuation items (on YouTube) are items which are appended to the
+ # end of the page for continuous scrolling. As such, in many cases,
+ # the items are lacking information such as author or category title,
+ # since the original results has already rendered them on the top of the page.
+ #
+ # The way they are structured is too varied to be accurately written down here.
+ # However, they all eventually lead to an array of parsable items after traversing
+ # through the JSON structure.
+ module Continuation
+ def self.process(initial_data : Hash(String, JSON::Any))
+ if target = initial_data["continuationContents"]?
+ self.extract(target)
+ elsif target = initial_data["appendContinuationItemsAction"]?
+ self.extract(target)
+ end
+ end
+
+ private def self.extract(target)
+ raw_items = [] of JSON::Any
+ if content = target["gridContinuation"]?
+ raw_items = content["items"].as_a
+ elsif content = target["continuationItems"]?
+ raw_items = content.as_a
+ end
+
+ return raw_items
+ end
+
+ def self.extractor_name
+ return {{@type.name}}
+ end
+ end
+end
+
+# Helper methods to aid in the parsing of InnerTube to data structs.
+#
+# Mostly used to extract out repeated structures to deal with code
+# repetition.
+private module HelperExtractors
+ # Retrieves the amount of videos present within the given InnerTube data.
+ #
+ # Returns a 0 when it's unable to do so
+ def self.get_video_count(container : JSON::Any) : Int32
+ if box = container["videoCountText"]?
+ return extract_text(box).try &.gsub(/\D/, "").to_i || 0
+ elsif box = container["videoCount"]?
+ return box.as_s.to_i
+ else
+ return 0
+ end
+ end
+
+ # Retrieve lowest quality thumbnail from InnerTube data
+ #
+ # TODO allow configuration of image quality (-1 is highest)
+ #
+ # Raises when it's unable to parse from the given JSON data.
+ def self.get_thumbnails(container : JSON::Any) : String
+ return container.dig("thumbnail", "thumbnails", 0, "url").as_s
+ end
+
+ # ditto
+ #
+ # YouTube sometimes sends the thumbnail as:
+ # {"thumbnails": [{"thumbnails": [{"url": "example.com"}, ...]}]}
+ def self.get_thumbnails_plural(container : JSON::Any) : String
+ return container.dig("thumbnails", 0, "thumbnails", 0, "url").as_s
+ end
+
+ # Retrieves the ID required for querying the InnerTube browse endpoint.
+ # Raises when it's unable to do so
+ def self.get_browse_id(container)
+ return container.dig("navigationEndpoint", "browseEndpoint", "browseId").as_s
+ end
+end
+
+# Extracts text from InnerTube response
+#
+# InnerTube can package text in three different formats
+# "runs": [
+# {"text": "something"},
+# {"text": "cont"},
+# ...
+# ]
+#
+# "SimpleText": "something"
+#
+# Or sometimes just none at all as with the data returned from
+# category continuations.
+#
+# In order to facilitate calling this function with `#[]?`:
+# A nil will be accepted. Of course, since nil cannot be parsed,
+# another nil will be returned.
+def extract_text(item : JSON::Any?) : String?
+ if item.nil?
+ return nil
+ end
+
+ if text_container = item["simpleText"]?
+ return text_container.as_s
+ elsif text_container = item["runs"]?
+ return text_container.as_a.map(&.["text"].as_s).join("")
+ else
+ nil
+ end
+end
+
+# Parses an item from Youtube's JSON response into a more usable structure.
+# The end result can either be a SearchVideo, SearchPlaylist or SearchChannel.
+def extract_item(item : JSON::Any, author_fallback : String? = "",
+ author_id_fallback : String? = "")
+ # We "allow" nil values but secretly use empty strings instead. This is to save us the
+ # hassle of modifying every author_fallback and author_id_fallback arg usage
+ # which is more often than not nil.
+ author_fallback = AuthorFallback.new(author_fallback || "", author_id_fallback || "")
+
+ # Cycles through all of the item parsers and attempt to parse the raw YT JSON data.
+ # Each parser automatically validates the data given to see if the data is
+ # applicable to itself. If not nil is returned and the next parser is attemped.
+ ITEM_PARSERS.each do |parser|
+ LOGGER.trace("extract_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)")
+
+ if result = parser.process(item, author_fallback)
+ LOGGER.debug("extract_item: Successfully parsed via #{parser.parser_name}")
+
+ return result
+ else
+ LOGGER.trace("extract_item: Parser \"#{parser.parser_name}\" does not apply. Cycling to the next one...")
+ end
+ end
+end
+
+# Parses multiple items from YouTube's initial JSON response into a more usable structure.
+# The end result is an array of SearchItem.
+def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil,
+ author_id_fallback : String? = nil) : Array(SearchItem)
+ items = [] of SearchItem
+
+ if unpackaged_data = initial_data["contents"]?.try &.as_h
+ elsif unpackaged_data = initial_data["response"]?.try &.as_h
+ elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 0).try &.as_h
+ else
+ unpackaged_data = initial_data
+ end
+
+ # This is identical to the parser cycling of extract_item().
+ ITEM_CONTAINER_EXTRACTOR.each do |extractor|
+ LOGGER.trace("extract_items: Attempting to extract item container using \"#{extractor.extractor_name}\" (cycling...)")
+
+ if container = extractor.process(unpackaged_data)
+ LOGGER.debug("extract_items: Successfully unpacked container with \"#{extractor.extractor_name}\"")
+ # Extract items in container
+ container.each do |item|
+ if parsed_result = extract_item(item, author_fallback, author_id_fallback)
+ items << parsed_result
+ end
+ end
+
+ break
+ else
+ LOGGER.trace("extract_items: Extractor \"#{extractor.extractor_name}\" does not apply. Cycling to the next one...")
+ end
+ end
+
+ return items
+end
diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr
index fb33df1c..968062d6 100644
--- a/src/invidious/helpers/helpers.cr
+++ b/src/invidious/helpers/helpers.cr
@@ -97,6 +97,10 @@ class Config
property hsts : Bool? = true # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely
property disable_proxy : Bool? | Array(String)? = false # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local'
+ # URL to the modified source code to be easily AGPL compliant
+ # Will display in the footer, next to the main source code link
+ property modified_source_code_url : String? = nil
+
@[YAML::Field(converter: Preferences::FamilyConverter)]
property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729)
property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument)
@@ -248,168 +252,40 @@ def html_to_content(description_html : String)
end
def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil)
- extract_items(initial_data, author_fallback, author_id_fallback).select(&.is_a?(SearchVideo)).map(&.as(SearchVideo))
-end
+ extracted = extract_items(initial_data, author_fallback, author_id_fallback)
-def extract_item(item : JSON::Any, author_fallback : String? = nil, author_id_fallback : String? = nil)
- if i = (item["videoRenderer"]? || item["gridVideoRenderer"]?)
- video_id = i["videoId"].as_s
- title = i["title"].try { |t| t["simpleText"]?.try &.as_s || t["runs"]?.try &.as_a.map(&.["text"].as_s).join("") } || ""
-
- author_info = i["ownerText"]?.try &.["runs"]?.try &.as_a?.try &.[0]?
- author = author_info.try &.["text"].as_s || author_fallback || ""
- author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_id_fallback || ""
-
- published = i["publishedTimeText"]?.try &.["simpleText"]?.try { |t| decode_date(t.as_s) } || Time.local
- view_count = i["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64
- description_html = i["descriptionSnippet"]?.try { |t| parse_content(t) } || ""
- length_seconds = i["lengthText"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } ||
- i["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?).try &.["thumbnailOverlayTimeStatusRenderer"]?
- .try &.["text"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || 0
-
- live_now = false
- premium = false
-
- premiere_timestamp = i["upcomingEventData"]?.try &.["startTime"]?.try { |t| Time.unix(t.as_s.to_i64) }
-
- i["badges"]?.try &.as_a.each do |badge|
- b = badge["metadataBadgeRenderer"]
- case b["label"].as_s
- when "LIVE NOW"
- live_now = true
- when "New", "4K", "CC"
- # TODO
- when "Premium"
- # TODO: Potentially available as i["topStandaloneBadge"]["metadataBadgeRenderer"]
- premium = true
- else nil # Ignore
- end
+ target = [] of SearchItem
+ extracted.each do |i|
+ if i.is_a?(Category)
+ i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video }
+ else
+ target << i
end
-
- SearchVideo.new({
- title: title,
- id: video_id,
- author: author,
- ucid: author_id,
- published: published,
- views: view_count,
- description_html: description_html,
- length_seconds: length_seconds,
- live_now: live_now,
- premium: premium,
- premiere_timestamp: premiere_timestamp,
- })
- elsif i = item["channelRenderer"]?
- author = i["title"]["simpleText"]?.try &.as_s || author_fallback || ""
- author_id = i["channelId"]?.try &.as_s || author_id_fallback || ""
-
- author_thumbnail = i["thumbnail"]["thumbnails"]?.try &.as_a[0]?.try &.["url"]?.try &.as_s || ""
- subscriber_count = i["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s.try { |s| short_text_to_number(s.split(" ")[0]) } || 0
-
- auto_generated = false
- auto_generated = true if !i["videoCountText"]?
- video_count = i["videoCountText"]?.try &.["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0
- description_html = i["descriptionSnippet"]?.try { |t| parse_content(t) } || ""
-
- SearchChannel.new({
- author: author,
- ucid: author_id,
- author_thumbnail: author_thumbnail,
- subscriber_count: subscriber_count,
- video_count: video_count,
- description_html: description_html,
- auto_generated: auto_generated,
- })
- elsif i = item["gridPlaylistRenderer"]?
- title = i["title"]["runs"].as_a[0]?.try &.["text"].as_s || ""
- plid = i["playlistId"]?.try &.as_s || ""
-
- video_count = i["videoCountText"]["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0
- playlist_thumbnail = i["thumbnail"]["thumbnails"][0]?.try &.["url"]?.try &.as_s || ""
-
- SearchPlaylist.new({
- title: title,
- id: plid,
- author: author_fallback || "",
- ucid: author_id_fallback || "",
- video_count: video_count,
- videos: [] of SearchPlaylistVideo,
- thumbnail: playlist_thumbnail,
- })
- elsif i = item["playlistRenderer"]?
- title = i["title"]["simpleText"]?.try &.as_s || ""
- plid = i["playlistId"]?.try &.as_s || ""
-
- video_count = i["videoCount"]?.try &.as_s.to_i || 0
- playlist_thumbnail = i["thumbnails"].as_a[0]?.try &.["thumbnails"]?.try &.as_a[0]?.try &.["url"].as_s || ""
-
- author_info = i["shortBylineText"]?.try &.["runs"]?.try &.as_a?.try &.[0]?
- author = author_info.try &.["text"].as_s || author_fallback || ""
- author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_id_fallback || ""
-
- videos = i["videos"]?.try &.as_a.map do |v|
- v = v["childVideoRenderer"]
- v_title = v["title"]["simpleText"]?.try &.as_s || ""
- v_id = v["videoId"]?.try &.as_s || ""
- v_length_seconds = v["lengthText"]?.try &.["simpleText"]?.try { |t| decode_length_seconds(t.as_s) } || 0
- SearchPlaylistVideo.new({
- title: v_title,
- id: v_id,
- length_seconds: v_length_seconds,
- })
- end || [] of SearchPlaylistVideo
-
- # TODO: i["publishedTimeText"]?
-
- SearchPlaylist.new({
- title: title,
- id: plid,
- author: author,
- ucid: author_id,
- video_count: video_count,
- videos: videos,
- thumbnail: playlist_thumbnail,
- })
- elsif i = item["radioRenderer"]? # Mix
- # TODO
- elsif i = item["showRenderer"]? # Show
- # TODO
- elsif i = item["shelfRenderer"]?
- elsif i = item["horizontalCardListRenderer"]?
- elsif i = item["searchPyvRenderer"]? # Ad
end
+ return target.select(&.is_a?(SearchVideo)).map(&.as(SearchVideo))
end
-def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil)
- items = [] of SearchItem
+def extract_selected_tab(tabs)
+ # Extract the selected tab from the array of tabs Youtube returns
+ return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]["tabRenderer"]
+end
- channel_v2_response = initial_data
- .try &.["continuationContents"]?
- .try &.["gridContinuation"]?
- .try &.["items"]?
+def fetch_continuation_token(items : Array(JSON::Any))
+ # Fetches the continuation token from an array of items
+ return items.last["continuationItemRenderer"]?
+ .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s
+end
- if channel_v2_response
- channel_v2_response.try &.as_a.each { |item|
- extract_item(item, author_fallback, author_id_fallback)
- .try { |t| items << t }
- }
+def fetch_continuation_token(initial_data : Hash(String, JSON::Any))
+ # Fetches the continuation token from initial data
+ if initial_data["onResponseReceivedActions"]?
+ continuation_items = initial_data["onResponseReceivedActions"][0]["appendContinuationItemsAction"]["continuationItems"]
else
- initial_data.try { |t| t["contents"]? || t["response"]? }
- .try { |t| t["twoColumnBrowseResultsRenderer"]?.try &.["tabs"].as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]?.try &.["tabRenderer"]["content"] ||
- t["twoColumnSearchResultsRenderer"]?.try &.["primaryContents"] ||
- t["continuationContents"]? }
- .try { |t| t["sectionListRenderer"]? || t["sectionListContinuation"]? }
- .try &.["contents"].as_a
- .each { |c| c.try &.["itemSectionRenderer"]?.try &.["contents"].as_a
- .try { |t| t[0]?.try &.["shelfRenderer"]?.try &.["content"]["expandedShelfContentsRenderer"]?.try &.["items"].as_a ||
- t[0]?.try &.["gridRenderer"]?.try &.["items"].as_a || t }
- .each { |item|
- extract_item(item, author_fallback, author_id_fallback)
- .try { |t| items << t }
- } }
+ tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"])
+ continuation_items = tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["gridRenderer"]["items"]
end
- items
+ return fetch_continuation_token(continuation_items.as_a)
end
def check_enum(db, enum_name, struct_type = nil)
diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr
index 7ffdfdcc..2ed4f150 100644
--- a/src/invidious/helpers/i18n.cr
+++ b/src/invidious/helpers/i18n.cr
@@ -1,43 +1,44 @@
+# "bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh) [Incomplete]
+# "eu" => load_locale("eu"), # Basque [Incomplete]
+# "si" => load_locale("si"), # Sinhala [Incomplete]
+# "sk" => load_locale("sk"), # Slovak [Incomplete]
+# "sr" => load_locale("sr"), # Serbian [Incomplete]
+# "sr_Cyrl" => load_locale("sr_Cyrl"), # Serbian (cyrillic) [Incomplete]
LOCALES = {
- "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
- "ko" => load_locale("ko"), # Korean
- "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)
+ "ar" => load_locale("ar"), # Arabic
+ "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
+ "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
+ "ko" => load_locale("ko"), # Korean
+ "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" => load_locale("pt"), # Portuguese
+ "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
+ "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/logger.cr b/src/invidious/helpers/logger.cr
index 5d91a258..e2e50905 100644
--- a/src/invidious/helpers/logger.cr
+++ b/src/invidious/helpers/logger.cr
@@ -17,7 +17,19 @@ class Invidious::LogHandler < Kemal::BaseLogHandler
elapsed_time = Time.measure { call_next(context) }
elapsed_text = elapsed_text(elapsed_time)
- info("#{context.response.status_code} #{context.request.method} #{context.request.resource} #{elapsed_text}")
+ # Default: full path with parameters
+ requested_url = context.request.resource
+
+ # Try not to log search queries passed as GET parameters during normal use
+ # (They will still be logged if log level is 'Debug' or 'Trace')
+ if @level > LogLevel::Debug && (
+ requested_url.downcase.includes?("search") || requested_url.downcase.includes?("q=")
+ )
+ # Log only the path
+ requested_url = context.request.path
+ end
+
+ info("#{context.response.status_code} #{context.request.method} #{requested_url} #{elapsed_text}")
context
end
diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr
new file mode 100644
index 00000000..a9798f0c
--- /dev/null
+++ b/src/invidious/helpers/serialized_yt_data.cr
@@ -0,0 +1,263 @@
+struct SearchVideo
+ include DB::Serializable
+
+ property title : String
+ property id : String
+ property author : String
+ property ucid : String
+ property published : Time
+ property views : Int64
+ property description_html : String
+ property length_seconds : Int32
+ property live_now : Bool
+ property premium : Bool
+ property premiere_timestamp : Time?
+
+ def to_xml(auto_generated, 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
+ if auto_generated
+ xml.element("name") { xml.text self.author }
+ xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" }
+ else
+ xml.element("name") { xml.text author }
+ xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" }
+ end
+ 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
+
+ xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text html_to_content(self.description_html) }
+ end
+ end
+
+ xml.element("published") { xml.text self.published.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")
+ xml.element("media:description") { xml.text html_to_content(self.description_html) }
+ end
+
+ xml.element("media:community") do
+ xml.element("media:statistics", views: self.views)
+ end
+ end
+ end
+
+ def to_xml(auto_generated, query_params, xml : XML::Builder | Nil = nil)
+ if xml
+ to_xml(HOST_URL, auto_generated, query_params, xml)
+ else
+ XML.build do |json|
+ to_xml(HOST_URL, auto_generated, query_params, xml)
+ end
+ end
+ end
+
+ def to_json(locale : Hash(String, JSON::Any), json : JSON::Builder)
+ json.object do
+ json.field "type", "video"
+ json.field "title", self.title
+ json.field "videoId", self.id
+
+ json.field "author", self.author
+ json.field "authorId", self.ucid
+ json.field "authorUrl", "/channel/#{self.ucid}"
+
+ json.field "videoThumbnails" do
+ generate_thumbnails(json, self.id)
+ end
+
+ json.field "description", html_to_content(self.description_html)
+ json.field "descriptionHtml", self.description_html
+
+ json.field "viewCount", self.views
+ json.field "published", self.published.to_unix
+ json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
+ json.field "lengthSeconds", self.length_seconds
+ json.field "liveNow", self.live_now
+ json.field "premium", self.premium
+ json.field "isUpcoming", self.is_upcoming
+
+ if self.premiere_timestamp
+ json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix
+ end
+ 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 is_upcoming
+ premiere_timestamp ? true : false
+ end
+end
+
+struct SearchPlaylistVideo
+ include DB::Serializable
+
+ property title : String
+ property id : String
+ property length_seconds : Int32
+end
+
+struct SearchPlaylist
+ include DB::Serializable
+
+ property title : String
+ property id : String
+ property author : String
+ property ucid : String
+ property video_count : Int32
+ property videos : Array(SearchPlaylistVideo)
+ property thumbnail : String?
+
+ def to_json(locale, json : JSON::Builder)
+ json.object do
+ json.field "type", "playlist"
+ json.field "title", self.title
+ json.field "playlistId", self.id
+ json.field "playlistThumbnail", self.thumbnail
+
+ json.field "author", self.author
+ json.field "authorId", self.ucid
+ json.field "authorUrl", "/channel/#{self.ucid}"
+
+ json.field "videoCount", self.video_count
+ json.field "videos" do
+ json.array do
+ self.videos.each do |video|
+ json.object do
+ json.field "title", video.title
+ json.field "videoId", video.id
+ json.field "lengthSeconds", video.length_seconds
+
+ json.field "videoThumbnails" do
+ generate_thumbnails(json, video.id)
+ end
+ end
+ end
+ end
+ end
+ 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
+end
+
+struct SearchChannel
+ include DB::Serializable
+
+ property author : String
+ property ucid : String
+ property author_thumbnail : String
+ property subscriber_count : Int32
+ property video_count : Int32
+ property description_html : String
+ property auto_generated : Bool
+
+ def to_json(locale, json : JSON::Builder)
+ json.object do
+ json.field "type", "channel"
+ json.field "author", self.author
+ json.field "authorId", self.ucid
+ json.field "authorUrl", "/channel/#{self.ucid}"
+
+ json.field "authorThumbnails" do
+ json.array do
+ qualities = {32, 48, 76, 100, 176, 512}
+
+ qualities.each do |quality|
+ json.object do
+ json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}")
+ json.field "width", quality
+ json.field "height", quality
+ end
+ end
+ end
+ end
+
+ json.field "autoGenerated", self.auto_generated
+ json.field "subCount", self.subscriber_count
+ json.field "videoCount", self.video_count
+
+ json.field "description", html_to_content(self.description_html)
+ json.field "descriptionHtml", self.description_html
+ 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
+end
+
+class Category
+ include DB::Serializable
+
+ property title : String
+ property contents : Array(SearchItem) | Array(Video)
+ property url : String?
+ property description_html : String
+ property badges : Array(Tuple(String, String))?
+
+ def to_json(locale, json : JSON::Builder)
+ json.object do
+ json.field "type", "category"
+ json.field "title", self.title
+ json.field "contents" do
+ json.array do
+ self.contents.each do |item|
+ item.to_json(locale, json)
+ end
+ end
+ end
+ 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
+end
+
+alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | Category
diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr
index 6ee07d7a..68ba76f9 100644
--- a/src/invidious/helpers/utils.cr
+++ b/src/invidious/helpers/utils.cr
@@ -412,7 +412,7 @@ end
def fetch_random_instance
begin
- instance_api_client = HTTP::Client.new(URI.parse("https://api.invidious.io"))
+ instance_api_client = make_client(URI.parse("https://api.invidious.io"))
# Timeouts
instance_api_client.connect_timeout = 10.seconds
diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr
index f56cc2ea..5034844e 100644
--- a/src/invidious/playlists.cr
+++ b/src/invidious/playlists.cr
@@ -107,7 +107,7 @@ struct Playlist
property updated : Time
property thumbnail : String?
- def to_json(offset, locale, json : JSON::Builder, continuation : String? = nil)
+ def to_json(offset, locale, json : JSON::Builder, video_id : String? = nil)
json.object do
json.field "type", "playlist"
json.field "title", self.title
@@ -142,7 +142,7 @@ struct Playlist
json.field "videos" do
json.array do
- videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation)
+ videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, video_id: video_id)
videos.each_with_index do |video, index|
video.to_json(locale, json)
end
@@ -151,12 +151,12 @@ struct Playlist
end
end
- def to_json(offset, locale, json : JSON::Builder? = nil, continuation : String? = nil)
+ def to_json(offset, locale, json : JSON::Builder? = nil, video_id : String? = nil)
if json
- to_json(offset, locale, json, continuation: continuation)
+ to_json(offset, locale, json, video_id: video_id)
else
JSON.build do |json|
- to_json(offset, locale, json, continuation: continuation)
+ to_json(offset, locale, json, video_id: video_id)
end
end
end
@@ -196,7 +196,7 @@ struct InvidiousPlaylist
end
end
- def to_json(offset, locale, json : JSON::Builder, continuation : String? = nil)
+ def to_json(offset, locale, json : JSON::Builder, video_id : String? = nil)
json.object do
json.field "type", "invidiousPlaylist"
json.field "title", self.title
@@ -218,11 +218,11 @@ struct InvidiousPlaylist
json.field "videos" do
json.array do
if !offset || offset == 0
- index = PG_DB.query_one?("SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1", self.id, continuation, as: Int64)
+ index = PG_DB.query_one?("SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1", self.id, video_id, as: Int64)
offset = self.index.index(index) || 0
end
- videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation)
+ videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, video_id: video_id)
videos.each_with_index do |video, index|
video.to_json(locale, json, offset + index)
end
@@ -231,12 +231,12 @@ struct InvidiousPlaylist
end
end
- def to_json(offset, locale, json : JSON::Builder? = nil, continuation : String? = nil)
+ def to_json(offset, locale, json : JSON::Builder? = nil, video_id : String? = nil)
if json
- to_json(offset, locale, json, continuation: continuation)
+ to_json(offset, locale, json, video_id: video_id)
else
JSON.build do |json|
- to_json(offset, locale, json, continuation: continuation)
+ to_json(offset, locale, json, video_id: video_id)
end
end
end
@@ -426,7 +426,7 @@ def fetch_playlist(plid, locale)
})
end
-def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil)
+def get_playlist_videos(db, playlist, offset, locale = nil, video_id = nil)
# Show empy playlist if requested page is out of range
# (e.g, when a new playlist has been created, offset will be negative)
if offset >= playlist.video_count || offset < 0
@@ -437,17 +437,26 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil)
db.query_all("SELECT * FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 100 OFFSET $3",
playlist.id, playlist.index, offset, as: PlaylistVideo)
else
- if offset >= 100
- # Normalize offset to match youtube's behavior (100 videos chunck per request)
- offset = (offset / 100).to_i64 * 100_i64
+ if video_id
+ initial_data = YoutubeAPI.next({
+ "videoId" => video_id,
+ "playlistId" => playlist.id,
+ })
+ offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "playlist", "playlist", "currentIndex").try &.as_i || offset
+ end
+
+ videos = [] of PlaylistVideo
+ until videos.size >= 200 || videos.size == playlist.video_count || offset >= playlist.video_count
+ # 100 videos per request
ctoken = produce_playlist_continuation(playlist.id, offset)
initial_data = YoutubeAPI.browse(ctoken)
- else
- initial_data = YoutubeAPI.browse("VL" + playlist.id, params: "")
+ videos += extract_playlist_videos(initial_data)
+
+ offset += 100
end
- return extract_playlist_videos(initial_data)
+ return videos
end
end
@@ -523,8 +532,8 @@ def template_playlist(playlist)
playlist["videos"].as_a.each do |video|
html += <<-END_HTML
- <li class="pure-menu-item">
- <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}">
+ <li class="pure-menu-item" id="#{video["videoId"]}">
+ <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}">
<div class="thumbnail">
<img class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg">
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr
index 93bee55c..f8963587 100644
--- a/src/invidious/routes/api/manifest.cr
+++ b/src/invidious/routes/api/manifest.cr
@@ -160,7 +160,7 @@ module Invidious::Routes::API::Manifest
manifest = response.body
if local
- manifest = manifest.gsub(/^https:\/\/r\d---.{11}\.c\.youtube\.com[^\n]*/m) do |match|
+ manifest = manifest.gsub(/^https:\/\/\w+---.{11}\.c\.youtube\.com[^\n]*/m) do |match|
path = URI.parse(match).path
path = path.lchop("/videoplayback/")
diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr
index cf95bd9b..80b59fd5 100644
--- a/src/invidious/routes/api/v1/misc.cr
+++ b/src/invidious/routes/api/v1/misc.cr
@@ -24,7 +24,7 @@ module Invidious::Routes::API::V1::Misc
offset ||= env.params.query["page"]?.try &.to_i?.try { |page| (page - 1) * 100 }
offset ||= 0
- continuation = env.params.query["continuation"]?
+ video_id = env.params.query["continuation"]?
format = env.params.query["format"]?
format ||= "json"
@@ -46,12 +46,32 @@ module Invidious::Routes::API::V1::Misc
return error_json(404, "Playlist does not exist.")
end
- response = playlist.to_json(offset, locale, continuation: continuation)
+ # includes into the playlist a maximum of 20 videos, before the offset
+ if offset > 0
+ lookback = offset < 50 ? offset : 50
+ response = playlist.to_json(offset - lookback, locale)
+ json_response = JSON.parse(response)
+ else
+ # Unless the continuation is really the offset 0, it becomes expensive.
+ # It happens when the offset is not set.
+ # First we find the actual offset, and then we lookback
+ # it shouldn't happen often though
+
+ lookback = 0
+ response = playlist.to_json(offset, locale, video_id: video_id)
+ json_response = JSON.parse(response)
+
+ if json_response["videos"].as_a[0]["index"] != offset
+ offset = json_response["videos"].as_a[0]["index"].as_i
+ lookback = offset < 50 ? offset : 50
+ response = playlist.to_json(offset - lookback, locale)
+ json_response = JSON.parse(response)
+ end
+ end
if format == "html"
- response = JSON.parse(response)
- playlist_html = template_playlist(response)
- index, next_video = response["videos"].as_a.skip(1).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil}
+ playlist_html = template_playlist(json_response)
+ index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil}
response = {
"playlistHtml" => playlist_html,
diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr
index 6a32988e..11c2f869 100644
--- a/src/invidious/routes/channels.cr
+++ b/src/invidious/routes/channels.cr
@@ -1,3 +1,5 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Channels
def self.home(env)
self.videos(env)
diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr
index 5fc8a61f..80d09789 100644
--- a/src/invidious/routes/embed.cr
+++ b/src/invidious/routes/embed.cr
@@ -1,3 +1,5 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Embed
def self.redirect(env)
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr
index c88e96cf..d9280529 100644
--- a/src/invidious/routes/feeds.cr
+++ b/src/invidious/routes/feeds.cr
@@ -1,3 +1,5 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Feeds
def self.view_all_playlists_redirect(env)
env.redirect "/feed/playlists"
diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr
new file mode 100644
index 00000000..bb924cdf
--- /dev/null
+++ b/src/invidious/routes/images.cr
@@ -0,0 +1,191 @@
+module Invidious::Routes::Images
+ # Avatars, banners and other large image assets.
+ def self.ggpht(env)
+ url = env.request.path.lchop("/ggpht")
+
+ headers = HTTP::Headers{":authority" => "yt3.ggpht.com"}
+ REQUEST_HEADERS_WHITELIST.each do |header|
+ if env.request.headers[header]?
+ headers[header] = env.request.headers[header]
+ end
+ end
+
+ begin
+ YT_POOL.client &.get(url, headers) do |response|
+ env.response.status_code = response.status_code
+ response.headers.each do |key, value|
+ if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
+ env.response.headers[key] = value
+ end
+ end
+
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
+
+ if response.status_code >= 300
+ env.response.headers.delete("Transfer-Encoding")
+ break
+ end
+
+ proxy_file(response, env)
+ end
+ rescue ex
+ end
+ end
+
+ def self.options_storyboard(env)
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
+ env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS"
+ env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range"
+ end
+
+ def self.get_storyboard(env)
+ authority = env.params.url["authority"]
+ id = env.params.url["id"]
+ storyboard = env.params.url["storyboard"]
+ index = env.params.url["index"]
+
+ url = "/sb/#{id}/#{storyboard}/#{index}?#{env.params.query}"
+
+ headers = HTTP::Headers.new
+
+ headers[":authority"] = "#{authority}.ytimg.com"
+
+ REQUEST_HEADERS_WHITELIST.each do |header|
+ if env.request.headers[header]?
+ headers[header] = env.request.headers[header]
+ end
+ end
+
+ begin
+ YT_POOL.client &.get(url, headers) do |response|
+ env.response.status_code = response.status_code
+ response.headers.each do |key, value|
+ if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
+ env.response.headers[key] = value
+ end
+ end
+
+ env.response.headers["Connection"] = "close"
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
+
+ if response.status_code >= 300
+ env.response.headers.delete("Transfer-Encoding")
+ break
+ end
+
+ proxy_file(response, env)
+ end
+ rescue ex
+ end
+ end
+
+ # ??? maybe also for storyboards?
+ def self.s_p_image(env)
+ id = env.params.url["id"]
+ name = env.params.url["name"]
+
+ url = env.request.resource
+
+ headers = HTTP::Headers{":authority" => "i9.ytimg.com"}
+ REQUEST_HEADERS_WHITELIST.each do |header|
+ if env.request.headers[header]?
+ headers[header] = env.request.headers[header]
+ end
+ end
+
+ begin
+ YT_POOL.client &.get(url, headers) do |response|
+ env.response.status_code = response.status_code
+ response.headers.each do |key, value|
+ if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
+ env.response.headers[key] = value
+ end
+ end
+
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
+
+ if response.status_code >= 300 && response.status_code != 404
+ env.response.headers.delete("Transfer-Encoding")
+ break
+ end
+
+ proxy_file(response, env)
+ end
+ rescue ex
+ end
+ end
+
+ def self.yts_image(env)
+ headers = HTTP::Headers.new
+ REQUEST_HEADERS_WHITELIST.each do |header|
+ if env.request.headers[header]?
+ headers[header] = env.request.headers[header]
+ end
+ end
+
+ begin
+ YT_POOL.client &.get(env.request.resource, headers) do |response|
+ env.response.status_code = response.status_code
+ response.headers.each do |key, value|
+ if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
+ env.response.headers[key] = value
+ end
+ end
+
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
+
+ if response.status_code >= 300 && response.status_code != 404
+ env.response.headers.delete("Transfer-Encoding")
+ break
+ end
+
+ proxy_file(response, env)
+ end
+ rescue ex
+ end
+ end
+
+ def self.thumbnails(env)
+ id = env.params.url["id"]
+ name = env.params.url["name"]
+
+ headers = HTTP::Headers{":authority" => "i.ytimg.com"}
+
+ if name == "maxres.jpg"
+ build_thumbnails(id).each do |thumb|
+ if YT_POOL.client &.head("/vi/#{id}/#{thumb[:url]}.jpg", headers).status_code == 200
+ name = thumb[:url] + ".jpg"
+ break
+ end
+ end
+ end
+ url = "/vi/#{id}/#{name}"
+
+ REQUEST_HEADERS_WHITELIST.each do |header|
+ if env.request.headers[header]?
+ headers[header] = env.request.headers[header]
+ end
+ end
+
+ begin
+ YT_POOL.client &.get(url, headers) do |response|
+ env.response.status_code = response.status_code
+ response.headers.each do |key, value|
+ if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
+ env.response.headers[key] = value
+ end
+ end
+
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
+
+ if response.status_code >= 300 && response.status_code != 404
+ env.response.headers.delete("Transfer-Encoding")
+ break
+ end
+
+ proxy_file(response, env)
+ end
+ rescue ex
+ end
+ end
+end
diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr
index f052d3f4..e7aef289 100644
--- a/src/invidious/routes/login.cr
+++ b/src/invidious/routes/login.cr
@@ -1,3 +1,5 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Login
def self.login_page(env)
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr
index 82c40a95..0e6356d0 100644
--- a/src/invidious/routes/misc.cr
+++ b/src/invidious/routes/misc.cr
@@ -1,3 +1,5 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Misc
def self.home(env)
preferences = env.get("preferences").as(Preferences)
diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr
index 05a198d8..5ab15093 100644
--- a/src/invidious/routes/playlists.cr
+++ b/src/invidious/routes/playlists.cr
@@ -1,3 +1,5 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Playlists
def self.new(env)
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr
index 0f26ec15..ae5407dc 100644
--- a/src/invidious/routes/preferences.cr
+++ b/src/invidious/routes/preferences.cr
@@ -1,3 +1,5 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::PreferencesRoute
def self.show(env)
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
@@ -198,6 +200,8 @@ module Invidious::Routes::PreferencesRoute
statistics_enabled ||= "off"
CONFIG.statistics_enabled = statistics_enabled == "on"
+ CONFIG.modified_source_code_url = env.params.body["modified_source_code_url"]?.try &.as(String)
+
File.write("config/config.yml", CONFIG.to_yaml)
end
else
diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr
index 610d5031..3f1e219f 100644
--- a/src/invidious/routes/search.cr
+++ b/src/invidious/routes/search.cr
@@ -1,3 +1,5 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Search
def self.opensearch(env)
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr
index f07b1358..2db133ee 100644
--- a/src/invidious/routes/watch.cr
+++ b/src/invidious/routes/watch.cr
@@ -1,3 +1,5 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Watch
def self.handle(env)
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr
index e0cddeb5..7551f22d 100644
--- a/src/invidious/routing.cr
+++ b/src/invidious/routing.cr
@@ -73,7 +73,7 @@ macro define_v1_api_routes
Invidious::Routing.get "/api/v1/stats", {{namespace}}::Misc, :stats
Invidious::Routing.get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist
Invidious::Routing.get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist
- Invidious::Routing.get "/api/v1//mixes/:rdid", {{namespace}}::Misc, :mixes
+ Invidious::Routing.get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes
end
macro define_api_manifest_routes
diff --git a/src/invidious/search.cr b/src/invidious/search.cr
index a3fcc7a3..d95d802e 100644
--- a/src/invidious/search.cr
+++ b/src/invidious/search.cr
@@ -1,233 +1,3 @@
-struct SearchVideo
- include DB::Serializable
-
- property title : String
- property id : String
- property author : String
- property ucid : String
- property published : Time
- property views : Int64
- property description_html : String
- property length_seconds : Int32
- property live_now : Bool
- property premium : Bool
- property premiere_timestamp : Time?
-
- def to_xml(auto_generated, 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
- if auto_generated
- xml.element("name") { xml.text self.author }
- xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" }
- else
- xml.element("name") { xml.text author }
- xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" }
- end
- 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
-
- xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text html_to_content(self.description_html) }
- end
- end
-
- xml.element("published") { xml.text self.published.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")
- xml.element("media:description") { xml.text html_to_content(self.description_html) }
- end
-
- xml.element("media:community") do
- xml.element("media:statistics", views: self.views)
- end
- end
- end
-
- def to_xml(auto_generated, query_params, xml : XML::Builder | Nil = nil)
- if xml
- to_xml(HOST_URL, auto_generated, query_params, xml)
- else
- XML.build do |json|
- to_xml(HOST_URL, auto_generated, query_params, xml)
- end
- end
- end
-
- def to_json(locale, json : JSON::Builder)
- json.object do
- json.field "type", "video"
- json.field "title", self.title
- json.field "videoId", self.id
-
- json.field "author", self.author
- json.field "authorId", self.ucid
- json.field "authorUrl", "/channel/#{self.ucid}"
-
- json.field "videoThumbnails" do
- generate_thumbnails(json, self.id)
- end
-
- json.field "description", html_to_content(self.description_html)
- json.field "descriptionHtml", self.description_html
-
- json.field "viewCount", self.views
- json.field "published", self.published.to_unix
- json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
- json.field "lengthSeconds", self.length_seconds
- json.field "liveNow", self.live_now
- json.field "premium", self.premium
- json.field "isUpcoming", self.is_upcoming
-
- if self.premiere_timestamp
- json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix
- end
- 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 is_upcoming
- premiere_timestamp ? true : false
- end
-end
-
-struct SearchPlaylistVideo
- include DB::Serializable
-
- property title : String
- property id : String
- property length_seconds : Int32
-end
-
-struct SearchPlaylist
- include DB::Serializable
-
- property title : String
- property id : String
- property author : String
- property ucid : String
- property video_count : Int32
- property videos : Array(SearchPlaylistVideo)
- property thumbnail : String?
-
- def to_json(locale, json : JSON::Builder)
- json.object do
- json.field "type", "playlist"
- json.field "title", self.title
- json.field "playlistId", self.id
- json.field "playlistThumbnail", self.thumbnail
-
- json.field "author", self.author
- json.field "authorId", self.ucid
- json.field "authorUrl", "/channel/#{self.ucid}"
-
- json.field "videoCount", self.video_count
- json.field "videos" do
- json.array do
- self.videos.each do |video|
- json.object do
- json.field "title", video.title
- json.field "videoId", video.id
- json.field "lengthSeconds", video.length_seconds
-
- json.field "videoThumbnails" do
- generate_thumbnails(json, video.id)
- end
- end
- end
- end
- end
- 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
-end
-
-struct SearchChannel
- include DB::Serializable
-
- property author : String
- property ucid : String
- property author_thumbnail : String
- property subscriber_count : Int32
- property video_count : Int32
- property description_html : String
- property auto_generated : Bool
-
- def to_json(locale, json : JSON::Builder)
- json.object do
- json.field "type", "channel"
- json.field "author", self.author
- json.field "authorId", self.ucid
- json.field "authorUrl", "/channel/#{self.ucid}"
-
- json.field "authorThumbnails" do
- json.array do
- qualities = {32, 48, 76, 100, 176, 512}
-
- qualities.each do |quality|
- json.object do
- json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}")
- json.field "width", quality
- json.field "height", quality
- end
- end
- end
- end
-
- json.field "autoGenerated", self.auto_generated
- json.field "subCount", self.subscriber_count
- json.field "videoCount", self.video_count
-
- json.field "description", html_to_content(self.description_html)
- json.field "descriptionHtml", self.description_html
- 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
-end
-
-alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist
-
def channel_search(query, page, channel)
response = YT_POOL.client &.get("/channel/#{channel}")
@@ -462,5 +232,20 @@ def process_search_query(query, page, user, region)
count, items = search(search_query, search_params, region).as(Tuple)
end
- {search_query, count, items, operators}
+ # Light processing to flatten search results out of Categories.
+ # They should ideally be supported in the future.
+ items_without_category = [] of SearchItem | ChannelVideo
+ items.each do |i|
+ if i.is_a? Category
+ i.contents.each do |nest_i|
+ if !nest_i.is_a? Video
+ items_without_category << nest_i
+ end
+ end
+ else
+ items_without_category << i
+ end
+ end
+
+ {search_query, items_without_category.size, items_without_category, operators}
end
diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr
index d9c07142..0e6bd77c 100644
--- a/src/invidious/videos.cr
+++ b/src/invidious/videos.cr
@@ -275,7 +275,7 @@ struct Video
end
end
- def to_json(locale, json : JSON::Builder)
+ def to_json(locale : Hash(String, JSON::Any), json : JSON::Builder)
json.object do
json.field "type", "video"
diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr
index 09eacbc8..c62861b0 100644
--- a/src/invidious/views/add_playlist_items.ecr
+++ b/src/invidious/views/add_playlist_items.ecr
@@ -41,7 +41,7 @@
<div class="pure-g h-box">
<div class="pure-u-1 pure-u-lg-1-5">
<% if page > 1 %>
- <a href="/add_playlist_items?list=<%= plid %>&q=<%= HTML.escape(query.not_nil!) %>&page=<%= page - 1 %>">
+ <a href="/add_playlist_items?list=<%= plid %>&q=<%= URI.encode_www_form(query.not_nil!) %>&page=<%= page - 1 %>">
<%= translate(locale, "Previous page") %>
</a>
<% end %>
@@ -49,7 +49,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="/add_playlist_items?list=<%= plid %>&q=<%= HTML.escape(query.not_nil!) %>&page=<%= page + 1 %>">
+ <a href="/add_playlist_items?list=<%= plid %>&q=<%= URI.encode_www_form(query.not_nil!) %>&page=<%= page + 1 %>">
<%= translate(locale, "Next page") %>
</a>
<% end %>
diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr
index 09cfb76e..7f797e37 100644
--- a/src/invidious/views/channel.ecr
+++ b/src/invidious/views/channel.ecr
@@ -96,7 +96,7 @@
<div class="pure-g h-box">
<div class="pure-u-1 pure-u-lg-1-5">
<% if page > 1 %>
- <a href="/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=<%= URI.encode_www_form(sort_by) %><% end %>">
<%= translate(locale, "Previous page") %>
</a>
<% end %>
@@ -104,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/<%= 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=<%= URI.encode_www_form(sort_by) %><% end %>">
<%= translate(locale, "Next page") %>
</a>
<% end %>
diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr
index 68aa1812..d084bfd4 100644
--- a/src/invidious/views/components/item.ecr
+++ b/src/invidious/views/components/item.ecr
@@ -48,7 +48,7 @@
<p dir="auto"><b><%= HTML.escape(item.author) %></b></p>
</a>
<% when PlaylistVideo %>
- <a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>">
+ <a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&index=<%= item.index %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
<img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
@@ -79,6 +79,19 @@
<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 %>&list=<%= item.plid %>">
+ <i class="icon ion-logo-youtube"></i>
+ </a>
+ <a title="<%=translate(locale, "Audio mode")%>" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&amp;listen=1">
+ <i class="icon ion-md-headset"></i>
+ </a>
+ <a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=<%=URI.encode_www_form("watch?v=#{item.id}&list=#{item.plid}")%>">
+ <i class="icon ion-md-jet"></i>
+ </a>
+ </div>
+ </div>
</div>
<div class="video-card-row flexible">
@@ -96,6 +109,7 @@
</div>
<% end %>
</div>
+ <% when Category %>
<% else %>
<a style="width:100%" href="/watch?v=<%= item.id %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
@@ -149,7 +163,7 @@
<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}")%>">
+ <a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=<%=URI.encode_www_form("watch?v=#{item.id}")%>">
<i class="icon ion-md-jet"></i>
</a>
</div>
diff --git a/src/invidious/views/login.ecr b/src/invidious/views/login.ecr
index 1f6618e8..e2963e9f 100644
--- a/src/invidious/views/login.ecr
+++ b/src/invidious/views/login.ecr
@@ -6,21 +6,6 @@
<div class="pure-u-1 pure-u-lg-1-5"></div>
<div class="pure-u-1 pure-u-lg-3-5">
<div class="h-box">
- <div class="pure-g">
- <div class="pure-u-1-2">
- <a class="pure-button <% if account_type == "invidious" %>pure-button-disabled<% end %>" href="/login?type=invidious">
- <%= translate(locale, "Log in/register") %>
- </a>
- </div>
- <div class="pure-u-1-2">
- <a class="pure-button <% if account_type == "google" %>pure-button-disabled<% end %>" href="/login?type=google">
- <%= translate(locale, "Log in with Google") %>
- </a>
- </div>
- </div>
-
- <hr>
-
<% case account_type when %>
<% when "google" %>
<form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=google" method="post">
diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr
index d9a17a9b..1245256f 100644
--- a/src/invidious/views/playlists.ecr
+++ b/src/invidious/views/playlists.ecr
@@ -96,7 +96,7 @@
<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/<%= 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=<%= URI.encode_www_form(sort_by) %><% end %>">
<%= translate(locale, "Next page") %>
</a>
<% end %>
diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr
index be021c59..401c15ea 100644
--- a/src/invidious/views/preferences.ecr
+++ b/src/invidious/views/preferences.ecr
@@ -286,6 +286,11 @@
<label for="statistics_enabled"><%= translate(locale, "Report statistics: ") %></label>
<input name="statistics_enabled" id="statistics_enabled" type="checkbox" <% if CONFIG.statistics_enabled %>checked<% end %>>
</div>
+
+ <div class="pure-control-group">
+ <label for="modified_source_code_url"><%= translate(locale, "adminprefs_modified_source_code_url_label") %></label>
+ <input name="modified_source_code_url" id="modified_source_code_url" type="input" <% if CONFIG.modified_source_code_url %>checked<% end %>>
+ </div>
<% end %>
<% if env.get? "user" %>
diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr
index fd176e41..db374548 100644
--- a/src/invidious/views/search.ecr
+++ b/src/invidious/views/search.ecr
@@ -2,7 +2,7 @@
<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_query_encoded = env.get?("search").try { |x| URI.encode_www_form(x.as(String), space_to_plus: true) } %>
<!-- Search redirection and filtering UI -->
<% if count == 0 %>
@@ -23,7 +23,7 @@
<% if operator_hash.fetch("date", "all") == date %>
<b><%= translate(locale, date) %></b>
<% else %>
- <a href="/search?q=<%= HTML.escape(query.not_nil!.gsub(/ ?date:[a-z]+/, "") + " date:" + date) %>&page=<%= page %>">
+ <a href="/search?q=<%= URI.encode_www_form(query.not_nil!.gsub(/ ?date:[a-z]+/, "") + " date:" + date) %>&page=<%= page %>">
<%= translate(locale, date) %>
</a>
<% end %>
@@ -38,7 +38,7 @@
<% if operator_hash.fetch("content_type", "all") == content_type %>
<b><%= translate(locale, content_type) %></b>
<% else %>
- <a href="/search?q=<%= HTML.escape(query.not_nil!.gsub(/ ?content_type:[a-z]+/, "") + " content_type:" + content_type) %>&page=<%= page %>">
+ <a href="/search?q=<%= URI.encode_www_form(query.not_nil!.gsub(/ ?content_type:[a-z]+/, "") + " content_type:" + content_type) %>&page=<%= page %>">
<%= translate(locale, content_type) %>
</a>
<% end %>
@@ -53,7 +53,7 @@
<% if operator_hash.fetch("duration", "all") == duration %>
<b><%= translate(locale, duration) %></b>
<% else %>
- <a href="/search?q=<%= HTML.escape(query.not_nil!.gsub(/ ?duration:[a-z]+/, "") + " duration:" + duration) %>&page=<%= page %>">
+ <a href="/search?q=<%= URI.encode_www_form(query.not_nil!.gsub(/ ?duration:[a-z]+/, "") + " duration:" + duration) %>&page=<%= page %>">
<%= translate(locale, duration) %>
</a>
<% end %>
@@ -68,11 +68,11 @@
<% if operator_hash.fetch("features", "all").includes?(feature) %>
<b><%= translate(locale, feature) %></b>
<% elsif operator_hash.has_key?("features") %>
- <a href="/search?q=<%= HTML.escape(query.not_nil!.gsub(/features:/, "features:" + feature + ",")) %>&page=<%= page %>">
+ <a href="/search?q=<%= URI.encode_www_form(query.not_nil!.gsub(/features:/, "features:" + feature + ",")) %>&page=<%= page %>">
<%= translate(locale, feature) %>
</a>
<% else %>
- <a href="/search?q=<%= HTML.escape(query.not_nil! + " features:" + feature) %>&page=<%= page %>">
+ <a href="/search?q=<%= URI.encode_www_form(query.not_nil! + " features:" + feature) %>&page=<%= page %>">
<%= translate(locale, feature) %>
</a>
<% end %>
@@ -87,7 +87,7 @@
<% if operator_hash.fetch("sort", "relevance") == sort %>
<b><%= translate(locale, sort) %></b>
<% else %>
- <a href="/search?q=<%= HTML.escape(query.not_nil!.gsub(/ ?sort:[a-z]+/, "") + " sort:" + sort) %>&page=<%= page %>">
+ <a href="/search?q=<%= URI.encode_www_form(query.not_nil!.gsub(/ ?sort:[a-z]+/, "") + " sort:" + sort) %>&page=<%= page %>">
<%= translate(locale, sort) %>
</a>
<% end %>
diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr
index 7be95959..3fb2fe18 100644
--- a/src/invidious/views/template.ecr
+++ b/src/invidious/views/template.ecr
@@ -117,38 +117,45 @@
<footer>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-3">
- <a href="https://github.com/iv-org/invidious">
- <%= translate(locale, "Released under the AGPLv3 on Github.") %>
- </a>
- </div>
- <div class="pure-u-1 pure-u-md-1-3">
- <i class="icon ion-ios-wallet"></i>
- BTC: <a href="bitcoin:bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr">bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr</a>
- </div>
- <div class="pure-u-1 pure-u-md-1-3">
- <i class="icon ion-ios-wallet"></i>
- XMR: <a href="monero:41nMCtek197boJtiUvGnTFYMatrLEpnpkQDmUECqx5Es2uX3sTKKWVhSL76suXsG3LXqkEJBrCZBgPTwJrDp1FrZJfycGPR">Click here</a>
- </div>
- <div class="pure-u-1 pure-u-md-1-3">
- <a href="https://github.com/iv-org/documentation">Documentation</a>
+ <span>
+ <i class="icon ion-logo-github"></i>
+ <% if CONFIG.modified_source_code_url %>
+ <a href="https://github.com/iv-org/invidious"><%= translate(locale, "footer_original_source_code") %></a>&nbsp;/
+ <a href="<%= CONFIG.modified_source_code_url %>"><%= translate(locale, "footer_modfied_source_code") %></a>
+ <% else %>
+ <a href="https://github.com/iv-org/invidious"><%= translate(locale, "footer_source_code") %></a>
+ <% end %>
+ </span>
+ <span>
+ <i class="icon ion-ios-paper"></i>
+ <a href="https://github.com/iv-org/documentation"><%= translate(locale, "footer_documentation") %></a>
+ </span>
</div>
+
<div class="pure-u-1 pure-u-md-1-3">
- <i class="icon ion-logo-javascript"></i>
- <a rel="jslicense" href="/licenses">
- <%= translate(locale, "View JavaScript license information.") %>
- </a>
- /
- <i class="icon ion-ios-paper"></i>
- <a href="/privacy">
- <%= translate(locale, "View privacy policy.") %>
- </a>
+ <span>
+ <a href="https://github.com/iv-org/invidious/blob/master/LICENSE"><%= translate(locale, "Released under the AGPLv3 on Github.") %></a>
+ </span>
+ <span>
+ <i class="icon ion-logo-javascript"></i>
+ <a rel="jslicense" href="/licenses"><%= translate(locale, "View JavaScript license information.") %></a>
+ </span>
+ <span>
+ <i class="icon ion-ios-paper"></i>
+ <a href="/privacy"><%= translate(locale, "View privacy policy.") %></a>
+ </span>
</div>
+
<div class="pure-u-1 pure-u-md-1-3">
- <i class="icon ion-logo-github"></i>
- <%= translate(locale, "Current version: ") %> <%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %> @ <%= CURRENT_BRANCH %>
+ <span>
+ <i class="icon ion-ios-wallet"></i>
+ <a href="https://invidious.io/donate/"><%= translate(locale, "footer_donate_page") %></a>
+ </span>
+ <span><%= translate(locale, "Current version: ") %> <%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %> @ <%= CURRENT_BRANCH %></span>
</div>
</div>
</footer>
+
</div>
<div class="pure-u-1 pure-u-md-2-24"></div>
</div>