diff options
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 @@ -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 %>&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 %>&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> / + <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> |
