invidiousアプデ
This commit is contained in:
parent
b1170492a5
commit
499bd8edb5
47
.github/workflows/container-release.yml
vendored
47
.github/workflows/container-release.yml
vendored
|
@ -4,9 +4,18 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- "master"
|
||||
schedule:
|
||||
- cron: 0 0 * * *
|
||||
paths-ignore:
|
||||
- "*.md"
|
||||
- LICENCE
|
||||
- TRANSLATION
|
||||
- invidious.service
|
||||
- .git*
|
||||
- .editorconfig
|
||||
|
||||
- screenshots/*
|
||||
- .github/ISSUE_TEMPLATE/*
|
||||
- kubernetes/**
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
@ -14,6 +23,19 @@ jobs:
|
|||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install Crystal
|
||||
uses: oprypin/install-crystal@v1.2.4
|
||||
with:
|
||||
crystal: 1.1.1
|
||||
|
||||
- name: Run lint
|
||||
run: |
|
||||
if ! crystal tool format --check; then
|
||||
crystal tool format
|
||||
git diff
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
@ -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
1
.gitignore
vendored
|
@ -7,3 +7,4 @@
|
|||
/invidious
|
||||
/sentry
|
||||
/config/config.yml
|
||||
/invidious.log
|
||||
|
|
|
@ -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
42
assets/css/keromod.css
Normal 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;
|
||||
}
|
|
@ -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 () {
|
||||
|
|
|
@ -1,4 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow: /search
|
||||
Disallow: /login
|
||||
Disallow: /watch
|
||||
Disallow: /
|
||||
|
|
|
@ -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: ""
|
||||
|
||||
|
||||
|
||||
#########################################
|
||||
|
|
|
@ -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;"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;"
|
||||
|
|
|
@ -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;"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;"
|
||||
|
|
|
@ -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'"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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: .
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 دقيقة)"
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
@ -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": {
|
||||
|
|
110
locales/fa.json
110
locales/fa.json
|
@ -8,15 +8,15 @@
|
|||
"": "`x` ویدیو ها"
|
||||
},
|
||||
"`x` playlists": {
|
||||
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` لیست های پخش",
|
||||
"": "`x` لیست های پخش"
|
||||
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` سیاههٔ پخش",
|
||||
"": "`x` سیاهههای پخش"
|
||||
},
|
||||
"LIVE": "زنده",
|
||||
"Shared `x` ago": "به اشتراک گذاشته شده `x` پیش",
|
||||
"Unsubscribe": "لغو اشتراک",
|
||||
"Subscribe": "مشترک شدن",
|
||||
"View channel on YouTube": "نمایش کانال در یوتیوب",
|
||||
"View playlist on YouTube": "نمایش لیست پخش در یوتیوب",
|
||||
"View playlist on YouTube": "نمایش سیاههٔ پخش در یوتیوب",
|
||||
"newest": "جدید تر",
|
||||
"oldest": "قدیمی تر",
|
||||
"popular": "محبوب",
|
||||
|
@ -77,8 +77,8 @@
|
|||
"Fallback captions: ": "عقب گرد زیرنویس ها: ",
|
||||
"Show related videos: ": "نمایش ویدیو های مرتبط: ",
|
||||
"Show annotations by default: ": "نمایش حاشیه نویسی ها به طور پیشفرض: ",
|
||||
"Automatically extend video description: ": "",
|
||||
"Interactive 360 degree videos: ": "",
|
||||
"Automatically extend video description: ": "گسترش خودکار توضیحات ویدئو: ",
|
||||
"Interactive 360 degree videos: ": "ویدئوها ۳۶۰ درجه تعاملی: ",
|
||||
"Visual preferences": "ترجیحات بصری",
|
||||
"Player style: ": "حالت پخش کننده: ",
|
||||
"Dark mode: ": "حالت تاریک: ",
|
||||
|
@ -86,8 +86,8 @@
|
|||
"dark": "تاریک",
|
||||
"light": "روشن",
|
||||
"Thin mode: ": "حالت نازک: ",
|
||||
"Miscellaneous preferences": "",
|
||||
"Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
|
||||
"Miscellaneous preferences": "ترجیحات متفرقه",
|
||||
"Automaticatic instance redirection (fallback to redirect.invidious.io): ": "هدایت خودکار نمونه (به طور پیشفرض به redirect.invidious.io): ",
|
||||
"Subscription preferences": "ترجیحات اشتراک",
|
||||
"Show annotations by default for subscribed channels: ": "نمایش حاشیه نویسی ها به طور پیشفرض برای کانال های مشترک شده: ",
|
||||
"Redirect homepage to feed: ": "تغییر مسیر صفحه خانه به خوراک: ",
|
||||
|
@ -117,7 +117,7 @@
|
|||
"Administrator preferences": "ترجیحات مدیریت",
|
||||
"Default homepage: ": "صفحه خانه پیشفرض ",
|
||||
"Feed menu: ": "منو خوراک: ",
|
||||
"Show nickname on top: ": "",
|
||||
"Show nickname on top: ": "نمایش نام مستعار در بالا: ",
|
||||
"Top enabled: ": "بالا فعال شده: ",
|
||||
"CAPTCHA enabled: ": "CAPTCHA فعال شده: ",
|
||||
"Login enabled: ": "ورود فعال شده: ",
|
||||
|
@ -145,7 +145,7 @@
|
|||
},
|
||||
"search": "جستجو",
|
||||
"Log out": "خروج",
|
||||
"Released under the AGPLv3 on Github.": "",
|
||||
"Released under the AGPLv3 on Github.": "منتشر شده تحت پروانه AGPLv3 روی گیتهاب.",
|
||||
"Source available here.": "منبع اینجا دردسترس است.",
|
||||
"View JavaScript license information.": "نمایش اطلاعات مجوز جاوا اسکریپت.",
|
||||
"View privacy policy.": "نمایش سیاست حفظ حریم خصوصی.",
|
||||
|
@ -153,19 +153,19 @@
|
|||
"Public": "عمومی",
|
||||
"Unlisted": "لیست نشده",
|
||||
"Private": "خصوصی",
|
||||
"View all playlists": "نمایش همه لیست پخش",
|
||||
"View all playlists": "نمایش همه سیاهههای پخش",
|
||||
"Updated `x` ago": "بروز شده `x` پیش",
|
||||
"Delete playlist `x`?": "حذف لیست پخش `x`؟",
|
||||
"Delete playlist": "حذف لیست پخش",
|
||||
"Create playlist": "ایجاد لیست پخش",
|
||||
"Delete playlist `x`?": "حذف سیاههٔ پخش `x`؟",
|
||||
"Delete playlist": "حذف سیاههٔ پخش",
|
||||
"Create playlist": "ایجاد سیاههٔ پخش",
|
||||
"Title": "عنوان",
|
||||
"Playlist privacy": "حریم خصوصی لیست پخش",
|
||||
"Editing playlist `x`": "تغییر لیست پخش `x`",
|
||||
"Show more": "",
|
||||
"Show less": "",
|
||||
"Playlist privacy": "حریم خصوصی سیاههٔ پخش",
|
||||
"Editing playlist `x`": "تغییر سیاههٔ پخش `x`",
|
||||
"Show more": "نمایش بیشتر",
|
||||
"Show less": "نمایش کمتر",
|
||||
"Watch on YouTube": "تماشا در یوتیوب",
|
||||
"Switch Invidious Instance": "",
|
||||
"Broken? Try another Invidious Instance": "",
|
||||
"Switch Invidious Instance": "تعویض نمونه اینویدیوس",
|
||||
"Broken? Try another Invidious Instance": "کار نمیکند؟ نمونه دیگری از اینویدیوس را امتحان کنید",
|
||||
"Hide annotations": "مخفی کردن حاشیه نویسی ها",
|
||||
"Show annotations": "نمایش حاشیه نویسی ها",
|
||||
"Genre: ": "ژانر: ",
|
||||
|
@ -224,9 +224,9 @@
|
|||
"": "`x` نقطه ها"
|
||||
},
|
||||
"Could not create mix.": "نمیتوان میکس ساخت.",
|
||||
"Empty playlist": "لیست پخش خالی",
|
||||
"Not a playlist.": "یک لیست پخش نیست.",
|
||||
"Playlist does not exist.": "لیست پخش وجود ندارد.",
|
||||
"Empty playlist": "سیاههٔ پخش خالی",
|
||||
"Not a playlist.": "یک سیاههٔ پخش نیست.",
|
||||
"Playlist does not exist.": "سیاههٔ پخش وجود ندارد.",
|
||||
"Could not pull trending pages.": "نمیتوان صفحه های پر طرفدار را بکشد.",
|
||||
"Hidden field \"challenge\" is a required field": "فیلد مخفی \"چالش\" یک فیلد ضروری است",
|
||||
"Hidden field \"token\" is a required field": "فیلد مخفی \"توکن\" یک فیلد ضروری است",
|
||||
|
@ -370,12 +370,12 @@
|
|||
},
|
||||
"Fallback comments: ": "نظرات عقب گرد: ",
|
||||
"Popular": "محبوب",
|
||||
"Search": "",
|
||||
"Search": "جستجو",
|
||||
"Top": "بالا",
|
||||
"About": "درباره",
|
||||
"Rating: ": "رتبه دهی: ",
|
||||
"Language: ": "زبان: ",
|
||||
"View as playlist": "نمایش به عنوان لیست پخش",
|
||||
"View as playlist": "نمایش به عنوان سیاههٔ پخش",
|
||||
"Default": "پیشفرض",
|
||||
"Music": "موسیقی",
|
||||
"Gaming": "بازی",
|
||||
|
@ -391,37 +391,37 @@
|
|||
"Audio mode": "حالت صدا",
|
||||
"Video mode": "حالت ویدیو",
|
||||
"Videos": "ویدیو ها",
|
||||
"Playlists": "لیست های پخش",
|
||||
"Playlists": "سیاهههای پخش",
|
||||
"Community": "اجتماع",
|
||||
"relevance": "",
|
||||
"rating": "",
|
||||
"date": "",
|
||||
"views": "",
|
||||
"content_type": "",
|
||||
"duration": "",
|
||||
"features": "",
|
||||
"sort": "",
|
||||
"hour": "",
|
||||
"today": "",
|
||||
"week": "",
|
||||
"month": "",
|
||||
"year": "",
|
||||
"video": "",
|
||||
"channel": "",
|
||||
"playlist": "",
|
||||
"movie": "",
|
||||
"show": "",
|
||||
"hd": "",
|
||||
"subtitles": "",
|
||||
"creative_commons": "",
|
||||
"3d": "",
|
||||
"live": "",
|
||||
"4k": "",
|
||||
"location": "",
|
||||
"hdr": "",
|
||||
"filter": "",
|
||||
"relevance": "مرتبط بودن",
|
||||
"rating": "امتیاز",
|
||||
"date": "تاریخ بارگذاری",
|
||||
"views": "تعداد بازدید",
|
||||
"content_type": "نوع",
|
||||
"duration": "مدت",
|
||||
"features": "ویژگیها",
|
||||
"sort": "به ترتیب",
|
||||
"hour": "یک ساعت گذشته",
|
||||
"today": "امروز",
|
||||
"week": "این هفته",
|
||||
"month": "این ماه",
|
||||
"year": "امسال",
|
||||
"video": "ویدئو",
|
||||
"channel": "کانال",
|
||||
"playlist": "سیاههٔ پخش",
|
||||
"movie": "فیلم",
|
||||
"show": "نمایش",
|
||||
"hd": "HD",
|
||||
"subtitles": "زیرنویس",
|
||||
"creative_commons": "کریتیو کامونز",
|
||||
"3d": "سهبعدی",
|
||||
"live": "زنده",
|
||||
"4k": "4K",
|
||||
"location": "مکان",
|
||||
"hdr": "HDR",
|
||||
"filter": "پالایه",
|
||||
"Current version: ": "نسخه فعلی: ",
|
||||
"next_steps_error_message": "",
|
||||
"next_steps_error_message_refresh": "",
|
||||
"next_steps_error_message_go_to_youtube": ""
|
||||
"next_steps_error_message": "اکنون بایستی یکی از این موارد را امتحان کنید: ",
|
||||
"next_steps_error_message_refresh": "تازهسازی",
|
||||
"next_steps_error_message_go_to_youtube": "رفتن به یوتیوب"
|
||||
}
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -136,7 +136,7 @@
|
|||
"Delete playlist": "재생목록 삭제",
|
||||
"Delete playlist `x`?": "재생목록 `x` 를 삭제 하시겠습니까?",
|
||||
"Updated `x` ago": "`x` 전에 업데이트됨",
|
||||
"Released under the AGPLv3 on Github.": "",
|
||||
"Released under the AGPLv3 on Github.": "Github에 AGPLv3 으로 배포됩니다.",
|
||||
"View all playlists": "모든 재생목록 보기",
|
||||
"Private": "비공개",
|
||||
"Unlisted": "목록에 없음",
|
||||
|
|
|
@ -423,5 +423,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ų)"
|
||||
}
|
||||
|
|
|
@ -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: ",
|
||||
|
|
|
@ -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
429
locales/pt.json
Normal 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)"
|
||||
}
|
|
@ -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 минут)"
|
||||
}
|
||||
|
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
@ -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 分钟)"
|
||||
}
|
||||
|
|
|
@ -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分鐘)"
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
|
|
142
src/invidious.cr
142
src/invidious.cr
|
@ -67,7 +67,7 @@ SOFTWARE = {
|
|||
"branch" => "#{CURRENT_BRANCH}",
|
||||
}
|
||||
|
||||
YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size, timeout: 2.0, use_quic: CONFIG.use_quic)
|
||||
YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size, use_quic: CONFIG.use_quic)
|
||||
|
||||
# CLI
|
||||
Kemal.config.extra_options do |parser|
|
||||
|
@ -153,10 +153,6 @@ if CONFIG.popular_enabled
|
|||
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
|
||||
end
|
||||
|
||||
if CONFIG.captcha_key
|
||||
Invidious::Jobs.register Invidious::Jobs::BypassCaptchaJob.new
|
||||
end
|
||||
|
||||
connection_channel = Channel({Bool, Channel(PQ::Notification)}).new(32)
|
||||
Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(connection_channel, CONFIG.database_url)
|
||||
|
||||
|
@ -316,80 +312,82 @@ 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|
|
||||
# /c/LinusTechTips
|
||||
Invidious::Routing.get "/c/:user#{path}", Invidious::Routes::Channels, :brand_redirect
|
||||
# /user/linustechtips | Not always the same as /c/
|
||||
Invidious::Routing.get "/user/:user#{path}", Invidious::Routes::Channels, :brand_redirect
|
||||
# /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow
|
||||
Invidious::Routing.get "/attribution_link#{path}", Invidious::Routes::Channels, :brand_redirect
|
||||
# /profile?user=linustechtips
|
||||
Invidious::Routing.get "/profile/#{path}", Invidious::Routes::Channels, :profile
|
||||
end
|
||||
["", "/videos", "/playlists", "/community", "/about"].each do |path|
|
||||
# /c/LinusTechTips
|
||||
Invidious::Routing.get "/c/:user#{path}", Invidious::Routes::Channels, :brand_redirect
|
||||
# /user/linustechtips | Not always the same as /c/
|
||||
Invidious::Routing.get "/user/:user#{path}", Invidious::Routes::Channels, :brand_redirect
|
||||
# /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow
|
||||
Invidious::Routing.get "/attribution_link#{path}", Invidious::Routes::Channels, :brand_redirect
|
||||
# /profile?user=linustechtips
|
||||
Invidious::Routing.get "/profile/#{path}", Invidious::Routes::Channels, :profile
|
||||
end
|
||||
|
||||
Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle
|
||||
Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect
|
||||
Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect
|
||||
Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect
|
||||
Invidious::Routing.get "/v/:id", Invidious::Routes::Watch, :redirect
|
||||
Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect
|
||||
Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_redirect
|
||||
Invidious::Routing.get "/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
|
||||
|
|
623
src/invidious/helpers/extractors.cr
Normal file
623
src/invidious/helpers/extractors.cr
Normal 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
|
|
@ -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
|
||||
target = [] of SearchItem
|
||||
extracted.each do |i|
|
||||
if i.is_a?(Category)
|
||||
i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video }
|
||||
else
|
||||
target << i
|
||||
end
|
||||
|
||||
SearchVideo.new({
|
||||
title: title,
|
||||
id: video_id,
|
||||
author: author,
|
||||
ucid: author_id,
|
||||
published: published,
|
||||
views: view_count,
|
||||
description_html: description_html,
|
||||
length_seconds: length_seconds,
|
||||
live_now: live_now,
|
||||
premium: premium,
|
||||
premiere_timestamp: premiere_timestamp,
|
||||
})
|
||||
elsif i = item["channelRenderer"]?
|
||||
author = i["title"]["simpleText"]?.try &.as_s || author_fallback || ""
|
||||
author_id = i["channelId"]?.try &.as_s || author_id_fallback || ""
|
||||
|
||||
author_thumbnail = i["thumbnail"]["thumbnails"]?.try &.as_a[0]?.try &.["url"]?.try &.as_s || ""
|
||||
subscriber_count = i["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s.try { |s| short_text_to_number(s.split(" ")[0]) } || 0
|
||||
|
||||
auto_generated = false
|
||||
auto_generated = true if !i["videoCountText"]?
|
||||
video_count = i["videoCountText"]?.try &.["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0
|
||||
description_html = i["descriptionSnippet"]?.try { |t| parse_content(t) } || ""
|
||||
|
||||
SearchChannel.new({
|
||||
author: author,
|
||||
ucid: author_id,
|
||||
author_thumbnail: author_thumbnail,
|
||||
subscriber_count: subscriber_count,
|
||||
video_count: video_count,
|
||||
description_html: description_html,
|
||||
auto_generated: auto_generated,
|
||||
})
|
||||
elsif i = item["gridPlaylistRenderer"]?
|
||||
title = i["title"]["runs"].as_a[0]?.try &.["text"].as_s || ""
|
||||
plid = i["playlistId"]?.try &.as_s || ""
|
||||
|
||||
video_count = i["videoCountText"]["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0
|
||||
playlist_thumbnail = i["thumbnail"]["thumbnails"][0]?.try &.["url"]?.try &.as_s || ""
|
||||
|
||||
SearchPlaylist.new({
|
||||
title: title,
|
||||
id: plid,
|
||||
author: author_fallback || "",
|
||||
ucid: author_id_fallback || "",
|
||||
video_count: video_count,
|
||||
videos: [] of SearchPlaylistVideo,
|
||||
thumbnail: playlist_thumbnail,
|
||||
})
|
||||
elsif i = item["playlistRenderer"]?
|
||||
title = i["title"]["simpleText"]?.try &.as_s || ""
|
||||
plid = i["playlistId"]?.try &.as_s || ""
|
||||
|
||||
video_count = i["videoCount"]?.try &.as_s.to_i || 0
|
||||
playlist_thumbnail = i["thumbnails"].as_a[0]?.try &.["thumbnails"]?.try &.as_a[0]?.try &.["url"].as_s || ""
|
||||
|
||||
author_info = i["shortBylineText"]?.try &.["runs"]?.try &.as_a?.try &.[0]?
|
||||
author = author_info.try &.["text"].as_s || author_fallback || ""
|
||||
author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_id_fallback || ""
|
||||
|
||||
videos = i["videos"]?.try &.as_a.map do |v|
|
||||
v = v["childVideoRenderer"]
|
||||
v_title = v["title"]["simpleText"]?.try &.as_s || ""
|
||||
v_id = v["videoId"]?.try &.as_s || ""
|
||||
v_length_seconds = v["lengthText"]?.try &.["simpleText"]?.try { |t| decode_length_seconds(t.as_s) } || 0
|
||||
SearchPlaylistVideo.new({
|
||||
title: v_title,
|
||||
id: v_id,
|
||||
length_seconds: v_length_seconds,
|
||||
})
|
||||
end || [] of SearchPlaylistVideo
|
||||
|
||||
# TODO: i["publishedTimeText"]?
|
||||
|
||||
SearchPlaylist.new({
|
||||
title: title,
|
||||
id: plid,
|
||||
author: author,
|
||||
ucid: author_id,
|
||||
video_count: video_count,
|
||||
videos: videos,
|
||||
thumbnail: playlist_thumbnail,
|
||||
})
|
||||
elsif i = item["radioRenderer"]? # Mix
|
||||
# TODO
|
||||
elsif i = item["showRenderer"]? # Show
|
||||
# TODO
|
||||
elsif i = item["shelfRenderer"]?
|
||||
elsif i = item["horizontalCardListRenderer"]?
|
||||
elsif i = item["searchPyvRenderer"]? # Ad
|
||||
end
|
||||
return target.select(&.is_a?(SearchVideo)).map(&.as(SearchVideo))
|
||||
end
|
||||
|
||||
def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil)
|
||||
items = [] of SearchItem
|
||||
def extract_selected_tab(tabs)
|
||||
# Extract the selected tab from the array of tabs Youtube returns
|
||||
return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]["tabRenderer"]
|
||||
end
|
||||
|
||||
channel_v2_response = initial_data
|
||||
.try &.["continuationContents"]?
|
||||
.try &.["gridContinuation"]?
|
||||
.try &.["items"]?
|
||||
def fetch_continuation_token(items : Array(JSON::Any))
|
||||
# Fetches the continuation token from an array of items
|
||||
return items.last["continuationItemRenderer"]?
|
||||
.try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s
|
||||
end
|
||||
|
||||
if channel_v2_response
|
||||
channel_v2_response.try &.as_a.each { |item|
|
||||
extract_item(item, author_fallback, author_id_fallback)
|
||||
.try { |t| items << t }
|
||||
}
|
||||
def fetch_continuation_token(initial_data : Hash(String, JSON::Any))
|
||||
# Fetches the continuation token from initial data
|
||||
if initial_data["onResponseReceivedActions"]?
|
||||
continuation_items = initial_data["onResponseReceivedActions"][0]["appendContinuationItemsAction"]["continuationItems"]
|
||||
else
|
||||
initial_data.try { |t| t["contents"]? || t["response"]? }
|
||||
.try { |t| t["twoColumnBrowseResultsRenderer"]?.try &.["tabs"].as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]?.try &.["tabRenderer"]["content"] ||
|
||||
t["twoColumnSearchResultsRenderer"]?.try &.["primaryContents"] ||
|
||||
t["continuationContents"]? }
|
||||
.try { |t| t["sectionListRenderer"]? || t["sectionListContinuation"]? }
|
||||
.try &.["contents"].as_a
|
||||
.each { |c| c.try &.["itemSectionRenderer"]?.try &.["contents"].as_a
|
||||
.try { |t| t[0]?.try &.["shelfRenderer"]?.try &.["content"]["expandedShelfContentsRenderer"]?.try &.["items"].as_a ||
|
||||
t[0]?.try &.["gridRenderer"]?.try &.["items"].as_a || t }
|
||||
.each { |item|
|
||||
extract_item(item, author_fallback, author_id_fallback)
|
||||
.try { |t| items << t }
|
||||
} }
|
||||
tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"])
|
||||
continuation_items = tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["gridRenderer"]["items"]
|
||||
end
|
||||
|
||||
items
|
||||
return fetch_continuation_token(continuation_items.as_a)
|
||||
end
|
||||
|
||||
def check_enum(db, enum_name, struct_type = nil)
|
||||
|
|
|
@ -1,43 +1,44 @@
|
|||
# "bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh) [Incomplete]
|
||||
# "eu" => load_locale("eu"), # Basque [Incomplete]
|
||||
# "si" => load_locale("si"), # Sinhala [Incomplete]
|
||||
# "sk" => load_locale("sk"), # Slovak [Incomplete]
|
||||
# "sr" => load_locale("sr"), # Serbian [Incomplete]
|
||||
# "sr_Cyrl" => load_locale("sr_Cyrl"), # Serbian (cyrillic) [Incomplete]
|
||||
LOCALES = {
|
||||
"ar" => load_locale("ar"), # Arabic
|
||||
"bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh)
|
||||
"cs" => load_locale("cs"), # Czech
|
||||
"da" => load_locale("da"), # Danish
|
||||
"de" => load_locale("de"), # German
|
||||
"el" => load_locale("el"), # Greek
|
||||
"en-US" => load_locale("en-US"), # English (US)
|
||||
"eo" => load_locale("eo"), # Esperanto
|
||||
"es" => load_locale("es"), # Spanish
|
||||
"eu" => load_locale("eu"), # Basque
|
||||
"fa" => load_locale("fa"), # Persian
|
||||
"fi" => load_locale("fi"), # Finnish
|
||||
"fr" => load_locale("fr"), # French
|
||||
"he" => load_locale("he"), # Hebrew
|
||||
"hr" => load_locale("hr"), # Croatian
|
||||
"hu-HU" => load_locale("hu-HU"), # Hungarian
|
||||
"id" => load_locale("id"), # Indonesian
|
||||
"is" => load_locale("is"), # Icelandic
|
||||
"it" => load_locale("it"), # Italian
|
||||
"ja" => load_locale("ja"), # Japanese
|
||||
"ko" => load_locale("ko"), # Korean
|
||||
"lt" => load_locale("lt"), # Lithuanian
|
||||
"nb-NO" => load_locale("nb-NO"), # Norwegian Bokmål
|
||||
"nl" => load_locale("nl"), # Dutch
|
||||
"pl" => load_locale("pl"), # Polish
|
||||
"pt-BR" => load_locale("pt-BR"), # Portuguese (Brazil)
|
||||
"pt-PT" => load_locale("pt-PT"), # Portuguese (Portugal)
|
||||
"ro" => load_locale("ro"), # Romanian
|
||||
"ru" => load_locale("ru"), # Russian
|
||||
"si" => load_locale("si"), # Sinhala
|
||||
"sk" => load_locale("sk"), # Slovak
|
||||
"sr" => load_locale("sr"), # Serbian
|
||||
"sr_Cyrl" => load_locale("sr_Cyrl"), # Serbian (cyrillic)
|
||||
"sv-SE" => load_locale("sv-SE"), # Swedish
|
||||
"tr" => load_locale("tr"), # Turkish
|
||||
"uk" => load_locale("uk"), # Ukrainian
|
||||
"vi" => load_locale("vi"), # Vietnamese
|
||||
"zh-CN" => load_locale("zh-CN"), # Chinese (Simplified)
|
||||
"zh-TW" => load_locale("zh-TW"), # Chinese (Traditional)
|
||||
"ar" => load_locale("ar"), # Arabic
|
||||
"cs" => load_locale("cs"), # Czech
|
||||
"da" => load_locale("da"), # Danish
|
||||
"de" => load_locale("de"), # German
|
||||
"el" => load_locale("el"), # Greek
|
||||
"en-US" => load_locale("en-US"), # English (US)
|
||||
"eo" => load_locale("eo"), # Esperanto
|
||||
"es" => load_locale("es"), # Spanish
|
||||
"fa" => load_locale("fa"), # Persian
|
||||
"fi" => load_locale("fi"), # Finnish
|
||||
"fr" => load_locale("fr"), # French
|
||||
"he" => load_locale("he"), # Hebrew
|
||||
"hr" => load_locale("hr"), # Croatian
|
||||
"hu-HU" => load_locale("hu-HU"), # Hungarian
|
||||
"id" => load_locale("id"), # Indonesian
|
||||
"is" => load_locale("is"), # Icelandic
|
||||
"it" => load_locale("it"), # Italian
|
||||
"ja" => load_locale("ja"), # Japanese
|
||||
"ko" => load_locale("ko"), # Korean
|
||||
"lt" => load_locale("lt"), # Lithuanian
|
||||
"nb-NO" => load_locale("nb-NO"), # Norwegian Bokmål
|
||||
"nl" => load_locale("nl"), # Dutch
|
||||
"pl" => load_locale("pl"), # Polish
|
||||
"pt" => load_locale("pt"), # Portuguese
|
||||
"pt-BR" => load_locale("pt-BR"), # Portuguese (Brazil)
|
||||
"pt-PT" => load_locale("pt-PT"), # Portuguese (Portugal)
|
||||
"ro" => load_locale("ro"), # Romanian
|
||||
"ru" => load_locale("ru"), # Russian
|
||||
"sv-SE" => load_locale("sv-SE"), # Swedish
|
||||
"tr" => load_locale("tr"), # Turkish
|
||||
"uk" => load_locale("uk"), # Ukrainian
|
||||
"vi" => load_locale("vi"), # Vietnamese
|
||||
"zh-CN" => load_locale("zh-CN"), # Chinese (Simplified)
|
||||
"zh-TW" => load_locale("zh-TW"), # Chinese (Traditional)
|
||||
}
|
||||
|
||||
def load_locale(name)
|
||||
|
|
|
@ -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
|
||||
|
|
263
src/invidious/helpers/serialized_yt_data.cr
Normal file
263
src/invidious/helpers/serialized_yt_data.cr
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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/")
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
{% skip_file if flag?(:api_only) %}
|
||||
|
||||
module Invidious::Routes::Channels
|
||||
def self.home(env)
|
||||
self.videos(env)
|
||||
|
|
|
@ -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]?
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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]?
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
{% skip_file if flag?(:api_only) %}
|
||||
|
||||
module Invidious::Routes::Misc
|
||||
def self.home(env)
|
||||
preferences = env.get("preferences").as(Preferences)
|
||||
|
|
|
@ -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]?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]?
|
||||
|
|
|
@ -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]?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
<p dir="auto"><b><%= HTML.escape(item.author) %></b></p>
|
||||
</a>
|
||||
<% when PlaylistVideo %>
|
||||
<a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>">
|
||||
<a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&index=<%= item.index %>">
|
||||
<% if !env.get("preferences").as(Preferences).thin_mode %>
|
||||
<div class="thumbnail">
|
||||
<img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
|
||||
|
@ -79,6 +79,19 @@
|
|||
<div class="flex-left"><a href="/channel/<%= item.ucid %>">
|
||||
<p class="channel-name" dir="auto"><%= HTML.escape(item.author) %></p>
|
||||
</a></div>
|
||||
<div class="flex-right">
|
||||
<div class="icon-buttons">
|
||||
<a title="<%=translate(locale, "Watch on YouTube")%>" href="https://www.youtube.com/watch?v=<%= item.id %>&list=<%= item.plid %>">
|
||||
<i class="icon ion-logo-youtube"></i>
|
||||
</a>
|
||||
<a title="<%=translate(locale, "Audio mode")%>" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&listen=1">
|
||||
<i class="icon ion-md-headset"></i>
|
||||
</a>
|
||||
<a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=<%=URI.encode_www_form("watch?v=#{item.id}&list=#{item.plid}")%>">
|
||||
<i class="icon ion-md-jet"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-card-row flexible">
|
||||
|
@ -96,6 +109,7 @@
|
|||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% when Category %>
|
||||
<% else %>
|
||||
<a style="width:100%" href="/watch?v=<%= item.id %>">
|
||||
<% if !env.get("preferences").as(Preferences).thin_mode %>
|
||||
|
@ -149,7 +163,7 @@
|
|||
<a title="<%=translate(locale, "Audio mode")%>" href="/watch?v=<%= item.id %>&listen=1">
|
||||
<i class="icon ion-md-headset"></i>
|
||||
</a>
|
||||
<a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=<%=HTML.escape("watch?v=#{item.id}")%>">
|
||||
<a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=<%=URI.encode_www_form("watch?v=#{item.id}")%>">
|
||||
<i class="icon ion-md-jet"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -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)
|
||||
%>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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" %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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>
|
||||
<span>
|
||||
<i class="icon ion-logo-github"></i>
|
||||
<% if CONFIG.modified_source_code_url %>
|
||||
<a href="https://github.com/iv-org/invidious"><%= translate(locale, "footer_original_source_code") %></a> /
|
||||
<a href="<%= CONFIG.modified_source_code_url %>"><%= translate(locale, "footer_modfied_source_code") %></a>
|
||||
<% else %>
|
||||
<a href="https://github.com/iv-org/invidious"><%= translate(locale, "footer_source_code") %></a>
|
||||
<% end %>
|
||||
</span>
|
||||
<span>
|
||||
<i class="icon ion-ios-paper"></i>
|
||||
<a href="https://github.com/iv-org/documentation"><%= translate(locale, "footer_documentation") %></a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<i class="icon ion-ios-wallet"></i>
|
||||
XMR: <a href="monero:436RmujnMVT834WXCUYoHcCWQBFnmDthxXYkxiwR8qVwBS7P84CjVwGUumvALfUcWDCNUKHFkJhSvPUQpkBtDk2zH9LSg7C">436RmujnMVT834WXCUYoHcCWQBFnmDthxXYkxiwR8qVwBS7P84CjVwGUumvALfUcWDCNUKHFkJhSvPUQpkBtDk2zH9LSg7C</a>
|
||||
</div>
|
||||
<span>
|
||||
<a href="https://github.com/iv-org/invidious/blob/master/LICENSE"><%= translate(locale, "Released under the AGPLv3 on Github.") %></a>
|
||||
</span>
|
||||
<span>
|
||||
<i class="icon ion-logo-javascript"></i>
|
||||
<a rel="jslicense" href="/licenses"><%= translate(locale, "View JavaScript license information.") %></a>
|
||||
</span>
|
||||
<span>
|
||||
<i class="icon ion-ios-paper"></i>
|
||||
<a href="/privacy"><%= translate(locale, "View privacy policy.") %></a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<i class="icon ion-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">
|
||||
<i class="icon ion-logo-github"></i>
|
||||
<%= translate(locale, "Current version: ") %> <%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %> @ <%= CURRENT_BRANCH %>
|
||||
<span>
|
||||
<i class="icon ion-ios-wallet"></i>
|
||||
<%= translate(locale, "footer_donate") %>
|
||||
<a href="monero:436RmujnMVT834WXCUYoHcCWQBFnmDthxXYkxiwR8qVwBS7P84CjVwGUumvALfUcWDCNUKHFkJhSvPUQpkBtDk2zH9LSg7C">XMR(076)</a> /
|
||||
<a href="bitcoin:bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr">BTC</a> /
|
||||
<a href="monero:41nMCtek197boJtiUvGnTFYMatrLEpnpkQDmUECqx5Es2uX3sTKKWVhSL76suXsG3LXqkEJBrCZBgPTwJrDp1FrZJfycGPR">XMR(Invidious)</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>
|
||||
|
|
Loading…
Reference in a new issue