invidiousアプデ

This commit is contained in:
テクニカル諏訪子 2021-10-13 11:31:06 +09:00
parent b1170492a5
commit 499bd8edb5
81 changed files with 2032 additions and 904 deletions

View file

@ -4,8 +4,17 @@ on:
push:
branches:
- "master"
schedule:
- cron: 0 0 * * *
paths-ignore:
- "*.md"
- LICENCE
- TRANSLATION
- invidious.service
- .git*
- .editorconfig
- screenshots/*
- .github/ISSUE_TEMPLATE/*
- kubernetes/**
jobs:
release:
@ -15,6 +24,19 @@ jobs:
- 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
with:
@ -30,15 +52,6 @@ jobs:
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_PASSWORD }}
- name: Cache Docker layers
if: github.ref == 'refs/heads/master'
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-multi-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-multi-buildx
- name: Build and push Docker AMD64 image for Push Event
if: github.ref == 'refs/heads/master'
uses: docker/build-push-action@v2
@ -49,8 +62,7 @@ jobs:
labels: quay.expires-after=12w
push: true
tags: quay.io/invidious/invidious:${{ github.sha }},quay.io/invidious/invidious:latest
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
build-args: release=1
- name: Build and push Docker ARM64 image for Push Event
if: github.ref == 'refs/heads/master'
@ -62,11 +74,4 @@ jobs:
labels: quay.expires-after=12w
push: true
tags: quay.io/invidious/invidious:${{ github.sha }}-arm64,quay.io/invidious/invidious:latest-arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
- name: Override old Docker cache
if: github.ref == 'refs/heads/master'
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
build-args: release=1

1
.gitignore vendored
View file

@ -7,3 +7,4 @@
/invidious
/sentry
/config/config.yml
/invidious.log

View file

@ -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 {

42
assets/css/keromod.css Normal file
View file

@ -0,0 +1,42 @@
body {
background-image: url('/861c6291044c997464bdaa59cde2f226a5216a3ac8f7c1329ccbf59fc6243b2f.png');
background-attachment: fixed;
}
.bgcon {
background-size: cover;
backdrop-filter: blur(17px);
color: #fcfcfc;
background-color: rgba(160,0,255,.5);
margin: 8px;
border: 4px solid #ff00ff;
border-radius: 4px;
}
.bgnav {
margin: 0 0 8px 0;
padding: 8px 16px 4px 16px;
}
.bgtit {
margin: 0 16px 16px 16px;
border-radius: 4px;
}
.bgmain {
margin: 0 4px;
padding: 0 8px;
border-radius: 4px;
margin-bottom: 12px;
}
.bgippan {
background-color: rgba(0,0,0,.4);
}
.dark-theme a {
color: #e599e5 !important;
}
#player-container {
padding-left: 0;
padding-right: 0;
margin-left: 1em;
margin-right: 1em;
}
footer a {
color: #e599e5 !important;
}

View file

@ -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 () {

View file

@ -1,4 +1,2 @@
User-agent: *
Disallow: /search
Disallow: /login
Disallow: /watch
Disallow: /

View file

@ -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: ""
#########################################

View file

@ -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;"

View file

@ -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"

View file

@ -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"

View file

@ -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;"

View file

@ -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"

View file

@ -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

View file

@ -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;"

View file

@ -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;"

View file

@ -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"

View file

@ -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;"

View file

@ -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'"

View file

@ -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;

View file

@ -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");

View file

@ -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");

View file

@ -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");

View file

@ -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;

View file

@ -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;

View file

@ -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");

View file

@ -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");

View file

@ -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");

View file

@ -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: .

View file

@ -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

View file

@ -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": "اِستيراد البيانات وتصديرها",
@ -423,5 +423,7 @@
"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 دقيقة)"
}

View file

@ -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"
}

View file

@ -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": "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"
}

View file

@ -423,5 +423,7 @@
"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)"
}

View file

@ -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,7 @@
"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 (< minutos)",
"long": "Largo (> minutos)"
}

View file

@ -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": {

View file

@ -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": "رفتن به یوتیوب"
}

View file

@ -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.",

View file

@ -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.",

View file

@ -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": "목록에 없음",

View file

@ -423,5 +423,7 @@
"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ų)"
}

View file

@ -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: ",

View file

@ -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"
}

429
locales/pt.json Normal file
View file

@ -0,0 +1,429 @@
{
"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)"
}

View file

@ -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 минут)"
}

View file

@ -423,5 +423,7 @@
"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)"
}

View file

@ -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 分钟)"
}

View file

@ -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分鐘"
}

View file

@ -5,7 +5,7 @@ shards:
version: 0.1.1
backtracer:
git: https://github.com/Sija/backtracer.cr.git
git: https://github.com/sija/backtracer.cr.git
version: 1.2.1
db:

View file

@ -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"

View file

@ -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,18 +312,19 @@ 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
{% unless flag?(:api_only) %}
Invidious::Routing.get "/", Invidious::Routes::Misc, :home
Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy
Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses
Invidious::Routing.get "/channel/:ucid", Invidious::Routes::Channels, :home
Invidious::Routing.get "/channel/:ucid/home", Invidious::Routes::Channels, :home
Invidious::Routing.get "/channel/:ucid/videos", Invidious::Routes::Channels, :videos
Invidious::Routing.get "/channel/:ucid/playlists", Invidious::Routes::Channels, :playlists
Invidious::Routing.get "/channel/:ucid/community", Invidious::Routes::Channels, :community
Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about
Invidious::Routing.get "/channel/:ucid", 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|
["", "/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/
@ -336,60 +333,61 @@ Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :abo
Invidious::Routing.get "/attribution_link#{path}", Invidious::Routes::Channels, :brand_redirect
# /profile?user=linustechtips
Invidious::Routing.get "/profile/#{path}", Invidious::Routes::Channels, :profile
end
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 "/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 "/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 "/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 "/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 "/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
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
# 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
# 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
# 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 %}
# API routes (macro)
define_v1_api_routes()
@ -848,7 +846,6 @@ post "/data_control" do |env|
if match = channel["url"].as_s.match(/\/channel\/(?<channel>UC[a-zA-Z0-9_-]{22})/)
next match["channel"]
elsif match = channel["url"].as_s.match(/\/user\/(?<user>.+)/)
#response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US")
response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=JP")
html = XML.parse_html(response.body)
ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
@ -1552,4 +1549,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

View file

@ -0,0 +1,623 @@
# 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"].as_a
raw_contents.each do |item|
result = extract_item(item)
if !result.nil?
contents << result
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"].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

View file

@ -36,7 +36,7 @@ struct ConfigPreferences
property latest_only : Bool = false
property listen : Bool = false
property local : Bool = false
property locale : String = "en-US"
property locale : String = "ja"
property max_results : Int32 = 40
property notifications_only : Bool = false
property player_style : String = "invidious"
@ -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)
@ -220,7 +224,7 @@ def login_req(f_req)
"checkConnection" => "youtube",
"checkedDomains" => "youtube",
"hl" => "en",
"deviceinfo" => %|[null,null,null,[],null,"JP",null,null,[],"GlifWebSignIn",null,[null,null,[]]]|,
"deviceinfo" => %|[null,null,null,[],null,"US",null,null,[],"GlifWebSignIn",null,[null,null,[]]]|,
"f.req" => f_req,
"flowName" => "GlifWebSignIn",
"flowEntry" => "ServiceLogin",
@ -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
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
end
def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil)
items = [] of SearchItem
channel_v2_response = initial_data
.try &.["continuationContents"]?
.try &.["gridContinuation"]?
.try &.["items"]?
if channel_v2_response
channel_v2_response.try &.as_a.each { |item|
extract_item(item, author_fallback, author_id_fallback)
.try { |t| items << t }
}
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
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 }
} }
target << i
end
end
return target.select(&.is_a?(SearchVideo)).map(&.as(SearchVideo))
end
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
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
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
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)

View file

@ -1,6 +1,11 @@
# "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
@ -8,7 +13,6 @@ LOCALES = {
"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
@ -24,14 +28,11 @@ LOCALES = {
"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
"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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
ctoken = produce_playlist_continuation(playlist.id, offset)
initial_data = YoutubeAPI.browse(ctoken)
else
initial_data = YoutubeAPI.browse("VL" + playlist.id, params: "")
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
return extract_playlist_videos(initial_data)
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)
videos += extract_playlist_videos(initial_data)
offset += 100
end
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>

View file

@ -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/")

View file

@ -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,

View file

@ -1,3 +1,5 @@
{% skip_file if flag?(:api_only) %}
module Invidious::Routes::Channels
def self.home(env)
self.videos(env)

View file

@ -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]?

View file

@ -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"

View file

@ -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]?

View file

@ -1,3 +1,5 @@
{% skip_file if flag?(:api_only) %}
module Invidious::Routes::Misc
def self.home(env)
preferences = env.get("preferences").as(Preferences)

View file

@ -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]?

View file

@ -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

View file

@ -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]?

View file

@ -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]?

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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 %>

View file

@ -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 %>

View file

@ -48,7 +48,7 @@
<p dir="auto"><b><%= HTML.escape(item.author) %></b></p>
</a>
<% when PlaylistVideo %>
<a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>">
<a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&index=<%= item.index %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
<img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
@ -79,6 +79,19 @@
<div class="flex-left"><a href="/channel/<%= item.ucid %>">
<p class="channel-name" dir="auto"><%= HTML.escape(item.author) %></p>
</a></div>
<div class="flex-right">
<div class="icon-buttons">
<a title="<%=translate(locale, "Watch on YouTube")%>" href="https://www.youtube.com/watch?v=<%= item.id %>&list=<%= item.plid %>">
<i class="icon ion-logo-youtube"></i>
</a>
<a title="<%=translate(locale, "Audio mode")%>" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&amp;listen=1">
<i class="icon ion-md-headset"></i>
</a>
<a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=<%=URI.encode_www_form("watch?v=#{item.id}&list=#{item.plid}")%>">
<i class="icon ion-md-jet"></i>
</a>
</div>
</div>
</div>
<div class="video-card-row flexible">
@ -96,6 +109,7 @@
</div>
<% end %>
</div>
<% when Category %>
<% else %>
<a style="width:100%" href="/watch?v=<%= item.id %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
@ -149,7 +163,7 @@
<a title="<%=translate(locale, "Audio mode")%>" href="/watch?v=<%= item.id %>&amp;listen=1">
<i class="icon ion-md-headset"></i>
</a>
<a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=<%=HTML.escape("watch?v=#{item.id}")%>">
<a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=<%=URI.encode_www_form("watch?v=#{item.id}")%>">
<i class="icon ion-md-jet"></i>
</a>
</div>

View file

@ -16,12 +16,14 @@
<% end %>
<%
fmt_stream.reject! { |f| f["itag"] == 17 }
fmt_stream.sort_by! {|f| params.quality == f["quality"] ? 0 : 1 }
fmt_stream.each_with_index do |fmt, i|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
src_url += "&local=true" if params.local
quality = fmt["quality"]
mimetype = fmt["mimeType"]
mimetype = HTML.escape(fmt["mimeType"].as_s)
selected = params.quality ? (params.quality == quality) : (i == 0)
%>

View file

@ -25,7 +25,7 @@
<script src="/js/silvermine-videojs-quality-selector.min.js?v=<%= ASSET_COMMIT %>"></script>
<% end %>
<% if params.vr_mode %>
<% if !params.listen && params.vr_mode %>
<link rel="stylesheet" href="/css/videojs-vr.css?v=<%= ASSET_COMMIT %>">
<script src="/js/videojs-vr.js?v=<%= ASSET_COMMIT %>"></script>
<% end %>

View file

@ -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">

View file

@ -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 %>

View file

@ -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" %>

View file

@ -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 %>

View file

@ -17,51 +17,7 @@
<link rel="stylesheet" href="/css/grids-responsive-min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/ionicons.min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
<style>
body {
background-image: url('/861c6291044c997464bdaa59cde2f226a5216a3ac8f7c1329ccbf59fc6243b2f.png');
background-attachment: fixed;
}
.bgcon {
background-size: cover;
backdrop-filter: blur(17px);
color: #fcfcfc;
background-color: rgba(160,0,255,.5);
margin: 8px;
border: 4px solid #ff00ff;
border-radius: 4px;
}
.bgnav {
margin: 0 0 8px 0;
padding: 8px 16px 4px 16px;
}
.bgtit {
margin: 0 16px 16px 16px;
border-radius: 4px;
}
.bgmain {
margin: 0 4px;
padding: 0 8px;
border-radius: 4px;
margin-bottom: 12px;
}
.bgippan {
background-color: rgba(0,0,0,.4);
}
.dark-theme a {
color: #e599e5 !important;
}
#player-container {
padding-left: 0;
padding-right: 0;
margin-left: 1em;
margin-right: 1em;
}
footer a {
color: #e599e5 !important;
}
</style>
<link rel="stylesheet" href="/css/keromod.css?v=<%= ASSET_COMMIT %>">
</head>
<% locale = LOCALES[env.get("preferences").as(Preferences).locale]? %>
@ -164,41 +120,48 @@
<footer class="bgippan bgfoot">
<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><br />
<a href="https://git.076.ne.jp/TechnicalSuwako/invidious-mod">
編集したソースコードEdited source code
</a>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<i class="icon ion-ios-wallet"></i>
XMR: <a href="monero:436RmujnMVT834WXCUYoHcCWQBFnmDthxXYkxiwR8qVwBS7P84CjVwGUumvALfUcWDCNUKHFkJhSvPUQpkBtDk2zH9LSg7C">436RmujnMVT834WXCUYoHcCWQBFnmDthxXYkxiwR8qVwBS7P84CjVwGUumvALfUcWDCNUKHFkJhSvPUQpkBtDk2zH9LSg7C</a>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<i class="icon ion-ios-person-circle"></i>
<a href="https://www.technicalsuwako.jp">テクニカル諏訪子</a>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<a href="https://github.com/iv-org/documentation">Documentation</a>
</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>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<span>
<i class="icon ion-logo-github"></i>
<%= translate(locale, "Current version: ") %> <%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %> @ <%= CURRENT_BRANCH %>
<% if CONFIG.modified_source_code_url %>
<a href="https://github.com/iv-org/invidious"><%= translate(locale, "footer_original_source_code") %></a>&nbsp;/
<a href="<%= CONFIG.modified_source_code_url %>"><%= translate(locale, "footer_modfied_source_code") %></a>
<% else %>
<a href="https://github.com/iv-org/invidious"><%= translate(locale, "footer_source_code") %></a>
<% end %>
</span>
<span>
<i class="icon ion-ios-paper"></i>
<a href="https://github.com/iv-org/documentation"><%= translate(locale, "footer_documentation") %></a>
</span>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<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">
<span>
<i class="icon ion-ios-wallet"></i>
<%= translate(locale, "footer_donate") %>
<a href="monero:436RmujnMVT834WXCUYoHcCWQBFnmDthxXYkxiwR8qVwBS7P84CjVwGUumvALfUcWDCNUKHFkJhSvPUQpkBtDk2zH9LSg7C">XMR076</a>&nbsp;/
<a href="bitcoin:bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr">BTC</a>&nbsp;/
<a href="monero:41nMCtek197boJtiUvGnTFYMatrLEpnpkQDmUECqx5Es2uX3sTKKWVhSL76suXsG3LXqkEJBrCZBgPTwJrDp1FrZJfycGPR">XMRInvidious</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>