Merge branch 'iv-org:master' into verified-badge

pull/2859/head
Jonas 3 years ago committed by GitHub
commit 6de449811d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -62,7 +62,8 @@ test:
crystal spec crystal spec
verify: verify:
crystal build src/invidious.cr --no-codegen --progress --stats --error-trace crystal build src/invidious.cr -Dskip_videojs_download \
--no-codegen --progress --stats --error-trace
# ----------------------- # -----------------------
@ -88,28 +89,28 @@ distclean: clean
# ----------------------- # -----------------------
help: help:
echo "Targets available in this Makefile:" @echo "Targets available in this Makefile:"
echo "" @echo ""
echo "get-libs Fetch Crystal libraries" @echo " get-libs Fetch Crystal libraries"
echo "invidious Build Invidious" @echo " invidious Build Invidious"
echo "run Launch Invidious" @echo " run Launch Invidious"
echo "" @echo ""
echo "format Run the Crystal formatter" @echo " format Run the Crystal formatter"
echo "test Run tests" @echo " test Run tests"
echo "verify Just make sure that the code compiles, but without" @echo " verify Just make sure that the code compiles, but without"
echo " generating any binaries. Useful to search for errors" @echo " generating any binaries. Useful to search for errors"
echo "" @echo ""
echo "clean Remove build artifacts" @echo " clean Remove build artifacts"
echo "distclean Remove build artifacts and libraries" @echo " distclean Remove build artifacts and libraries"
echo "" @echo ""
echo "" @echo ""
echo "Build options available for this Makefile:" @echo "Build options available for this Makefile:"
echo "" @echo ""
echo "RELEASE Make a release build (Default: 1)" @echo " RELEASE Make a release build (Default: 1)"
echo "STATIC Link libraries statically (Default: 0)" @echo " STATIC Link libraries statically (Default: 0)"
echo "" @echo ""
echo "DISABLE_QUIC Disable support for QUIC (Default: 0)" @echo " DISABLE_QUIC Disable support for QUIC (Default: 0)"
echo "NO_DBG_SYMBOLS Strip debug symbols (Default: 0)" @echo " NO_DBG_SYMBOLS Strip debug symbols (Default: 0)"

@ -51,8 +51,8 @@
<img alt="Mastodon: @invidious@social.tchncs.de" src="https://img.shields.io/badge/Mastodon-%40invidious%40social.tchncs.de-darkgreen"> <img alt="Mastodon: @invidious@social.tchncs.de" src="https://img.shields.io/badge/Mastodon-%40invidious%40social.tchncs.de-darkgreen">
</a> </a>
<br> <br>
<a href="#contact-the-team-directly"> <a href="https://invidious.io/contact/">
<img alt="Contact the team directly" src="https://img.shields.io/badge/E%2d%2dmail-darkgreen"> <img alt="E-mail" src="https://img.shields.io/badge/E%2d%2dmail-darkgreen">
</a> </a>
</div> </div>
@ -152,19 +152,6 @@ Weblate also allows you to log-in with major SSO providers like Github, Gitlab,
- [HoloPlay](https://github.com/stephane-r/HoloPlay): Funny Android application connecting on Invidious API's with search, playlists and favorites. - [HoloPlay](https://github.com/stephane-r/HoloPlay): Funny Android application connecting on Invidious API's with search, playlists and favorites.
## Contact the team directly
Every team member is available through GitHub or through the Matrix room (bridged to IRC), however, if you need/have to, you can contact the team directly via e-mail (remove `+SPAMGUARD` from the addresses):
- General Inquiries (forwarded to all team members): `contact +SPAMGUARD [at] invidious [dot] io`
Note: before sending a bug report please check that it hasn't already be reported on GitHub - bug reports sent to this address will be copied to GitHub
- Security issues (forwarded to the two project owners, <a href="https://github.com/TheFrenchGhosty">@TheFrenchGhosty</a> and <a href="https://github.com/Perflyst">@Perflyst</a>): `security +SPAMGUARD [at] invidious [dot] io`
Note: the creation of a PGP key for this address is planned
## Liability ## Liability
We take no responsibility for the use of our tool, or external instances We take no responsibility for the use of our tool, or external instances

@ -150,13 +150,13 @@
// Ignore shortcuts if any text input is focused // Ignore shortcuts if any text input is focused
let focused_tag = document.activeElement.tagName.toLowerCase(); let focused_tag = document.activeElement.tagName.toLowerCase();
let focused_type = document.activeElement.type.toLowerCase(); const allowed = /^(button|checkbox|file|radio|submit)$/;
let allowed = /^(button|checkbox|file|radio|submit)$/;
if (focused_tag === "textarea" || if (focused_tag === "textarea") return;
(focused_tag === "input" && !focused_type.match(allowed)) if (focused_tag === "input") {
) let focused_type = document.activeElement.type.toLowerCase();
return; if (!focused_type.match(allowed)) return;
}
// Focus search bar on '/' // Focus search bar on '/'
if (event.key == "/") { if (event.key == "/") {

@ -35,21 +35,11 @@ if (player_data.aspect_ratio) {
var embed_url = new URL(location); var embed_url = new URL(location);
embed_url.searchParams.delete('v'); embed_url.searchParams.delete('v');
short_url = location.origin + '/' + video_data.id + embed_url.search; var short_url = location.origin + '/' + video_data.id + embed_url.search;
embed_url = location.origin + '/embed/' + video_data.id + embed_url.search; embed_url = location.origin + '/embed/' + video_data.id + embed_url.search;
var save_player_pos_key = "save_player_pos"; var save_player_pos_key = "save_player_pos";
var shareOptions = {
socials: ['fbFeed', 'tw', 'reddit', 'email'],
url: short_url,
title: player_data.title,
description: player_data.description,
image: player_data.thumbnail,
embedCode: "<iframe id='ivplayer' width='640' height='360' src='" + embed_url + "' style='border:none;'></iframe>"
}
videojs.Vhs.xhr.beforeRequest = function(options) { videojs.Vhs.xhr.beforeRequest = function(options) {
if (options.uri.indexOf('videoplayback') === -1 && options.uri.indexOf('local=true') === -1) { if (options.uri.indexOf('videoplayback') === -1 && options.uri.indexOf('local=true') === -1) {
options.uri = options.uri + '?local=true'; options.uri = options.uri + '?local=true';
@ -59,34 +49,55 @@ videojs.Vhs.xhr.beforeRequest = function(options) {
var player = videojs('player', options); var player = videojs('player', options);
const storage = (() => { /**
try { * Function for add time argument to url
if (localStorage.length !== -1) { * @param {String} url
return localStorage; * @returns urlWithTimeArg
} */
} catch (e) { function addCurrentTimeToURL(url) {
console.info('No storage available: ' + e); var urlUsed = new URL(url);
urlUsed.searchParams.delete('start');
var currentTime = Math.ceil(player.currentTime());
if (currentTime > 0)
urlUsed.searchParams.set('t', currentTime);
else if (urlUsed.searchParams.has('t'))
urlUsed.searchParams.delete('t');
return urlUsed;
}
var shareOptions = {
socials: ['fbFeed', 'tw', 'reddit', 'email'],
get url() {
return addCurrentTimeToURL(short_url);
},
title: player_data.title,
description: player_data.description,
image: player_data.thumbnail,
get embedCode() {
return "<iframe id='ivplayer' width='640' height='360' src='" +
addCurrentTimeToURL(embed_url) + "' style='border:none;'></iframe>";
} }
};
const storage = (() => {
try { if (localStorage.length !== -1) return localStorage; }
catch (e) { console.info('No storage available: ' + e); }
return undefined; return undefined;
})(); })();
if (location.pathname.startsWith('/embed/')) { if (location.pathname.startsWith('/embed/')) {
var overlay_content = '<h1><a rel="noopener" target="_blank" href="' + location.origin + '/watch?v=' + video_data.id + '">' + player_data.title + '</a></h1>';
player.overlay({ player.overlay({
overlays: [{ overlays: [
start: 'loadstart', { start: 'loadstart', content: overlay_content, end: 'playing', align: 'top'},
content: '<h1><a rel="noopener" target="_blank" href="' + location.origin + '/watch?v=' + video_data.id + '">' + player_data.title + '</a></h1>', { start: 'pause', content: overlay_content, end: 'playing', align: 'top'}
end: 'playing', ]
align: 'top'
}, {
start: 'pause',
content: '<h1><a rel="noopener" target="_blank" href="' + location.origin + '/watch?v=' + video_data.id + '">' + player_data.title + '</a></h1>',
end: 'playing',
align: 'top'
}]
}); });
} }
// Detect mobile users and initalize mobileUi for better UX // Detect mobile users and initialize mobileUi for better UX
// Detection code taken from https://stackoverflow.com/a/20293441 // Detection code taken from https://stackoverflow.com/a/20293441
function isMobile() { function isMobile() {
@ -99,9 +110,7 @@ if (isMobile()) {
buttons = ["playToggle", "volumePanel", "captionsButton"]; buttons = ["playToggle", "volumePanel", "captionsButton"];
if (video_data.params.quality !== 'dash') { if (video_data.params.quality !== 'dash') buttons.push("qualitySelector")
buttons.push("qualitySelector")
}
// Create new control bar object for operation buttons // Create new control bar object for operation buttons
const ControlBar = videojs.getComponent("controlBar"); const ControlBar = videojs.getComponent("controlBar");
@ -119,7 +128,7 @@ if (isMobile()) {
operations_bar_element.className += " mobile-operations-bar" operations_bar_element.className += " mobile-operations-bar"
player.addChild(operations_bar) player.addChild(operations_bar)
// Playback menu doesn't work when its initalized outside of the primary control bar // Playback menu doesn't work when it's initialized outside of the primary control bar
playback_element = document.getElementsByClassName("vjs-playback-rate")[0] playback_element = document.getElementsByClassName("vjs-playback-rate")[0]
operations_bar_element.append(playback_element) operations_bar_element.append(playback_element)
@ -138,7 +147,7 @@ if (isMobile()) {
player.on('error', function (event) { player.on('error', function (event) {
if (player.error().code === 2 || player.error().code === 4) { if (player.error().code === 2 || player.error().code === 4) {
setTimeout(function (event) { setTimeout(function (event) {
console.log('An error occured in the player, reloading...'); console.log('An error occurred in the player, reloading...');
var currentTime = player.currentTime(); var currentTime = player.currentTime();
var playbackRate = player.playbackRate(); var playbackRate = player.playbackRate();
@ -146,16 +155,12 @@ player.on('error', function (event) {
player.load(); player.load();
if (currentTime > 0.5) { if (currentTime > 0.5) currentTime -= 0.5;
currentTime -= 0.5;
}
player.currentTime(currentTime); player.currentTime(currentTime);
player.playbackRate(playbackRate); player.playbackRate(playbackRate);
if (!paused) { if (!paused) player.play();
player.play();
}
}, 5000); }, 5000);
} }
}); });
@ -183,13 +188,8 @@ if (video_data.params.video_start > 0 || video_data.params.video_end > 0) {
player.markers({ player.markers({
onMarkerReached: function (marker) { onMarkerReached: function (marker) {
if (marker.text === 'End') { if (marker.text === 'End')
if (player.loop()) { player.loop() ? player.markers.prev('Start') : player.pause();
player.markers.prev('Start');
} else {
player.pause();
}
}
}, },
markers: markers markers: markers
}); });
@ -217,9 +217,7 @@ if (video_data.params.save_player_pos) {
const remeberedTime = get_video_time(); const remeberedTime = get_video_time();
let lastUpdated = 0; let lastUpdated = 0;
if(!hasTimeParam) { if(!hasTimeParam) set_seconds_after_start(remeberedTime);
set_seconds_after_start(remeberedTime);
}
const updateTime = () => { const updateTime = () => {
const raw = player.currentTime(); const raw = player.currentTime();
@ -233,9 +231,7 @@ if (video_data.params.save_player_pos) {
player.on("timeupdate", updateTime); player.on("timeupdate", updateTime);
} }
else { else remove_all_video_times();
remove_all_video_times();
}
if (video_data.params.autoplay) { if (video_data.params.autoplay) {
var bpb = player.getChild('bigPlayButton'); var bpb = player.getChild('bigPlayButton');
@ -433,26 +429,10 @@ function set_time_percent(percent) {
player.currentTime(newTime); player.currentTime(newTime);
} }
function play() { function play() { player.play(); }
player.play(); function pause() { player.pause(); }
} function stop() { player.pause(); player.currentTime(0); }
function toggle_play() { player.paused() ? play() : pause(); }
function pause() {
player.pause();
}
function stop() {
player.pause();
player.currentTime(0);
}
function toggle_play() {
if (player.paused()) {
play();
} else {
pause();
}
}
const toggle_captions = (function () { const toggle_captions = (function () {
let toggledTrack = null; let toggledTrack = null;
@ -490,9 +470,7 @@ const toggle_captions = (function () {
const tracks = player.textTracks(); const tracks = player.textTracks();
for (let i = 0; i < tracks.length; i++) { for (let i = 0; i < tracks.length; i++) {
const track = tracks[i]; const track = tracks[i];
if (track.kind !== 'captions') { if (track.kind !== 'captions') continue;
continue;
}
if (fallbackCaptionsTrack === null) { if (fallbackCaptionsTrack === null) {
fallbackCaptionsTrack = track; fallbackCaptionsTrack = track;
@ -513,11 +491,7 @@ const toggle_captions = (function () {
})(); })();
function toggle_fullscreen() { function toggle_fullscreen() {
if (player.isFullscreen()) { player.isFullscreen() ? player.exitFullscreen() : player.requestFullscreen();
player.exitFullscreen();
} else {
player.requestFullscreen();
}
} }
function increase_playback_rate(steps) { function increase_playback_rate(steps) {
@ -560,27 +534,15 @@ window.addEventListener('keydown', e => {
action = toggle_play; action = toggle_play;
break; break;
case 'MediaPlay': case 'MediaPlay': action = play; break;
action = play; case 'MediaPause': action = pause; break;
break; case 'MediaStop': action = stop; break;
case 'MediaPause':
action = pause;
break;
case 'MediaStop':
action = stop;
break;
case 'ArrowUp': case 'ArrowUp':
if (isPlayerFocused) { if (isPlayerFocused) action = increase_volume.bind(this, 0.1);
action = increase_volume.bind(this, 0.1);
}
break; break;
case 'ArrowDown': case 'ArrowDown':
if (isPlayerFocused) { if (isPlayerFocused) action = increase_volume.bind(this, -0.1);
action = increase_volume.bind(this, -0.1);
}
break; break;
case 'm': case 'm':
@ -612,16 +574,15 @@ window.addEventListener('keydown', e => {
case '7': case '7':
case '8': case '8':
case '9': case '9':
// Ignore numpad numbers
if (code > 57) break;
const percent = (code - 48) * 10; const percent = (code - 48) * 10;
action = set_time_percent.bind(this, percent); action = set_time_percent.bind(this, percent);
break; break;
case 'c': case 'c': action = toggle_captions; break;
action = toggle_captions; case 'f': action = toggle_fullscreen; break;
break;
case 'f':
action = toggle_fullscreen;
break;
case 'N': case 'N':
case 'MediaTrackNext': case 'MediaTrackNext':
@ -639,12 +600,8 @@ window.addEventListener('keydown', e => {
// TODO: Add support for previous-frame-stepping. // TODO: Add support for previous-frame-stepping.
break; break;
case '>': case '>': action = increase_playback_rate.bind(this, 1); break;
action = increase_playback_rate.bind(this, 1); case '<': action = increase_playback_rate.bind(this, -1); break;
break;
case '<':
action = increase_playback_rate.bind(this, -1);
break;
default: default:
console.info('Unhandled key down event: %s:', decoratedKey, e); console.info('Unhandled key down event: %s:', decoratedKey, e);

@ -77,7 +77,7 @@ function update_mode (mode) {
// If preference for dark mode indicated // If preference for dark mode indicated
set_mode(true); set_mode(true);
} }
else if (mode === 'false' /* for backwards compaibility */ || mode === 'light') { else if (mode === 'false' /* for backwards compatibility */ || mode === 'light') {
// If preference for light mode indicated // If preference for light mode indicated
set_mode(false); set_mode(false);
} }

@ -163,7 +163,7 @@ https_only: false
#use_quic: false #use_quic: false
## ##
## Additionnal cookies to be sent when requesting the youtube API. ## Additional cookies to be sent when requesting the youtube API.
## ##
## Accepted values: a string in the format "name1=value1; name2=value2..." ## Accepted values: a string in the format "name1=value1; name2=value2..."
## Default: <none> ## Default: <none>
@ -188,7 +188,7 @@ https_only: false
## ##
## Path to log file. Can be absolute or relative to the invidious ## Path to log file. Can be absolute or relative to the invidious
## binary. This is overriden if "-o OUTPUT" or "--output=OUTPUT" ## binary. This is overridden if "-o OUTPUT" or "--output=OUTPUT"
## are passed on the command line. ## are passed on the command line.
## ##
## Accepted values: a filesystem path or 'STDOUT' ## Accepted values: a filesystem path or 'STDOUT'
@ -197,7 +197,7 @@ https_only: false
#output: STDOUT #output: STDOUT
## ##
## Logging Verbosity. This is overriden if "-l LEVEL" or ## Logging Verbosity. This is overridden if "-l LEVEL" or
## "--log-level=LEVEL" are passed on the command line. ## "--log-level=LEVEL" are passed on the command line.
## ##
## Accepted values: All, Trace, Debug, Info, Warn, Error, Fatal, Off ## Accepted values: All, Trace, Debug, Info, Warn, Error, Fatal, Off
@ -306,7 +306,7 @@ https_only: false
## ##
## Notes: ## Notes:
## - Setting this to 0 will disable the channel videos crawl job. ## - Setting this to 0 will disable the channel videos crawl job.
## - This setting is overriden if "-c THREADS" or ## - This setting is overridden if "-c THREADS" or
## "--channel-threads=THREADS" are passed on the command line. ## "--channel-threads=THREADS" are passed on the command line.
## ##
## Accepted values: a positive integer ## Accepted values: a positive integer
@ -314,6 +314,15 @@ https_only: false
## ##
channel_threads: 1 channel_threads: 1
##
## Time interval between two executions of the job that crawls
## channel videos (subscriptions update).
##
## Accepted values: a valid time interval (like 1h30m or 90m)
## Default: 30m
##
#channel_refresh_interval: 30m
## ##
## Forcefully dump and re-download the entire list of uploaded ## Forcefully dump and re-download the entire list of uploaded
## videos when crawling channel (during subscriptions update). ## videos when crawling channel (during subscriptions update).
@ -328,7 +337,7 @@ full_refresh: false
## ##
## Notes: ## Notes:
## - Setting this to 0 will disable the channel videos crawl job. ## - Setting this to 0 will disable the channel videos crawl job.
## - This setting is overriden if "-f THREADS" or ## - This setting is overridden if "-f THREADS" or
## "--feed-threads=THREADS" are passed on the command line. ## "--feed-threads=THREADS" are passed on the command line.
## ##
## Accepted values: a positive integer ## Accepted values: a positive integer
@ -371,7 +380,7 @@ feed_threads: 1
# ----------------------------- # -----------------------------
# Miscellanous # Miscellaneous
# ----------------------------- # -----------------------------
## ##
@ -433,7 +442,7 @@ feed_threads: 1
#cache_annotations: false #cache_annotations: false
## ##
## Source code URL. If your instance is running a modfied source ## Source code URL. If your instance is running a modified source
## code, you MUST publish it somewhere and set this option. ## code, you MUST publish it somewhere and set this option.
## ##
## Accepted values: a string ## Accepted values: a string
@ -520,9 +529,9 @@ default_user_preferences:
#region: US #region: US
## ##
## Top 3 prefered languages for video captions. ## Top 3 preferred languages for video captions.
## ##
## Note: overridin the default (no preferred ## Note: overriding the default (no preferred
## caption language) is not recommended, in order ## caption language) is not recommended, in order
## to not penalize people using other languages. ## to not penalize people using other languages.
## ##
@ -594,7 +603,7 @@ default_user_preferences:
#feed_menu: ["Popular", "Trending", "Subscriptions", "Playlists"] #feed_menu: ["Popular", "Trending", "Subscriptions", "Playlists"]
## ##
## Default feed to diplay on the home page. ## Default feed to display on the home page.
## ##
## Note: setting this option to "Popular" has no ## Note: setting this option to "Popular" has no
## effect when 'popular_enabled' is set to false. ## effect when 'popular_enabled' is set to false.
@ -812,7 +821,7 @@ default_user_preferences:
# ----------------------------- # -----------------------------
# Miscellanous # Miscellaneous
# ----------------------------- # -----------------------------
## ##
@ -847,3 +856,13 @@ default_user_preferences:
## Default: false ## Default: false
## ##
#automatic_instance_redirect: false #automatic_instance_redirect: false
##
## Show the entire video description by default (when set to 'false',
## only the first few lines of the description are shown and a
## "show more" button allows to expand it).
##
## Accepted values: true, false
## Default: false
##
#extend_desc: false

@ -1,18 +1,12 @@
version: '3' # Warning: This docker-compose file is made for development purposes.
# Using it will build an image from the locally cloned repository.
#
# If you want to use Invidious in production, see the docker-compose.yml file provided
# in the installation documentation: https://docs.invidious.io/Installation.md
version: "3"
services: services:
postgres:
image: postgres:10
restart: unless-stopped
volumes:
- postgresdata:/var/lib/postgresql/data
- ./config/sql:/config/sql
- ./docker/init-invidious-db.sh:/docker-entrypoint-initdb.d/init-invidious-db.sh
environment:
POSTGRES_DB: invidious
POSTGRES_PASSWORD: kemal
POSTGRES_USER: kemal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
invidious: invidious:
build: build:
context: . context: .
@ -21,27 +15,42 @@ services:
ports: ports:
- "127.0.0.1:3000:3000" - "127.0.0.1:3000:3000"
environment: environment:
# Adapted from ./config/config.yml # Please read the following file for a comprehensive list of all available
# configuration options and their associated syntax:
# https://github.com/iv-org/invidious/blob/master/config/config.example.yml
INVIDIOUS_CONFIG: | INVIDIOUS_CONFIG: |
channel_threads: 1
check_tables: true
feed_threads: 1
db: db:
dbname: invidious
user: kemal user: kemal
password: kemal password: kemal
host: postgres host: invidious-db
port: 5432 port: 5432
dbname: invidious check_tables: true
full_refresh: false # external_port:
https_only: false # domain:
domain: # https_only: false
# statistics_enabled: false
healthcheck: healthcheck:
test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/comments/jNQXAC9IVRw || exit 1 test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/comments/jNQXAC9IVRw || exit 1
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 2 retries: 2
depends_on: depends_on:
- postgres - invidious-db
invidious-db:
image: docker.io/library/postgres:14
restart: unless-stopped
volumes:
- postgresdata:/var/lib/postgresql/data
- ./config/sql:/config/sql
- ./docker/init-invidious-db.sh:/docker-entrypoint-initdb.d/init-invidious-db.sh
environment:
POSTGRES_DB: invidious
POSTGRES_USER: kemal
POSTGRES_PASSWORD: kemal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
volumes: volumes:
postgresdata: postgresdata:

@ -42,7 +42,7 @@ RUN addgroup -g 1000 -S invidious && \
adduser -u 1000 -S invidious -G invidious adduser -u 1000 -S invidious -G invidious
COPY --chown=invidious ./config/config.* ./config/ COPY --chown=invidious ./config/config.* ./config/
RUN mv -n config/config.example.yml config/config.yml RUN mv -n config/config.example.yml config/config.yml
RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: postgres/' config/config.yml RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: invidious-db/' config/config.yml
COPY ./config/sql/ ./config/sql/ COPY ./config/sql/ ./config/sql/
COPY ./locales/ ./locales/ COPY ./locales/ ./locales/
COPY --from=builder /invidious/assets ./assets/ COPY --from=builder /invidious/assets ./assets/

@ -41,7 +41,7 @@ RUN addgroup -g 1000 -S invidious && \
adduser -u 1000 -S invidious -G invidious adduser -u 1000 -S invidious -G invidious
COPY --chown=invidious ./config/config.* ./config/ COPY --chown=invidious ./config/config.* ./config/
RUN mv -n config/config.example.yml config/config.yml RUN mv -n config/config.example.yml config/config.yml
RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: postgres/' config/config.yml RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: invidious-db/' config/config.yml
COPY ./config/sql/ ./config/sql/ COPY ./config/sql/ ./config/sql/
COPY ./locales/ ./locales/ COPY ./locales/ ./locales/
COPY --from=builder /invidious/assets ./assets/ COPY --from=builder /invidious/assets ./assets/

@ -1,7 +1,7 @@
{ {
"LIVE": "مُباشِر", "LIVE": "مُباشِر",
"Shared `x` ago": "تمَّ رفع المقطع المرئيّ مُنذ `x`", "Shared `x` ago": "تمَّ رفع المقطع المرئيّ مُنذ `x`",
"Unsubscribe": "إلغاء الإشتراك", "Unsubscribe": "إلغاء الاشتراك",
"Subscribe": "الإشتراك", "Subscribe": "الإشتراك",
"View channel on YouTube": "زيارة القناة على موقع يوتيوب", "View channel on YouTube": "زيارة القناة على موقع يوتيوب",
"View playlist on YouTube": "عرض قائمة التشغيل على اليوتيوب", "View playlist on YouTube": "عرض قائمة التشغيل على اليوتيوب",
@ -13,7 +13,7 @@
"Previous page": "الصفحة السابقة", "Previous page": "الصفحة السابقة",
"Clear watch history?": "هل تريد محو سجل المشاهدة؟", "Clear watch history?": "هل تريد محو سجل المشاهدة؟",
"New password": "كلمة مرور جديدة", "New password": "كلمة مرور جديدة",
"New passwords must match": "يَجبُ أن تكون كلمتي المرور متطابقتان", "New passwords must match": "يَجبُ أن تكون كلمتا المرور متطابقتين",
"Cannot change password for Google accounts": "لا يُمكن تغيير كلمة المرور لِحسابات جوجل", "Cannot change password for Google accounts": "لا يُمكن تغيير كلمة المرور لِحسابات جوجل",
"Authorize token?": "رمز التفويض؟", "Authorize token?": "رمز التفويض؟",
"Authorize token for `x`?": "السماح بالرمز المميز ل 'x'؟", "Authorize token for `x`?": "السماح بالرمز المميز ل 'x'؟",
@ -27,8 +27,8 @@
"Import NewPipe subscriptions (.json)": "استيراد اشتراكات نيو بايب (.json)", "Import NewPipe subscriptions (.json)": "استيراد اشتراكات نيو بايب (.json)",
"Import NewPipe data (.zip)": "استيراد بيانات نيو بايب (.zip)", "Import NewPipe data (.zip)": "استيراد بيانات نيو بايب (.zip)",
"Export": "تصدير", "Export": "تصدير",
"Export subscriptions as OPML": "تصدير الاشتراكات كَ OPML", "Export subscriptions as OPML": "تصدير الاشتراكات كـOPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "تصدير الاشتراكات كَ OPML (لِنيو بايب و فريتيوب)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "تصدير الاشتراكات كـOPML (لِنيو بايب و فريتيوب)",
"Export data as JSON": "تصدير البيانات بتنسيق JSON", "Export data as JSON": "تصدير البيانات بتنسيق JSON",
"Delete account?": "حذف الحساب؟", "Delete account?": "حذف الحساب؟",
"History": "السِّجل", "History": "السِّجل",
@ -47,18 +47,18 @@
"Register": "التسجيل", "Register": "التسجيل",
"E-mail": "البريد الإلكتروني", "E-mail": "البريد الإلكتروني",
"Google verification code": "رمز تحقق جوجل", "Google verification code": "رمز تحقق جوجل",
"Preferences": "التفضيلات", "Preferences": "الإعدادات",
"preferences_category_player": "التفضيلات المُشغِّل", "preferences_category_player": "إعدادات المُشغِّل",
"preferences_video_loop_label": "كرر المقطع المرئيّ دائما: ", "preferences_video_loop_label": "كرر المقطع المرئيّ دائما: ",
"preferences_autoplay_label": "تشغيل تلقائي: ", "preferences_autoplay_label": "تشغيل تلقائي: ",
"preferences_continue_label": "شغل المقطع التالي تلقائيًا: ", "preferences_continue_label": "شغل المقطع التالي تلقائيًا: ",
"preferences_continue_autoplay_label": "شغل المقطع التالي تلقائيًا: ", "preferences_continue_autoplay_label": "شغل المقطع التالي تلقائيًا: ",
"preferences_listen_label": "تشغيل النسخة السمعية تلقائيًا: ", "preferences_listen_label": "تشغيل النسخة السمعية تلقائيًا: ",
"preferences_local_label": "بروكسي المقاطع المرئيّة؟ ", "preferences_local_label": "بروكسي المقاطع المرئيّة؟ ",
"preferences_speed_label": "السرعة الإفتراضية: ", "preferences_speed_label": "السرعة الافتراضية: ",
"preferences_quality_label": "الجودة المفضلة للمقاطع: ", "preferences_quality_label": "الجودة المفضلة للمقاطع: ",
"preferences_volume_label": "صوت المشغل: ", "preferences_volume_label": "صوت المشغل: ",
"preferences_comments_label": "التعليقات الإفتراضية: ", "preferences_comments_label": "التعليقات الافتراضية: ",
"youtube": "يوتيوب", "youtube": "يوتيوب",
"reddit": "ريديت", "reddit": "ريديت",
"preferences_captions_label": "التسميات التوضيحية الإفتراضية: ", "preferences_captions_label": "التسميات التوضيحية الإفتراضية: ",
@ -69,41 +69,41 @@
"preferences_vr_mode_label": "مقاطع فيديو تفاعلية ب درجة 360: ", "preferences_vr_mode_label": "مقاطع فيديو تفاعلية ب درجة 360: ",
"preferences_category_visual": "التفضيلات المرئية", "preferences_category_visual": "التفضيلات المرئية",
"preferences_player_style_label": "شكل مشغل الفيديوهات: ", "preferences_player_style_label": "شكل مشغل الفيديوهات: ",
"Dark mode: ": "الوضع الليلى: ", "Dark mode: ": "الوضع الليلي: ",
"preferences_dark_mode_label": "المظهر: ", "preferences_dark_mode_label": "المظهر: ",
"dark": "غامق (اسود)", "dark": "غامق (اسود)",
"light": "فاتح (ابيض)", "light": "فاتح (ابيض)",
"preferences_thin_mode_label": "الوضع الخفيف: ", "preferences_thin_mode_label": "الوضع الخفيف: ",
"preferences_category_misc": "تفضيلات متنوعة", "preferences_category_misc": "تفضيلات متنوعة",
"preferences_automatic_instance_redirect_label": "إعادة توجيه المثيل التلقائي (إعادة التوجيه إلى redirect.invidious.io): ", "preferences_automatic_instance_redirect_label": "إعادة توجيه المثيل التلقائي (إعادة التوجيه إلى redirect.invidious.io): ",
"preferences_category_subscription": "تفضيلات الإشتراك", "preferences_category_subscription": "تفضيلات الاشتراك",
"preferences_annotations_subscribed_label": "عرض الملاحظات في الفيديوهات تلقائيا في القنوات المشترك بها فقط: ", "preferences_annotations_subscribed_label": "عرض الملاحظات في الفيديوهات تلقائيا في القنوات المشترك بها فقط: ",
"Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ", "Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ",
"preferences_max_results_label": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ", "preferences_max_results_label": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ",
"preferences_sort_label": "ترتيب الفيديو ب: ", "preferences_sort_label": "ترتيب الفيديوهات بـ: ",
"published": "احدث فيديو", "published": "أحدث فيديو",
"published - reverse": "احدث فيديو - عكسى", "published - reverse": "أحدث فيديو - عكسي",
"alphabetically": "ترتيب ابجدى", "alphabetically": "ترتيب أبجدي",
"alphabetically - reverse": "ابجدى - عكسى", "alphabetically - reverse": "أبجدي - عكسي",
"channel name": إسم القناة", "channel name": اسم القناة",
"channel name - reverse": إسم القناة - عكسى", "channel name - reverse": اسم القناة - عكسى",
"Only show latest video from channel: ": "فقط إظهر اخر فيديو من القناة: ", "Only show latest video from channel: ": "فقط أظهر آخر فيديو من القناة: ",
"Only show latest unwatched video from channel: ": "فقط اظهر اخر فيديو لم يتم رؤيتة من القناة: ", "Only show latest unwatched video from channel: ": "فقط أظهر آخر فيديو لم يتم رؤيته من القناة: ",
"preferences_unseen_only_label": "فقط اظهر الذى لم يتم رؤيتة: ", "preferences_unseen_only_label": "فقط أظهر الذي لم يتم رؤيته: ",
"preferences_notifications_only_label": "إظهار الإشعارات فقط (إذا كان هناك أي): ", "preferences_notifications_only_label": "إظهار الإشعارات فقط (إذا كان هناك أي): ",
"Enable web notifications": "تفعيل إشعارات المتصفح", "Enable web notifications": "تفعيل إشعارات المتصفح",
"`x` uploaded a video": "`x` رفع فيديو", "`x` uploaded a video": "`x` رفع فيديو",
"`x` is live": "`x` فى بث مباشر", "`x` is live": "`x` في بث مباشر",
"preferences_category_data": "إعدادات التفضيلات", "preferences_category_data": "إعدادات التفضيلات",
"Clear watch history": "حذف سجل المشاهدة", "Clear watch history": "حذف سجل المشاهدة",
"Import/export data": "إضافة\\إستخراج البيانات", "Import/export data": "إضافة\\استخراج البيانات",
"Change password": "غير الرقم السرى", "Change password": "غير كلمة السر",
"Manage subscriptions": "إدارة المشتركين", "Manage subscriptions": "إدارة الاشتراكات",
"Manage tokens": "إدارة الرموز", "Manage tokens": "إدارة الرموز",
"Watch history": "سجل المشاهدة", "Watch history": "سجل المشاهدة",
"Delete account": "حذف الحساب", "Delete account": "حذف الحساب",
"preferences_category_admin": "إعدادات المدير", "preferences_category_admin": "إعدادات المدير",
"preferences_default_home_label": "الصفحة الرئيسية الافتراضية ", "preferences_default_home_label": "الصفحة الرئيسية الافتراضية: ",
"preferences_feed_menu_label": "قائمة التدفقات: ", "preferences_feed_menu_label": "قائمة التدفقات: ",
"preferences_show_nick_label": "إظهار اللقب في الأعلى: ", "preferences_show_nick_label": "إظهار اللقب في الأعلى: ",
"Top enabled: ": "تفعيل 'الأفضل' ؟ ", "Top enabled: ": "تفعيل 'الأفضل' ؟ ",
@ -111,14 +111,14 @@
"Login enabled: ": "تفعيل الولوج: ", "Login enabled: ": "تفعيل الولوج: ",
"Registration enabled: ": "تفعيل التسجيل: ", "Registration enabled: ": "تفعيل التسجيل: ",
"Report statistics: ": "الإبلاغ عن الإحصائيات: ", "Report statistics: ": "الإبلاغ عن الإحصائيات: ",
"Save preferences": "حفظ التفضيلات", "Save preferences": "حفظ الإعدادات",
"Subscription manager": "مدير الإشتراكات", "Subscription manager": "مدير الاشتراكات",
"Token manager": "إداره الرمز", "Token manager": "إداره الرمز",
"Token": "الرمز", "Token": "الرمز",
"Import/export": "إضافة\\إستخراج", "Import/export": "استيراد/تصدير",
"unsubscribe": "إلغاء الإشتراك", "unsubscribe": "إلغاء الاشتراك",
"revoke": "مسح", "revoke": "مسح",
"Subscriptions": "الإشتراكات", "Subscriptions": "الاشتراكات",
"search": "بحث", "search": "بحث",
"Log out": "تسجيل الخروج", "Log out": "تسجيل الخروج",
"Released under the AGPLv3 on Github.": "صدر تحت AGPLv3 على Github.", "Released under the AGPLv3 on Github.": "صدر تحت AGPLv3 على Github.",
@ -131,30 +131,30 @@
"Private": "خاص", "Private": "خاص",
"View all playlists": "عرض جميع قوائم التشغيل", "View all playlists": "عرض جميع قوائم التشغيل",
"Updated `x` ago": "تم تحديثه منذ `x`", "Updated `x` ago": "تم تحديثه منذ `x`",
"Delete playlist `x`?": "حذف قائمه التشغيل `x` ?", "Delete playlist `x`?": "حذف قائمة التشغيل `x`؟",
"Delete playlist": "حذف قائمه التغشيل", "Delete playlist": "حذف قائمة التغشيل",
"Create playlist": "إنشاء قائمه تشغيل", "Create playlist": "إنشاء قائمة تشغيل",
"Title": "العنوان", "Title": "العنوان",
"Playlist privacy": "إعدادات الخصوصيه", "Playlist privacy": "إعدادات الخصوصية",
"Editing playlist `x`": "تعديل قائمه التشفيل `x`", "Editing playlist `x`": "تعديل قائمة التشغيل `x`",
"Show more": "أظهر المزيد", "Show more": "إظهار المزيد",
"Show less": "عرض اقل", "Show less": "عرض اقل",
"Watch on YouTube": "مشاهدة الفيديو على اليوتيوب", "Watch on YouTube": "مشاهدة الفيديو على اليوتيوب",
"Switch Invidious Instance": "تبديل المثيل Invidious", "Switch Invidious Instance": "تبديل المثيل Invidious",
"Broken? Try another Invidious Instance": "معطل؟ جرب مثيل Invidious آخر", "Broken? Try another Invidious Instance": "معطل؟ جرب مثيل Invidious آخر",
"Hide annotations": "إخفاء الملاحظات فى الفيديو", "Hide annotations": "إخفاء الملاحظات في الفيديو",
"Show annotations": "عرض الملاحظات فى الفيديو", "Show annotations": "عرض الملاحظات في الفيديو",
"Genre: ": "النوع: ", "Genre: ": "النوع: ",
"License: ": "التراخيص: ", "License: ": "التراخيص: ",
"Family friendly? ": "محتوى عائلى? ", "Family friendly? ": "محتوى عائلي؟ ",
"Wilson score: ": "درجة ويلسون: ", "Wilson score: ": "درجة ويلسون: ",
"Engagement: ": "نسبة المشاركة (عدد المشاهدات\\عدد الإعجابات): ", "Engagement: ": "نسبة المشاركة: ",
"Whitelisted regions: ": "الدول المسموح فيها هذا الفيديو: ", "Whitelisted regions: ": "الدول المسموح فيها هذا الفيديو: ",
"Blacklisted regions: ": "الدول الحظور فيها هذا الفيديو: ", "Blacklisted regions: ": "الدول المحظور فيها هذا الفيديو: ",
"Shared `x`": "شارك منذ `x`", "Shared `x`": "شارك منذ `x`",
"Premieres in `x`": "يعرض فى `x`", "Premieres in `x`": "يعرض فى `x`",
"Premieres `x`": "يعرض `x`", "Premieres `x`": "يعرض `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.": "اهلا! يبدو ان الجافاسكريبت معطلة. اضغط هنا لعرض التعليقات, ضع فى إعتبارك انها ستأخذ وقت اطول للعرض.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "أهلًا! يبدو أن جافاسكريبت معطلٌ لديك. اضغط هنا لعرض التعليقات، وَضَع في اعتبارك أنها ستأخذ وقتًا أطول للتحميل.",
"View YouTube comments": "عرض تعليقات اليوتيوب", "View YouTube comments": "عرض تعليقات اليوتيوب",
"View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع Reddit", "View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع Reddit",
"View `x` comments": { "View `x` comments": {
@ -164,25 +164,25 @@
"View Reddit comments": "عرض تعليقات ريدإت Reddit", "View Reddit comments": "عرض تعليقات ريدإت Reddit",
"Hide replies": "إخفاء الردود", "Hide replies": "إخفاء الردود",
"Show replies": "عرض الردود", "Show replies": "عرض الردود",
"Incorrect password": "الرقم السرى غير صحيح", "Incorrect password": "كلمة السر غير صحيحة",
"Quota exceeded, try again in a few hours": "تم تجاوز عدد المرات المسموح بها, حاول مرة اخرى بعد عدة ساعات", "Quota exceeded, try again in a few hours": "تم تجاوز عدد المرات المسموح بها، حاول مجددًا بعد بضع ساعات",
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "غير قادر على تسجيل الدخول, تأكد من تشغيل المصادقة الثنائية 2FA.", "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "غير قادر على تسجيل الدخول، تأكد من تشغيل المصادقة الثنائية 2FA.",
"Invalid TFA code": "كود مصادقة ثنائية 2FA غير صحيح", "Invalid TFA code": "كود مصادقة ثنائية 2FA غير صحيح",
"Login failed. This may be because two-factor authentication is not turned on for your account.": "لم يتم تسجيل الدخول. هذا ربما بسبب ان المصادقة الثنائية 2FA معطلة فى حسابك.", "Login failed. This may be because two-factor authentication is not turned on for your account.": "فشل تسجيل الدخول. قد يكون هذا بسبب أن المصادقة الثنائية 2FA معطلة في حسابك.",
"Wrong answer": "إجابة خاطئة", "Wrong answer": "إجابة خاطئة",
"Erroneous CAPTCHA": "الكابتشا CAPTCHA غير صاحلة", "Erroneous CAPTCHA": "الكابتشا CAPTCHA غير صاحلة",
"CAPTCHA is a required field": "مكان الكابتشا CAPTCHA مطلوب", "CAPTCHA is a required field": "مكان الكابتشا CAPTCHA مطلوب",
"User ID is a required field": "مكان إسم المستخدم مطلوب", "User ID is a required field": "مكان اسم المستخدم مطلوب",
"Password is a required field": "مكان الرقم السرى مطلوب", "Password is a required field": "مكان كلمة السر مطلوب",
"Wrong username or password": "إسم المستخدم او الرقم السرى غير صحيح", "Wrong username or password": "اسم المستخدم او كلمة السر غير صحيح",
"Please sign in using 'Log in with Google'": "الرجاء تسجيل الدخول 'تسجيل الدخول بواسطة جوجل'", "Please sign in using 'Log in with Google'": "الرجاء تسجيل الدخول 'تسجيل الدخول بواسطة جوجل'",
"Password cannot be empty": "الرقم السرى لايمكن ان يكون فارغ", "Password cannot be empty": "لا يمكن أن تكون كلمة السر فارغة",
"Password cannot be longer than 55 characters": "الرقم السرى لا يتعدى 55 حرف", "Password cannot be longer than 55 characters": "يجب أن لا تتعدى كلمة السر 55 حرفًا",
"Please log in": "الرجاء تسجيل الدخول", "Please log in": "الرجاء تسجيل الدخول",
"Invidious Private Feed for `x`": "تغذية Invidious خاصة ل 'x'", "Invidious Private Feed for `x`": "تغذية Invidious خاصة ل 'x'",
"channel:`x`": "قناة:`x`", "channel:`x`": "قناة:`x`",
"Deleted or invalid channel": "قناة ممسوحة او غير صالحة", "Deleted or invalid channel": "قناة ممسوحة او غير صالحة",
"This channel does not exist.": "القناة غير موجودة.", "This channel does not exist.": "هذه القناة غير موجودة.",
"Could not get channel info.": "لم يستطع الحصول على معلومات القناة.", "Could not get channel info.": "لم يستطع الحصول على معلومات القناة.",
"Could not fetch comments": "لم يتمكن من إحضار التعليقات", "Could not fetch comments": "لم يتمكن من إحضار التعليقات",
"`x` ago": "`x` منذ", "`x` ago": "`x` منذ",
@ -192,22 +192,22 @@
"Not a playlist.": "قائمة التشغيل غير صالحة.", "Not a playlist.": "قائمة التشغيل غير صالحة.",
"Playlist does not exist.": "قائمة التشغيل غير موجودة.", "Playlist does not exist.": "قائمة التشغيل غير موجودة.",
"Could not pull trending pages.": "لم يستطع عرض الصفحات الراجئة.", "Could not pull trending pages.": "لم يستطع عرض الصفحات الراجئة.",
"Hidden field \"challenge\" is a required field": "مكان مخفى \"تحدى\" مكان مطلوب", "Hidden field \"challenge\" is a required field": "مكان مخفي \"تحدي\" مكان مطلوب",
"Hidden field \"token\" is a required field": "مكان مخفى \"رمز\" مكان مطلوب", "Hidden field \"token\" is a required field": "مكان مخفي \"رمز\" مكان مطلوب",
"Erroneous challenge": "تحدى غير صالح", "Erroneous challenge": "تحدي غير صالح",
"Erroneous token": "روز غير صالح", "Erroneous token": "روز غير صالح",
"No such user": "مستخدم غير صالح", "No such user": "مستخدم غير صالح",
"Token is expired, please try again": "الرمز منتهى الصلاحية , الرجاء المحاولة مرة اخرى", "Token is expired, please try again": "الرمز منتهى الصلاحية، الرجاء المحاولة مرة اخرى",
"English": "إنجليزى", "English": "إنجليزي",
"English (auto-generated)": "إنجليزى (تم إنشائة تلقائى)", "English (auto-generated)": "إنجليزي (تم إنشائه تلقائيًا)",
"Afrikaans": "الأفريكانية", "Afrikaans": "الأفريكانية",
"Albanian": "الألبانية", "Albanian": "الألبانية",
"Amharic": "الأمهرية", "Amharic": "الأمهرية",
"Arabic": "العربية", "Arabic": "العربية",
"Armenian": "الأرميني", "Armenian": "الأرمينية",
"Azerbaijani": "أذربيجان", "Azerbaijani": "أذربيجانية",
"Bangla": "البنغالية", "Bangla": "البنغالية",
"Basque": "الباسكي", "Basque": "الباسكية",
"Belarusian": "البيلاروسية", "Belarusian": "البيلاروسية",
"Bosnian": "البوسنية", "Bosnian": "البوسنية",
"Bulgarian": "البلغارية", "Bulgarian": "البلغارية",
@ -318,18 +318,18 @@
"News": "الأخبار", "News": "الأخبار",
"Movies": "الأفلام", "Movies": "الأفلام",
"Download": "نزّل", "Download": "نزّل",
"Download as: ": "نزله ك:. ", "Download as: ": "نزله كـ: ",
"%A %B %-d, %Y": "%A %-d %B %Y", "%A %B %-d, %Y": "%A %-d %B %Y",
"(edited)": "(تم تعديلة)", "(edited)": "(معدّل)",
"YouTube comment permalink": "رابط التعليق على اليوتيوب", "YouTube comment permalink": "رابط التعليق على اليوتيوب",
"permalink": "الرابط", "permalink": "الرابط",
"`x` marked it with a ❤": "`x` اعجب بهذا", "`x` marked it with a ❤": "`x` أعجب بهذا",
"Audio mode": "الوضع الصوتى", "Audio mode": "الوضع الصوتي",
"Video mode": "وضع الفيديو", "Video mode": "وضع الفيديو",
"Videos": "الفيديوهات", "Videos": "الفيديوهات",
"Playlists": "قوائم التشغيل", "Playlists": "قوائم التشغيل",
"Community": "المجتمع", "Community": "المجتمع",
"relevance": "ملاءم", "relevance": "ملاؤم",
"rating": "تقييم", "rating": "تقييم",
"date": "التاريخ", "date": "التاريخ",
"views": "مشاهدات", "views": "مشاهدات",
@ -339,9 +339,9 @@
"sort": "فرز", "sort": "فرز",
"hour": "ساعة", "hour": "ساعة",
"today": "اليوم", "today": "اليوم",
"week": "إسبوع", "week": "هذا الأسبوع",
"month": "شهر", "month": "هذا الشهر",
"year": "سنة", "year": "هذه السنة",
"video": "فيديو", "video": "فيديو",
"channel": "قناة", "channel": "قناة",
"playlist": "قائمة التشغيل", "playlist": "قائمة التشغيل",
@ -353,7 +353,7 @@
"3d": "ثلاثي الأبعاد", "3d": "ثلاثي الأبعاد",
"live": "مباشر", "live": "مباشر",
"4k": "4k", "4k": "4k",
"location": "الاماكن", "location": "الأماكن",
"hdr": "وضع التباين العالي", "hdr": "وضع التباين العالي",
"filter": "معامل الفرز", "filter": "معامل الفرز",
"Current version: ": "الإصدار الحالي: ", "Current version: ": "الإصدار الحالي: ",
@ -368,7 +368,7 @@
"adminprefs_modified_source_code_url_label": "URL إلى مستودع التعليمات البرمجية المصدرية المعدلة", "adminprefs_modified_source_code_url_label": "URL إلى مستودع التعليمات البرمجية المصدرية المعدلة",
"footer_documentation": "التوثيق", "footer_documentation": "التوثيق",
"footer_donate_page": "تبرّع", "footer_donate_page": "تبرّع",
"preferences_region_label": "بلد المحتوى:. ", "preferences_region_label": "بلد المحتوى: ",
"preferences_quality_dash_label": "جودة فيديو DASH المفضلة: ", "preferences_quality_dash_label": "جودة فيديو DASH المفضلة: ",
"preferences_quality_option_dash": "DASH (جودة تكييفية)", "preferences_quality_option_dash": "DASH (جودة تكييفية)",
"preferences_quality_option_hd720": "HD720", "preferences_quality_option_hd720": "HD720",
@ -398,5 +398,36 @@
"360": "360°", "360": "360°",
"download_subtitles": "ترجمات - 'x' (.vtt)", "download_subtitles": "ترجمات - 'x' (.vtt)",
"invidious": "الخيالي", "invidious": "الخيالي",
"preferences_save_player_pos_label": "احفظ وقت الفيديو الحالي: " "preferences_save_player_pos_label": "احفظ وقت الفيديو الحالي: ",
"crash_page_you_found_a_bug": "يبدو أنك قد وجدت خطأً برمجيًّا في Invidious!",
"generic_videos_count_0": "لا فيديوهات",
"generic_videos_count_1": "فيديو واحد",
"generic_videos_count_2": "فيديوهين",
"generic_videos_count_3": "{{count}} فيديوهات",
"generic_videos_count_4": "{{count}} فيديو",
"generic_videos_count_5": "{{count}} فيديو",
"generic_subscribers_count_0": "لا مشتركين",
"generic_subscribers_count_1": "مشترك واحد",
"generic_subscribers_count_2": "مشتركان",
"generic_subscribers_count_3": "{{count}} مشتركين",
"generic_subscribers_count_4": "{{count}} مشترك",
"generic_subscribers_count_5": "{{count}} مشترك",
"generic_views_count_0": "لا مشاهدات",
"generic_views_count_1": "مشاهدة واحدة",
"generic_views_count_2": "مشاهدتان",
"generic_views_count_3": "{{count}} مشاهدات",
"generic_views_count_4": "{{count}} مشاهدة",
"generic_views_count_5": "{{count}} مشاهدة",
"generic_subscriptions_count_0": "لا اشتراكات",
"generic_subscriptions_count_1": "اشتراك واحد",
"generic_subscriptions_count_2": "اشتراكان",
"generic_subscriptions_count_3": "{{count}} اشتراكات",
"generic_subscriptions_count_4": "{{count}} اشتراك",
"generic_subscriptions_count_5": "{{count}} اشتراك",
"generic_playlists_count_0": "لا قوائم تشغيل",
"generic_playlists_count_1": "قائمة تشغيل واحدة",
"generic_playlists_count_2": "قائمتا تشغيل",
"generic_playlists_count_3": "{{count}} قوائم تشغيل",
"generic_playlists_count_4": "{{count}} قائمة تشغيل",
"generic_playlists_count_5": "{{count}} قائمة تشغيل"
} }

@ -21,15 +21,15 @@
"No": "Όχι", "No": "Όχι",
"Import and Export Data": "Εισαγωγή και Εξαγωγή Δεδομένων", "Import and Export Data": "Εισαγωγή και Εξαγωγή Δεδομένων",
"Import": "Εισαγωγή", "Import": "Εισαγωγή",
"Import Invidious data": "Εισαγωγή δεδομένων Invidious", "Import Invidious data": "Εsαγωγή δεδομένων Invidious JSON",
"Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube", "Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Εισαγωγή συνδρομών FreeTube (.db)", "Import FreeTube subscriptions (.db)": "Εισαγωγή συνδρομών FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Εισαγωγή συνδρομών NewPipe (.json)", "Import NewPipe subscriptions (.json)": "Εισαγωγή συνδρομών NewPipe (.json)",
"Import NewPipe data (.zip)": "Εισαγωγή δεδομένων NewPipe (.zip)", "Import NewPipe data (.zip)": "Εισαγωγή δεδομένων NewPipe (.zip)",
"Export": "Εξαγωγή", "Export": "Εξαγωγή",
"Export subscriptions as OPML": "Εξαγωγή συνδρομών ως OPML", "Export subscriptions as OPML": "Εξαγωγή συνδρομών ως OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Εξαγωγή συνδρομών ως OPML (για NewPipe & FreeTube)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Εξαγωγή συνδρομών ως OPML (για NewPipe & FreeTube)",
"Export data as JSON": "Εξαγωγή δεδομένων ως JSON", "Export data as JSON": "Εξαγωγή δεδομένων Invidious ως JSON",
"Delete account?": "Διαγραφή λογαριασμού;", "Delete account?": "Διαγραφή λογαριασμού;",
"History": "Ιστορικό", "History": "Ιστορικό",
"An alternative front-end to YouTube": "Μία εναλλακτική πλατφόρμα για το YouTube", "An alternative front-end to YouTube": "Μία εναλλακτική πλατφόρμα για το YouTube",
@ -419,7 +419,34 @@
"Search": "Αναζήτηση", "Search": "Αναζήτηση",
"hdr": "HDR", "hdr": "HDR",
"preferences_extend_desc_label": "Αυτόματη επέκταση της περιγραφής του βίντεο: ", "preferences_extend_desc_label": "Αυτόματη επέκταση της περιγραφής του βίντεο: ",
"preferences_vr_mode_label": "Διαδραστικά βίντεο 360 μοιρών: ", "preferences_vr_mode_label": "Διαδραστικά βίντεο 360 μοιρών (απαιτεί WebGL): ",
"Show less": "Εμφάνιση λιγότερων", "Show less": "Εμφάνιση λιγότερων",
"footer_source_code": "Πηγαίος κώδικας" "footer_source_code": "Πηγαίος κώδικας",
"Chinese (Taiwan)": "Κινέζικα (Ταϊβάν)",
"Portuguese (Brazil)": "Πορτογαλικά (Βραζιλία)",
"German (auto-generated)": "Γερμανικά (αυτόματη παραγωγή)",
"Korean (auto-generated)": "Κορεάτικα (αυτόματη παραγωγή)",
"Russian (auto-generated)": "Ρωσικά (αυτόματη παραγωγή)",
"Spanish (auto-generated)": "Ισπανικά (αυτόματη παραγωγή)",
"Vietnamese (auto-generated)": "Βιετναμέζικα (αυτόματη παραγωγή)",
"English (United Kingdom)": "Αγγλικά (Ηνωμένο Βασίλειο)",
"English (United States)": "Αγγλικά (Ηνωμένων Πολιτειών)",
"Cantonese (Hong Kong)": "Καντονέζικα (Χονγκ Κονγκ)",
"Chinese": "Κινεζικά",
"Chinese (China)": "Κινέζικα (Κίνα)",
"Chinese (Hong Kong)": "Κινεζικά (Χονγκ Κονγκ)",
"Dutch (auto-generated)": "Ολαμδικά (αυτόματη παραγωγή)",
"French (auto-generated)": "Γαλλικά (αυτόματη παραγωγή)",
"Interlingue": "Ιντερλίνγκουα",
"Indonesian (auto-generated)": "Ινδονησιακά (αυτόματη παραγωγή)",
"Italian (auto-generated)": "Ιταλικά (αυτόματη παραγωγή)",
"Japanese (auto-generated)": "Ιαπωνικά (αυτόματη παραγωγή)",
"Portuguese (auto-generated)": "Πορτογαλικά (αυτόματη παραγωγή)",
"Spanish (Mexico)": "Ισπανικά (Μεξικό)",
"Spanish (Spain)": "Ισπανικά (Ισπανία)",
"Turkish (auto-generated)": "Τούρκικα (αυτόματη παραγωγή)",
"none": "κανένα",
"videoinfo_youTube_embed_link": "Ενσωμάτωση",
"videoinfo_invidious_embed_link": "Σύνδεσμος Ενσωμάτωσης",
"show": "Μπάρα προόδου διαβάσματος"
} }

@ -31,15 +31,15 @@
"No": "No", "No": "No",
"Import and Export Data": "Import and Export Data", "Import and Export Data": "Import and Export Data",
"Import": "Import", "Import": "Import",
"Import Invidious data": "Import Invidious data", "Import Invidious data": "Import Invidious JSON data",
"Import YouTube subscriptions": "Import YouTube subscriptions", "Import YouTube subscriptions": "Import YouTube/OPML subscriptions",
"Import FreeTube subscriptions (.db)": "Import FreeTube subscriptions (.db)", "Import FreeTube subscriptions (.db)": "Import FreeTube subscriptions (.db)",
"Import NewPipe subscriptions (.json)": "Import NewPipe subscriptions (.json)", "Import NewPipe subscriptions (.json)": "Import NewPipe subscriptions (.json)",
"Import NewPipe data (.zip)": "Import NewPipe data (.zip)", "Import NewPipe data (.zip)": "Import NewPipe data (.zip)",
"Export": "Export", "Export": "Export",
"Export subscriptions as OPML": "Export subscriptions as OPML", "Export subscriptions as OPML": "Export subscriptions as OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Export subscriptions as OPML (for NewPipe & FreeTube)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Export subscriptions as OPML (for NewPipe & FreeTube)",
"Export data as JSON": "Export data as JSON", "Export data as JSON": "Export Invidious data as JSON",
"Delete account?": "Delete account?", "Delete account?": "Delete account?",
"History": "History", "History": "History",
"An alternative front-end to YouTube": "An alternative front-end to YouTube", "An alternative front-end to YouTube": "An alternative front-end to YouTube",
@ -65,6 +65,7 @@
"preferences_continue_autoplay_label": "Autoplay next video: ", "preferences_continue_autoplay_label": "Autoplay next video: ",
"preferences_listen_label": "Listen by default: ", "preferences_listen_label": "Listen by default: ",
"preferences_local_label": "Proxy videos: ", "preferences_local_label": "Proxy videos: ",
"preferences_watch_history_label": "Enable watch history: ",
"preferences_speed_label": "Default speed: ", "preferences_speed_label": "Default speed: ",
"preferences_quality_label": "Preferred video quality: ", "preferences_quality_label": "Preferred video quality: ",
"preferences_quality_option_dash": "DASH (adaptative quality)", "preferences_quality_option_dash": "DASH (adaptative quality)",
@ -94,7 +95,7 @@
"preferences_related_videos_label": "Show related videos: ", "preferences_related_videos_label": "Show related videos: ",
"preferences_annotations_label": "Show annotations by default: ", "preferences_annotations_label": "Show annotations by default: ",
"preferences_extend_desc_label": "Automatically extend video description: ", "preferences_extend_desc_label": "Automatically extend video description: ",
"preferences_vr_mode_label": "Interactive 360 degree videos: ", "preferences_vr_mode_label": "Interactive 360 degree videos (requires WebGL): ",
"preferences_category_visual": "Visual preferences", "preferences_category_visual": "Visual preferences",
"preferences_region_label": "Content country: ", "preferences_region_label": "Content country: ",
"preferences_player_style_label": "Player style: ", "preferences_player_style_label": "Player style: ",
@ -236,6 +237,8 @@
"No such user": "No such user", "No such user": "No such user",
"Token is expired, please try again": "Token is expired, please try again", "Token is expired, please try again": "Token is expired, please try again",
"English": "English", "English": "English",
"English (United Kingdom)": "English (United Kingdom)",
"English (United States)": "English (United States)",
"English (auto-generated)": "English (auto-generated)", "English (auto-generated)": "English (auto-generated)",
"Afrikaans": "Afrikaans", "Afrikaans": "Afrikaans",
"Albanian": "Albanian", "Albanian": "Albanian",
@ -249,23 +252,31 @@
"Bosnian": "Bosnian", "Bosnian": "Bosnian",
"Bulgarian": "Bulgarian", "Bulgarian": "Bulgarian",
"Burmese": "Burmese", "Burmese": "Burmese",
"Cantonese (Hong Kong)": "Cantonese (Hong Kong)",
"Catalan": "Catalan", "Catalan": "Catalan",
"Cebuano": "Cebuano", "Cebuano": "Cebuano",
"Chinese": "Chinese",
"Chinese (China)": "Chinese (China)",
"Chinese (Hong Kong)": "Chinese (Hong Kong)",
"Chinese (Simplified)": "Chinese (Simplified)", "Chinese (Simplified)": "Chinese (Simplified)",
"Chinese (Taiwan)": "Chinese (Taiwan)",
"Chinese (Traditional)": "Chinese (Traditional)", "Chinese (Traditional)": "Chinese (Traditional)",
"Corsican": "Corsican", "Corsican": "Corsican",
"Croatian": "Croatian", "Croatian": "Croatian",
"Czech": "Czech", "Czech": "Czech",
"Danish": "Danish", "Danish": "Danish",
"Dutch": "Dutch", "Dutch": "Dutch",
"Dutch (auto-generated)": "Dutch (auto-generated)",
"Esperanto": "Esperanto", "Esperanto": "Esperanto",
"Estonian": "Estonian", "Estonian": "Estonian",
"Filipino": "Filipino", "Filipino": "Filipino",
"Finnish": "Finnish", "Finnish": "Finnish",
"French": "French", "French": "French",
"French (auto-generated)": "French (auto-generated)",
"Galician": "Galician", "Galician": "Galician",
"Georgian": "Georgian", "Georgian": "Georgian",
"German": "German", "German": "German",
"German (auto-generated)": "German (auto-generated)",
"Greek": "Greek", "Greek": "Greek",
"Gujarati": "Gujarati", "Gujarati": "Gujarati",
"Haitian Creole": "Haitian Creole", "Haitian Creole": "Haitian Creole",
@ -278,14 +289,19 @@
"Icelandic": "Icelandic", "Icelandic": "Icelandic",
"Igbo": "Igbo", "Igbo": "Igbo",
"Indonesian": "Indonesian", "Indonesian": "Indonesian",
"Indonesian (auto-generated)": "Indonesian (auto-generated)",
"Interlingue": "Interlingue",
"Irish": "Irish", "Irish": "Irish",
"Italian": "Italian", "Italian": "Italian",
"Italian (auto-generated)": "Italian (auto-generated)",
"Japanese": "Japanese", "Japanese": "Japanese",
"Japanese (auto-generated)": "Japanese (auto-generated)",
"Javanese": "Javanese", "Javanese": "Javanese",
"Kannada": "Kannada", "Kannada": "Kannada",
"Kazakh": "Kazakh", "Kazakh": "Kazakh",
"Khmer": "Khmer", "Khmer": "Khmer",
"Korean": "Korean", "Korean": "Korean",
"Korean (auto-generated)": "Korean (auto-generated)",
"Kurdish": "Kurdish", "Kurdish": "Kurdish",
"Kyrgyz": "Kyrgyz", "Kyrgyz": "Kyrgyz",
"Lao": "Lao", "Lao": "Lao",
@ -308,9 +324,12 @@
"Persian": "Persian", "Persian": "Persian",
"Polish": "Polish", "Polish": "Polish",
"Portuguese": "Portuguese", "Portuguese": "Portuguese",
"Portuguese (auto-generated)": "Portuguese (auto-generated)",
"Portuguese (Brazil)": "Portuguese (Brazil)",
"Punjabi": "Punjabi", "Punjabi": "Punjabi",
"Romanian": "Romanian", "Romanian": "Romanian",
"Russian": "Russian", "Russian": "Russian",
"Russian (auto-generated)": "Russian (auto-generated)",
"Samoan": "Samoan", "Samoan": "Samoan",
"Scottish Gaelic": "Scottish Gaelic", "Scottish Gaelic": "Scottish Gaelic",
"Serbian": "Serbian", "Serbian": "Serbian",
@ -322,7 +341,10 @@
"Somali": "Somali", "Somali": "Somali",
"Southern Sotho": "Southern Sotho", "Southern Sotho": "Southern Sotho",
"Spanish": "Spanish", "Spanish": "Spanish",
"Spanish (auto-generated)": "Spanish (auto-generated)",
"Spanish (Latin America)": "Spanish (Latin America)", "Spanish (Latin America)": "Spanish (Latin America)",
"Spanish (Mexico)": "Spanish (Mexico)",
"Spanish (Spain)": "Spanish (Spain)",
"Sundanese": "Sundanese", "Sundanese": "Sundanese",
"Swahili": "Swahili", "Swahili": "Swahili",
"Swedish": "Swedish", "Swedish": "Swedish",
@ -331,10 +353,12 @@
"Telugu": "Telugu", "Telugu": "Telugu",
"Thai": "Thai", "Thai": "Thai",
"Turkish": "Turkish", "Turkish": "Turkish",
"Turkish (auto-generated)": "Turkish (auto-generated)",
"Ukrainian": "Ukrainian", "Ukrainian": "Ukrainian",
"Urdu": "Urdu", "Urdu": "Urdu",
"Uzbek": "Uzbek", "Uzbek": "Uzbek",
"Vietnamese": "Vietnamese", "Vietnamese": "Vietnamese",
"Vietnamese (auto-generated)": "Vietnamese (auto-generated)",
"Welsh": "Welsh", "Welsh": "Welsh",
"Western Frisian": "Western Frisian", "Western Frisian": "Western Frisian",
"Xhosa": "Xhosa", "Xhosa": "Xhosa",

@ -21,15 +21,15 @@
"No": "No", "No": "No",
"Import and Export Data": "Importación y exportación de datos", "Import and Export Data": "Importación y exportación de datos",
"Import": "Importar", "Import": "Importar",
"Import Invidious data": "Importar datos de Invidious", "Import Invidious data": "Importar datos JSON de Invidious",
"Import YouTube subscriptions": "Importar suscripciones de YouTube", "Import YouTube subscriptions": "Importar suscripciones de YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Importar suscripciones de FreeTube (.db)", "Import FreeTube subscriptions (.db)": "Importar suscripciones de FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importar suscripciones de NewPipe (.json)", "Import NewPipe subscriptions (.json)": "Importar suscripciones de NewPipe (.json)",
"Import NewPipe data (.zip)": "Importar datos de NewPipe (.zip)", "Import NewPipe data (.zip)": "Importar datos de NewPipe (.zip)",
"Export": "Exportar", "Export": "Exportar",
"Export subscriptions as OPML": "Exportar suscripciones como OPML", "Export subscriptions as OPML": "Exportar suscripciones como OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar suscripciones como OPML (para NewPipe y FreeTube)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar suscripciones como OPML (para NewPipe y FreeTube)",
"Export data as JSON": "Exportar datos como JSON", "Export data as JSON": "Exportar datos de Invidious como JSON",
"Delete account?": "¿Quiere borrar la cuenta?", "Delete account?": "¿Quiere borrar la cuenta?",
"History": "Historial", "History": "Historial",
"An alternative front-end to YouTube": "Una interfaz alternativa para YouTube", "An alternative front-end to YouTube": "Una interfaz alternativa para YouTube",
@ -66,7 +66,7 @@
"preferences_related_videos_label": "¿Mostrar vídeos relacionados? ", "preferences_related_videos_label": "¿Mostrar vídeos relacionados? ",
"preferences_annotations_label": "¿Mostrar anotaciones por defecto? ", "preferences_annotations_label": "¿Mostrar anotaciones por defecto? ",
"preferences_extend_desc_label": "Extender automáticamente la descripción del vídeo: ", "preferences_extend_desc_label": "Extender automáticamente la descripción del vídeo: ",
"preferences_vr_mode_label": "Vídeos interactivos de 360 grados: ", "preferences_vr_mode_label": "Vídeos interactivos de 360 grados (necesita WebGL): ",
"preferences_category_visual": "Preferencias visuales", "preferences_category_visual": "Preferencias visuales",
"preferences_player_style_label": "Estilo de reproductor: ", "preferences_player_style_label": "Estilo de reproductor: ",
"Dark mode: ": "Modo oscuro: ", "Dark mode: ": "Modo oscuro: ",
@ -199,7 +199,7 @@
"No such user": "Usuario no válido", "No such user": "Usuario no válido",
"Token is expired, please try again": "El símbolo ha caducado, inténtelo de nuevo", "Token is expired, please try again": "El símbolo ha caducado, inténtelo de nuevo",
"English": "Inglés", "English": "Inglés",
"English (auto-generated)": "Inglés (autogenerado)", "English (auto-generated)": "Inglés (generados automáticamente)",
"Afrikaans": "Afrikáans", "Afrikaans": "Afrikáans",
"Albanian": "Albanés", "Albanian": "Albanés",
"Amharic": "Amárico", "Amharic": "Amárico",
@ -435,5 +435,29 @@
"crash_page_search_issue": "buscado <a href=\"`x`\">problemas existentes en Github</a>", "crash_page_search_issue": "buscado <a href=\"`x`\">problemas existentes en Github</a>",
"crash_page_you_found_a_bug": "¡Parece que has encontrado un error en Invidious!", "crash_page_you_found_a_bug": "¡Parece que has encontrado un error en Invidious!",
"crash_page_refresh": "probado a <a href=\"`x`\">recargar la página</a>", "crash_page_refresh": "probado a <a href=\"`x`\">recargar la página</a>",
"crash_page_report_issue": "Si nada de lo anterior ha sido de ayuda, por favor, <a href=\"`x`\">abre una nueva incidencia en GitHub</a> (preferiblemente en inglés) e incluye el siguiente texto en tu mensaje (NO traduzcas este texto):" "crash_page_report_issue": "Si nada de lo anterior ha sido de ayuda, por favor, <a href=\"`x`\">abre una nueva incidencia en GitHub</a> (preferiblemente en inglés) e incluye el siguiente texto en tu mensaje (NO traduzcas este texto):",
"English (United States)": "Inglés (Estados Unidos)",
"Cantonese (Hong Kong)": "Cantonés (Hong Kong)",
"Dutch (auto-generated)": "Neerlandés (generados automáticamente)",
"French (auto-generated)": "Francés (generados automáticamente)",
"Interlingue": "Occidental",
"Japanese (auto-generated)": "Japonés (generados automáticamente)",
"Russian (auto-generated)": "Ruso (generados automáticamente)",
"Spanish (Spain)": "Español (España)",
"Vietnamese (auto-generated)": "Vietnamita (generados automáticamente)",
"English (United Kingdom)": "Inglés (Reino Unido)",
"Chinese (Taiwan)": "Chino (Taiwán)",
"German (auto-generated)": "Alemán (generados automáticamente)",
"Italian (auto-generated)": "Italiano (generados automáticamente)",
"Turkish (auto-generated)": "Turco (generados automáticamente)",
"Portuguese (Brazil)": "Portugués (Brasil)",
"Indonesian (auto-generated)": "Indonesio (generados automáticamente)",
"Portuguese (auto-generated)": "Portugués (generados automáticamente)",
"Chinese": "Chino",
"Chinese (Hong Kong)": "Chino (Hong Kong)",
"Chinese (China)": "Chino (China)",
"Korean (auto-generated)": "Coreano (generados automáticamente)",
"Spanish (Mexico)": "Español (Méjico)",
"Spanish (auto-generated)": "Español (generados automáticamente)",
"preferences_watch_history_label": "Habilitar historial de reproducciones: "
} }

@ -31,15 +31,15 @@
"No": "Non", "No": "Non",
"Import and Export Data": "Importer et exporter des données", "Import and Export Data": "Importer et exporter des données",
"Import": "Importer", "Import": "Importer",
"Import Invidious data": "Importer des données Invidious", "Import Invidious data": "Importer des données Invidious au format JSON",
"Import YouTube subscriptions": "Importer des abonnements YouTube", "Import YouTube subscriptions": "Importer des abonnements YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Importer des abonnements FreeTube (.db)", "Import FreeTube subscriptions (.db)": "Importer des abonnements FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importer des abonnements NewPipe (.json)", "Import NewPipe subscriptions (.json)": "Importer des abonnements NewPipe (.json)",
"Import NewPipe data (.zip)": "Importer des données NewPipe (.zip)", "Import NewPipe data (.zip)": "Importer des données NewPipe (.zip)",
"Export": "Exporter", "Export": "Exporter",
"Export subscriptions as OPML": "Exporter les abonnements au format OPML", "Export subscriptions as OPML": "Exporter les abonnements au format OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements au format OPML (pour NewPipe & FreeTube)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements au format OPML (pour NewPipe & FreeTube)",
"Export data as JSON": "Exporter les données au format JSON", "Export data as JSON": "Exporter les données Invidious au format JSON",
"Delete account?": "Êtes-vous sûr de vouloir supprimer votre compte ?", "Delete account?": "Êtes-vous sûr de vouloir supprimer votre compte ?",
"History": "Historique", "History": "Historique",
"An alternative front-end to YouTube": "Un front-end alternatif à YouTube", "An alternative front-end to YouTube": "Un front-end alternatif à YouTube",
@ -76,7 +76,7 @@
"preferences_related_videos_label": "Voir les vidéos liées : ", "preferences_related_videos_label": "Voir les vidéos liées : ",
"preferences_annotations_label": "Afficher les annotations par défaut : ", "preferences_annotations_label": "Afficher les annotations par défaut : ",
"preferences_extend_desc_label": "Etendre automatiquement la description : ", "preferences_extend_desc_label": "Etendre automatiquement la description : ",
"preferences_vr_mode_label": "Vidéos interactives à 360° : ", "preferences_vr_mode_label": "Vidéos interactives à 360° (nécessite WebGL) : ",
"preferences_category_visual": "Préférences du site", "preferences_category_visual": "Préférences du site",
"preferences_player_style_label": "Style du lecteur : ", "preferences_player_style_label": "Style du lecteur : ",
"Dark mode: ": "Mode sombre : ", "Dark mode: ": "Mode sombre : ",
@ -437,5 +437,29 @@
"crash_page_read_the_faq": "lu la <a href=\"`x`\">Foire Aux Questions (FAQ)</a>", "crash_page_read_the_faq": "lu la <a href=\"`x`\">Foire Aux Questions (FAQ)</a>",
"crash_page_search_issue": "<a href=\"`x`\">cherché ce bug sur Github</a>", "crash_page_search_issue": "<a href=\"`x`\">cherché ce bug sur Github</a>",
"crash_page_before_reporting": "Avant de signaler un bug, veuillez vous assurez que vous avez :", "crash_page_before_reporting": "Avant de signaler un bug, veuillez vous assurez que vous avez :",
"crash_page_report_issue": "Si aucune des solutions proposées ci-dessus ne vous a aidé, veuillez <a href=\"`x`\">ouvrir une \"issue\" sur GitHub</a> (de préférence en anglais) et d'y inclure le message suivant (ne PAS traduire le texte) :" "crash_page_report_issue": "Si aucune des solutions proposées ci-dessus ne vous a aidé, veuillez <a href=\"`x`\">ouvrir une \"issue\" sur GitHub</a> (de préférence en anglais) et d'y inclure le message suivant (ne PAS traduire le texte) :",
"English (United States)": "Anglais (Etats-Unis)",
"Chinese (China)": "Chinois (Chine)",
"Chinese (Hong Kong)": "Chinois (Hong Kong)",
"Dutch (auto-generated)": "Danoi (auto-généré)",
"French (auto-generated)": "Français (auto-généré)",
"German (auto-generated)": "Allemand (auto-généré)",
"Japanese (auto-generated)": "Japonais (auto-généré)",
"Korean (auto-generated)": "Coréen (auto-généré)",
"Indonesian (auto-generated)": "Indonésien (auto-généré)",
"Portuguese (auto-generated)": "Portuguais (auto-généré)",
"Portuguese (Brazil)": "Portugais (Brésil)",
"Spanish (auto-generated)": "Espagnol (auto-généré)",
"Spanish (Mexico)": "Espagnol (Mexique)",
"Turkish (auto-generated)": "Turque (auto-généré)",
"Chinese": "Chinois",
"English (United Kingdom)": "Anglais (Royaume-Uni)",
"Chinese (Taiwan)": "Chinois (Taiwan)",
"Cantonese (Hong Kong)": "Cantonais (Hong Kong)",
"Interlingue": "Occidental",
"Italian (auto-generated)": "Italien (auto-généré)",
"Vietnamese (auto-generated)": "Vietnamien (auto-généré)",
"Russian (auto-generated)": "Russe (auto-généré)",
"Spanish (Spain)": "Espagnol (Espagne)",
"preferences_watch_history_label": "Activer l'historique de visionnage : "
} }

@ -21,15 +21,15 @@
"No": "Ne", "No": "Ne",
"Import and Export Data": "Uvezi i izvezi podatke", "Import and Export Data": "Uvezi i izvezi podatke",
"Import": "Uvezi", "Import": "Uvezi",
"Import Invidious data": "Uvezi Invidious podatke", "Import Invidious data": "Uvezi Invidious JSON podatke",
"Import YouTube subscriptions": "Uvezi YouTube pretplate", "Import YouTube subscriptions": "Uvezi YouTube/OPML pretplate",
"Import FreeTube subscriptions (.db)": "Uvezi FreeTube pretplate (.db)", "Import FreeTube subscriptions (.db)": "Uvezi FreeTube pretplate (.db)",
"Import NewPipe subscriptions (.json)": "Uvezi NewPipe pretplate (.json)", "Import NewPipe subscriptions (.json)": "Uvezi NewPipe pretplate (.json)",
"Import NewPipe data (.zip)": "Uvezi NewPipe podatke (.zip)", "Import NewPipe data (.zip)": "Uvezi NewPipe podatke (.zip)",
"Export": "Izvezi", "Export": "Izvezi",
"Export subscriptions as OPML": "Izvezi pretplate kao OPML", "Export subscriptions as OPML": "Izvezi pretplate kao OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Izvezi pretplate kao OPML (za NewPipe i FreeTube)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Izvezi pretplate kao OPML (za NewPipe i FreeTube)",
"Export data as JSON": "Izvezi podatke kao JSON", "Export data as JSON": "Izvezi Invidious podatke kao JSON",
"Delete account?": "Izbrisati račun?", "Delete account?": "Izbrisati račun?",
"History": "Povijest", "History": "Povijest",
"An alternative front-end to YouTube": "Alternativa za YouTube", "An alternative front-end to YouTube": "Alternativa za YouTube",
@ -66,7 +66,7 @@
"preferences_related_videos_label": "Prikaži povezana videa: ", "preferences_related_videos_label": "Prikaži povezana videa: ",
"preferences_annotations_label": "Standardno prikaži napomene: ", "preferences_annotations_label": "Standardno prikaži napomene: ",
"preferences_extend_desc_label": "Automatski proširi opis videa: ", "preferences_extend_desc_label": "Automatski proširi opis videa: ",
"preferences_vr_mode_label": "Interaktivna videa od 360 stupnjeva: ", "preferences_vr_mode_label": "Interaktivna videa od 360 stupnjeva (zahtijeva WebGL): ",
"preferences_category_visual": "Postavke prikaza", "preferences_category_visual": "Postavke prikaza",
"preferences_player_style_label": "Stil playera: ", "preferences_player_style_label": "Stil playera: ",
"Dark mode: ": "Tamni modus: ", "Dark mode: ": "Tamni modus: ",
@ -446,5 +446,35 @@
"generic_views_count_2": "{{count}} prikaza", "generic_views_count_2": "{{count}} prikaza",
"comments_view_x_replies_0": "Prikaži {{count}} odgovor", "comments_view_x_replies_0": "Prikaži {{count}} odgovor",
"comments_view_x_replies_1": "Prikaži {{count}} odgovora", "comments_view_x_replies_1": "Prikaži {{count}} odgovora",
"comments_view_x_replies_2": "Prikaži {{count}} odgovora" "comments_view_x_replies_2": "Prikaži {{count}} odgovora",
"crash_page_you_found_a_bug": "Čini se da si pronašao/la grešku u Invidiousu!",
"crash_page_before_reporting": "Prije prijavljivanja greške:",
"crash_page_refresh": "pokušaj <a href=\"`x`\">aktualizirati stranicu</a>",
"crash_page_switch_instance": "pokušaj <a href=\"`x`\">koristiti jednu drugu instancu</a>",
"crash_page_read_the_faq": "pročitaj <a href=\"`x`\">Često postavljena pitanja (ČPP)</a>",
"crash_page_search_issue": "pretraži <a href=\"`x`\">postojeće probleme na Github-u</a>",
"crash_page_report_issue": "Ako ništa od gore navedenog ne pomaže, <a href=\"`x`\">prijavi novi problem na GitHub-u</a> (po mogućnosti na engleskom) i uključi sljedeći tekst u poruku (NEMOJ prevoditi taj tekst):",
"English (United Kingdom)": "Engleski (Ujedinjeno Kraljevstvo)",
"English (United States)": "Engleski (Sjedinjene Američke Države)",
"Cantonese (Hong Kong)": "Kantonski (Hong Kong)",
"Chinese": "Kineski",
"Chinese (Taiwan)": "Kineski (Tajvan)",
"Dutch (auto-generated)": "Nizozemski (automatski generiran)",
"French (auto-generated)": "Francuski (automatski generiran)",
"Indonesian (auto-generated)": "Indonezijski (automatski generiran)",
"Interlingue": "Interlingua",
"Japanese (auto-generated)": "Japanski (automatski generiran)",
"Russian (auto-generated)": "Ruski (automatski generiran)",
"Turkish (auto-generated)": "Turski (automatski generiran)",
"Vietnamese (auto-generated)": "Vijetnamski (automatski generiran)",
"Spanish (Spain)": "Španjolski (Španjolska)",
"Italian (auto-generated)": "Talijanski (automatski generiran)",
"Portuguese (Brazil)": "Portugalski (Brazil)",
"Spanish (Mexico)": "Španjolski (Meksiko)",
"German (auto-generated)": "Njemački (automatski generiran)",
"Chinese (China)": "Kineski (Kina)",
"Chinese (Hong Kong)": "Kineski (Hong Kong)",
"Korean (auto-generated)": "Korejski (automatski generiran)",
"Portuguese (auto-generated)": "Portugalski (automatski generiran)",
"Spanish (auto-generated)": "Španjolski (automatski generiran)"
} }

@ -76,7 +76,7 @@
"preferences_related_videos_label": "Hasonló videók ajánlása: ", "preferences_related_videos_label": "Hasonló videók ajánlása: ",
"preferences_annotations_label": "Szövegmagyarázat alapértelmezett mutatása: ", "preferences_annotations_label": "Szövegmagyarázat alapértelmezett mutatása: ",
"preferences_extend_desc_label": "A videó leírása automatikusan látható: ", "preferences_extend_desc_label": "A videó leírása automatikusan látható: ",
"preferences_vr_mode_label": "Interaktív, 360°-os videók ", "preferences_vr_mode_label": "Interaktív 360 fokos videók (WebGL szükséges): ",
"preferences_category_visual": "Kinézet, elrendezés és régió beállításai", "preferences_category_visual": "Kinézet, elrendezés és régió beállításai",
"preferences_player_style_label": "Lejátszó kinézete: ", "preferences_player_style_label": "Lejátszó kinézete: ",
"Dark mode: ": "Elsötétített mód: ", "Dark mode: ": "Elsötétített mód: ",
@ -437,5 +437,28 @@
"crash_page_search_issue": "járj utána a <a href=\"`x`\">már meglévő issue-knak a GitHubon</a>", "crash_page_search_issue": "járj utána a <a href=\"`x`\">már meglévő issue-knak a GitHubon</a>",
"crash_page_switch_instance": "válts át <a href=\"`x`\">másik Invidious-oldalra</a>", "crash_page_switch_instance": "válts át <a href=\"`x`\">másik Invidious-oldalra</a>",
"crash_page_refresh": "<a href=\"`x`\">töltsd újra</a> az oldalt", "crash_page_refresh": "<a href=\"`x`\">töltsd újra</a> az oldalt",
"crash_page_report_issue": "Ha a fentiek után nem jutottál eredményre, akkor <a href=\"`x`\">nyiss egy új issue-t a GitHubon</a> (lehetőleg angol nyelven írj) és másold be pontosan a lenti szöveget (ezt nem kell lefordítani):" "crash_page_report_issue": "Ha a fentiek után nem jutottál eredményre, akkor <a href=\"`x`\">nyiss egy új issue-t a GitHubon</a> (lehetőleg angol nyelven írj) és másold be pontosan a lenti szöveget (ezt nem kell lefordítani):",
"Cantonese (Hong Kong)": "kantoni (Hongkong)",
"Chinese": "kínai",
"Chinese (China)": "kínai (Kína)",
"Chinese (Hong Kong)": "kínai (Hongkong)",
"Chinese (Taiwan)": "kínai (Tajvan)",
"German (auto-generated)": "német (automatikusan generált)",
"Interlingue": "interlingva",
"Japanese (auto-generated)": "japán (automatikusan generált)",
"Korean (auto-generated)": "koreai (automatikusan generált)",
"Portuguese (Brazil)": "portugál (Brazília)",
"Russian (auto-generated)": "orosz (automatikusan generált)",
"Spanish (auto-generated)": "spanyol (automatikusan generált)",
"Spanish (Mexico)": "spanyol (Mexikó)",
"Spanish (Spain)": "spanyol (Spanyolország)",
"English (United States)": "angol (Egyesült Államok)",
"Portuguese (auto-generated)": "portugál (automatikusan generált)",
"Turkish (auto-generated)": "török (automatikusan generált)",
"English (United Kingdom)": "angol (Egyesült Királyság)",
"Indonesian (auto-generated)": "indonéz (automatikusan generált)",
"Italian (auto-generated)": "olasz (automatikusan generált)",
"Dutch (auto-generated)": "holland (automatikusan generált)",
"French (auto-generated)": "francia (automatikusan generált)",
"Vietnamese (auto-generated)": "vietnámi (automatikusan generált)"
} }

@ -325,7 +325,7 @@
"Search": "Cari", "Search": "Cari",
"Top": "Teratas", "Top": "Teratas",
"About": "Tentang", "About": "Tentang",
"Rating: ": "Rating: ", "Rating: ": "Penilaian: ",
"preferences_locale_label": "Bahasa: ", "preferences_locale_label": "Bahasa: ",
"View as playlist": "Lihat sebagai daftar putar", "View as playlist": "Lihat sebagai daftar putar",
"Default": "Baku", "Default": "Baku",
@ -346,7 +346,7 @@
"Playlists": "Daftar putar", "Playlists": "Daftar putar",
"Community": "Komunitas", "Community": "Komunitas",
"relevance": "Relevansi", "relevance": "Relevansi",
"rating": "Rating", "rating": "Penilaian",
"date": "Tanggal unggah", "date": "Tanggal unggah",
"views": "Jumlah ditonton", "views": "Jumlah ditonton",
"content_type": "Tipe", "content_type": "Tipe",
@ -414,5 +414,7 @@
"preferences_quality_dash_option_auto": "Otomatis", "preferences_quality_dash_option_auto": "Otomatis",
"preferences_quality_dash_option_480p": "480p", "preferences_quality_dash_option_480p": "480p",
"Video unavailable": "Video tidak tersedia", "Video unavailable": "Video tidak tersedia",
"preferences_save_player_pos_label": "Simpan posisi pemutaran: " "preferences_save_player_pos_label": "Simpan posisi pemutaran: ",
"crash_page_you_found_a_bug": "Sepertinya kamu telah menemukan masalah di invidious!",
"crash_page_before_reporting": "Sebelum melaporkan masalah, pastikan anda memiliki:"
} }

@ -318,5 +318,6 @@
"Videos": "Myndbönd", "Videos": "Myndbönd",
"Playlists": "Spilunarlistar", "Playlists": "Spilunarlistar",
"Community": "Samfélag", "Community": "Samfélag",
"Current version: ": "Núverandi útgáfa: " "Current version: ": "Núverandi útgáfa: ",
"preferences_watch_history_label": "Virkja áhorfssögu: "
} }

@ -21,8 +21,8 @@
"No": "Nei", "No": "Nei",
"Import and Export Data": "Importer- og eksporter data", "Import and Export Data": "Importer- og eksporter data",
"Import": "Importer", "Import": "Importer",
"Import Invidious data": "Importer Invidious-data", "Import Invidious data": "Importer Invidious-JSON-data",
"Import YouTube subscriptions": "Importer YouTube-abonnementer", "Import YouTube subscriptions": "Importer YouTube/OPML-abonnementer",
"Import FreeTube subscriptions (.db)": "Importer FreeTube-abonnementer (.db)", "Import FreeTube subscriptions (.db)": "Importer FreeTube-abonnementer (.db)",
"Import NewPipe subscriptions (.json)": "Importer NewPipe-abonnementer (.json)", "Import NewPipe subscriptions (.json)": "Importer NewPipe-abonnementer (.json)",
"Import NewPipe data (.zip)": "Importer NewPipe-data (.zip)", "Import NewPipe data (.zip)": "Importer NewPipe-data (.zip)",
@ -430,5 +430,12 @@
"generic_count_minutes": "{{count}} minutt", "generic_count_minutes": "{{count}} minutt",
"generic_count_minutes_plural": "{{count}} minutter", "generic_count_minutes_plural": "{{count}} minutter",
"generic_count_years": "{{count}} år", "generic_count_years": "{{count}} år",
"generic_count_years_plural": "{{count}} år" "generic_count_years_plural": "{{count}} år",
"crash_page_read_the_faq": "lest de <a href=\"`x`\">Ofte stilte spørsmålene (OSS/FAQ)</a>",
"crash_page_search_issue": "søkt etter <a href=\"`x`\">eksisterende utfordringer på Github</a>",
"crash_page_you_found_a_bug": "Det ser ut til at du fant en feil i Invidious!",
"crash_page_refresh": "forsøkt å <a href=\"`x`\">laste siden på nytt</a>",
"crash_page_switch_instance": "forsøkt et <a href=\"`x`\">annet eksemplar</a>",
"crash_page_before_reporting": "Før du rapporterer en feil, sikre at du har:",
"crash_page_report_issue": "Hvis intet av det overnevnte hjalp, <a href=\"`x`\">lag en ny utfordring på Github</a> (fortrinnsvis på engelsk) og ta med følgende tekstbit i meldingen dit (IKKE oversett denne teksten):"
} }

@ -21,15 +21,15 @@
"No": "Nie", "No": "Nie",
"Import and Export Data": "Import i eksport danych", "Import and Export Data": "Import i eksport danych",
"Import": "Import", "Import": "Import",
"Import Invidious data": "Importuj dane Invidious", "Import Invidious data": "Importuj dane JSON Invidious",
"Import YouTube subscriptions": "Importuj subskrybcje z YouTube", "Import YouTube subscriptions": "Importuj subskrybcje z YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Importuj subskrybcje z FreeTube (.db)", "Import FreeTube subscriptions (.db)": "Importuj subskrybcje z FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importuj subskrybcje z NewPipe (.json)", "Import NewPipe subscriptions (.json)": "Importuj subskrybcje z NewPipe (.json)",
"Import NewPipe data (.zip)": "Importuj dane NewPipe (.zip)", "Import NewPipe data (.zip)": "Importuj dane NewPipe (.zip)",
"Export": "Eksport", "Export": "Eksport",
"Export subscriptions as OPML": "Eksportuj subskrybcje jako OPML", "Export subscriptions as OPML": "Eksportuj subskrybcje jako OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksportuj subskrybcje jako OPML (dla NewPipe i FreeTube)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksportuj subskrybcje jako OPML (dla NewPipe i FreeTube)",
"Export data as JSON": "Eksportuj dane jako JSON", "Export data as JSON": "Eksportuj dane Invidious jako JSON",
"Delete account?": "Usunąć konto?", "Delete account?": "Usunąć konto?",
"History": "Historia", "History": "Historia",
"An alternative front-end to YouTube": "Alternatywny front-end dla YouTube", "An alternative front-end to YouTube": "Alternatywny front-end dla YouTube",
@ -66,7 +66,7 @@
"preferences_related_videos_label": "Pokaż powiązane filmy? ", "preferences_related_videos_label": "Pokaż powiązane filmy? ",
"preferences_annotations_label": "Domyślnie pokazuj adnotacje: ", "preferences_annotations_label": "Domyślnie pokazuj adnotacje: ",
"preferences_extend_desc_label": "Automatycznie rozwijaj opisy filmów: ", "preferences_extend_desc_label": "Automatycznie rozwijaj opisy filmów: ",
"preferences_vr_mode_label": "Interaktywne filmy 360 stopni: ", "preferences_vr_mode_label": "Interaktywne filmy 360 stopni (wymaga WebGL): ",
"preferences_category_visual": "Preferencje Wizualne", "preferences_category_visual": "Preferencje Wizualne",
"preferences_player_style_label": "Styl odtwarzacza: ", "preferences_player_style_label": "Styl odtwarzacza: ",
"Dark mode: ": "Ciemny motyw: ", "Dark mode: ": "Ciemny motyw: ",
@ -446,12 +446,35 @@
"Video unavailable": "Film niedostępny", "Video unavailable": "Film niedostępny",
"preferences_save_player_pos_label": "Zapisz pozycję odtwarzania: ", "preferences_save_player_pos_label": "Zapisz pozycję odtwarzania: ",
"preferences_region_label": "Region zawartości: ", "preferences_region_label": "Region zawartości: ",
"Released under the AGPLv3 on Github.": "Wydane na licencji AGPLv3 na Github'ie.", "Released under the AGPLv3 on Github.": "Wydany na licencji AGPLv3 na Github.",
"short": "Krótkie (< 4 minutes)", "short": "Krótkie (< 4 minutes)",
"long": "Długie (> 20 minutes)", "long": "Długie (> 20 minutes)",
"footer_documentation": "Dokumentacja", "footer_documentation": "Dokumentacja",
"footer_source_code": "Kod źródłowy", "footer_source_code": "Kod źródłowy",
"footer_modfied_source_code": "Zmodyfikowany Kod źródłowy", "footer_modfied_source_code": "Zmodyfikowany Kod źródłowy",
"footer_original_source_code": "Oryginalny kod źródłowy", "footer_original_source_code": "Oryginalny kod źródłowy",
"adminprefs_modified_source_code_url_label": "Adres URL do repozytorium z zmodyfikowanym kodem źródłowym" "adminprefs_modified_source_code_url_label": "Adres URL do repozytorium z zmodyfikowanym kodem źródłowym",
"English (United Kingdom)": "angielski (Wielka Brytania)",
"English (United States)": "angielski (Stany Zjednoczone)",
"Cantonese (Hong Kong)": "kantoński (Hong Kong)",
"Chinese": "chiński",
"Chinese (China)": "chiński (Chiny)",
"Chinese (Hong Kong)": "chiński (Hong Kong)",
"Chinese (Taiwan)": "chiński (Tajwan)",
"Dutch (auto-generated)": "niderlandzki (wygenerowany automatycznie)",
"French (auto-generated)": "francuski (wygenerowany automatycznie)",
"German (auto-generated)": "niemiecki (wygenerowany automatycznie)",
"Indonesian (auto-generated)": "indonezyjski (wygenerowany automatycznie)",
"Interlingue": "interlingue",
"Italian (auto-generated)": "włoski (wygenerowany automatycznie)",
"Korean (auto-generated)": "koreański (wygenerowany automatycznie)",
"Spanish (auto-generated)": "hiszpański (wygenerowany automatycznie)",
"Spanish (Mexico)": "hiszpański (Meksyk)",
"Spanish (Spain)": "hiszpański (Hiszpania)",
"Turkish (auto-generated)": "turecki (wygenerowany automatycznie)",
"Vietnamese (auto-generated)": "wietnamski (wygenerowany automatycznie)",
"Japanese (auto-generated)": "japoński (wygenerowany automatycznie)",
"Russian (auto-generated)": "rosyjski (wygenerowany automatycznie)",
"Portuguese (auto-generated)": "portugalski (wygenerowany automatycznie)",
"Portuguese (Brazil)": "portugalski (Brazylia)"
} }

@ -388,7 +388,7 @@
"preferences_quality_dash_label": "Qualidade de vídeo DASH preferida: ", "preferences_quality_dash_label": "Qualidade de vídeo DASH preferida: ",
"preferences_quality_option_small": "Baixa", "preferences_quality_option_small": "Baixa",
"preferences_quality_option_hd720": "HD720", "preferences_quality_option_hd720": "HD720",
"preferences_quality_dash_option_auto": "Auto", "preferences_quality_dash_option_auto": "Automático",
"preferences_quality_dash_option_best": "Melhor", "preferences_quality_dash_option_best": "Melhor",
"preferences_quality_dash_option_4320p": "4320p", "preferences_quality_dash_option_4320p": "4320p",
"preferences_quality_dash_option_2160p": "2160p", "preferences_quality_dash_option_2160p": "2160p",
@ -397,12 +397,12 @@
"preferences_quality_dash_option_360p": "360p", "preferences_quality_dash_option_360p": "360p",
"preferences_quality_dash_option_240p": "240p", "preferences_quality_dash_option_240p": "240p",
"preferences_quality_dash_option_144p": "144p", "preferences_quality_dash_option_144p": "144p",
"purchased": "Adquirido", "purchased": "Comprado",
"360": "360°", "360": "360°",
"videoinfo_invidious_embed_link": "Incorporar hiperligação", "videoinfo_invidious_embed_link": "Incorporar hiperligação",
"Video unavailable": "Vídeo não disponível", "Video unavailable": "Vídeo não disponível",
"invidious": "Invidious", "invidious": "Invidious",
"preferences_quality_option_medium": "Médio", "preferences_quality_option_medium": "Média",
"preferences_quality_option_dash": "DASH (qualidade adaptativa)", "preferences_quality_option_dash": "DASH (qualidade adaptativa)",
"preferences_quality_dash_option_1440p": "1440p", "preferences_quality_dash_option_1440p": "1440p",
"preferences_quality_dash_option_480p": "480p", "preferences_quality_dash_option_480p": "480p",
@ -410,5 +410,32 @@
"preferences_quality_dash_option_worst": "Pior", "preferences_quality_dash_option_worst": "Pior",
"none": "nenhum", "none": "nenhum",
"videoinfo_youTube_embed_link": "Incorporar", "videoinfo_youTube_embed_link": "Incorporar",
"preferences_save_player_pos_label": "Guardar o tempo atual do vídeo: " "preferences_save_player_pos_label": "Guardar a posição de reprodução atual do vídeo: ",
"download_subtitles": "Legendas - `x` (.vtt)",
"generic_views_count": "{{count}} visualização",
"generic_views_count_plural": "{{count}} visualizações",
"videoinfo_started_streaming_x_ago": "Iniciou a transmissão há `x`",
"user_saved_playlists": "`x` listas de reprodução guardadas",
"generic_videos_count": "{{count}} vídeo",
"generic_videos_count_plural": "{{count}} vídeos",
"generic_playlists_count": "{{count}} lista de reprodução",
"generic_playlists_count_plural": "{{count}} listas de reprodução",
"subscriptions_unseen_notifs_count": "{{count}} notificação não vista",
"subscriptions_unseen_notifs_count_plural": "{{count}} notificações não vistas",
"comments_view_x_replies": "Ver {{count}} resposta",
"comments_view_x_replies_plural": "Ver {{count}} respostas",
"generic_subscribers_count": "{{count}} inscrito",
"generic_subscribers_count_plural": "{{count}} inscritos",
"generic_subscriptions_count": "{{count}} inscrição",
"generic_subscriptions_count_plural": "{{count}} inscrições",
"comments_points_count": "{{count}} ponto",
"comments_points_count_plural": "{{count}} pontos",
"crash_page_you_found_a_bug": "Parece que encontrou um erro no Invidious!",
"crash_page_before_reporting": "Antes de reportar um erro, verifique se:",
"crash_page_refresh": "tentou <a href=\"`x`\">recarregar a página</a>",
"crash_page_switch_instance": "tentou <a href=\"`x`\">usar outra instância</a>",
"crash_page_read_the_faq": "leu as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>",
"crash_page_search_issue": "procurou se <a href=\"`x`\">o erro já foi reportado no Github</a>",
"crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor <a href=\"`x`\">abra um novo problema no Github</a> (preferencialmente em inglês) e inclua o seguinte texto tal qual (NÃO o traduza):",
"user_created_playlists": "`x` listas de reprodução criadas"
} }

@ -21,15 +21,15 @@
"No": "Нет", "No": "Нет",
"Import and Export Data": "Импорт и экспорт данных", "Import and Export Data": "Импорт и экспорт данных",
"Import": "Импорт", "Import": "Импорт",
"Import Invidious data": "Импортировать данные Invidious", "Import Invidious data": "Импортировать JSON с данными Invidious",
"Import YouTube subscriptions": "Импортировать подписки из YouTube", "Import YouTube subscriptions": "Импортировать подписки из YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Импортировать подписки из FreeTube (.db)", "Import FreeTube subscriptions (.db)": "Импортировать подписки из FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Импортировать подписки из NewPipe (.json)", "Import NewPipe subscriptions (.json)": "Импортировать подписки из NewPipe (.json)",
"Import NewPipe data (.zip)": "Импортировать данные из NewPipe (.zip)", "Import NewPipe data (.zip)": "Импортировать данные из NewPipe (.zip)",
"Export": "Экспорт", "Export": "Экспорт",
"Export subscriptions as OPML": "Экспортировать подписки в формате OPML", "Export subscriptions as OPML": "Экспортировать подписки в формате OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Экспортировать подписки в формате OPML (для NewPipe и FreeTube)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Экспортировать подписки в формате OPML (для NewPipe и FreeTube)",
"Export data as JSON": "Экспортировать данные в формате JSON", "Export data as JSON": "Экспортировать данные Invidious в формате JSON",
"Delete account?": "Удалить аккаунт?", "Delete account?": "Удалить аккаунт?",
"History": "История", "History": "История",
"An alternative front-end to YouTube": "Альтернативный фронтенд для YouTube", "An alternative front-end to YouTube": "Альтернативный фронтенд для YouTube",
@ -66,7 +66,7 @@
"preferences_related_videos_label": "Показывать похожие видео? ", "preferences_related_videos_label": "Показывать похожие видео? ",
"preferences_annotations_label": "Всегда показывать аннотации? ", "preferences_annotations_label": "Всегда показывать аннотации? ",
"preferences_extend_desc_label": "Автоматически раскрывать описание видео: ", "preferences_extend_desc_label": "Автоматически раскрывать описание видео: ",
"preferences_vr_mode_label": "Интерактивные 360-градусные видео: ", "preferences_vr_mode_label": "Интерактивные 360-градусные видео (необходим WebGL): ",
"preferences_category_visual": "Настройки сайта", "preferences_category_visual": "Настройки сайта",
"preferences_player_style_label": "Стиль проигрывателя: ", "preferences_player_style_label": "Стиль проигрывателя: ",
"Dark mode: ": "Тёмное оформление: ", "Dark mode: ": "Тёмное оформление: ",
@ -75,7 +75,7 @@
"light": "светлая", "light": "светлая",
"preferences_thin_mode_label": "Облегчённое оформление: ", "preferences_thin_mode_label": "Облегчённое оформление: ",
"preferences_category_misc": "Прочие предпочтения", "preferences_category_misc": "Прочие предпочтения",
"preferences_automatic_instance_redirect_label": "Автоматическое перенаправление экземпляра (резервный вариант redirect.invidious.io): ", "preferences_automatic_instance_redirect_label": "Автоматическое перенаправление на зеркало сайта (резервный вариант redirect.invidious.io): ",
"preferences_category_subscription": "Настройки подписок", "preferences_category_subscription": "Настройки подписок",
"preferences_annotations_subscribed_label": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ", "preferences_annotations_subscribed_label": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ",
"Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ", "Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ",
@ -361,5 +361,120 @@
"next_steps_error_message_refresh": "Обновить", "next_steps_error_message_refresh": "Обновить",
"next_steps_error_message_go_to_youtube": "Перейти на YouTube", "next_steps_error_message_go_to_youtube": "Перейти на YouTube",
"short": "Короткие (< 4 минут)", "short": "Короткие (< 4 минут)",
"long": "Длинные (> 20 минут)" "long": "Длинные (> 20 минут)",
"preferences_quality_dash_option_best": "Наилучшее",
"generic_count_weeks_0": "{{count}} неделя",
"generic_count_weeks_1": "{{count}} недели",
"generic_count_weeks_2": "{{count}} недель",
"English (United Kingdom)": "Английский (Великобритания)",
"English (United States)": "Английский (США)",
"Cantonese (Hong Kong)": "Кантонский (Гонконг)",
"Chinese (Taiwan)": "Китайский (Тайвань)",
"Dutch (auto-generated)": "Голландский (автоматический)",
"German (auto-generated)": "Немецкий (автоматический)",
"Indonesian (auto-generated)": "Индонезийский (автоматический)",
"Italian (auto-generated)": "Итальянский (автоматический)",
"Interlingue": "Окциденталь",
"Russian (auto-generated)": "Русский (автоматический)",
"Spanish (auto-generated)": "Испанский (автоматический)",
"Spanish (Spain)": "Испанский (Испания)",
"Turkish (auto-generated)": "Турецкий (автоматический)",
"Vietnamese (auto-generated)": "Вьетнамский (автоматический)",
"footer_documentation": "Документация",
"adminprefs_modified_source_code_url_label": "Ссылка на нашу ветку репозитория",
"none": "ничего",
"videoinfo_watch_on_youTube": "Смотреть на YouTube",
"videoinfo_youTube_embed_link": "Встраиваемый элемент",
"videoinfo_invidious_embed_link": "Встраиваемая ссылка",
"download_subtitles": "Субтитры - `x` (.vtt)",
"user_created_playlists": "`x` созданных плейлистов",
"crash_page_you_found_a_bug": "Похоже вы нашли баг в Invidious!",
"crash_page_before_reporting": "Прежде чем сообщать об ошибке, убедитесь, что вы:",
"crash_page_refresh": "пробовали <a href=\"`x`\"> перезагрузить страницу</a>",
"crash_page_report_issue": "Если ни один вариант не помог, пожалуйста <a href=\"`x`\">откройте новую проблему на GitHub</a> (желательно на английском) и приложите следующий текст к вашему сообщению (НЕ переводите его):",
"generic_videos_count_0": "{{count}} видео",
"generic_videos_count_1": "{{count}} видео",
"generic_videos_count_2": "{{count}} видео",
"generic_playlists_count_0": "{{count}} плейлист",
"generic_playlists_count_1": "{{count}} плейлиста",
"generic_playlists_count_2": "{{count}} плейлистов",
"tokens_count_0": "{{count}} токен",
"tokens_count_1": "{{count}} токена",
"tokens_count_2": "{{count}} токенов",
"subscriptions_unseen_notifs_count_0": "{{count}} новое уведомление",
"subscriptions_unseen_notifs_count_1": "{{count}} новых уведомления",
"subscriptions_unseen_notifs_count_2": "{{count}} новых уведомлений",
"comments_view_x_replies_0": "{{count}} ответ",
"comments_view_x_replies_1": "{{count}} ответа",
"comments_view_x_replies_2": "{{count}} ответов",
"generic_count_years_0": "{{count}} год",
"generic_count_years_1": "{{count}} года",
"generic_count_years_2": "{{count}} лет",
"generic_count_minutes_0": "{{count}} минута",
"generic_count_minutes_1": "{{count}} минуты",
"generic_count_minutes_2": "{{count}} минут",
"generic_subscribers_count_0": "{{count}} подписчик",
"generic_subscribers_count_1": "{{count}} подписчика",
"generic_subscribers_count_2": "{{count}} подписчиков",
"generic_views_count_0": "{{count}} просмотр",
"generic_views_count_1": "{{count}} просмотра",
"generic_views_count_2": "{{count}} просмотров",
"French (auto-generated)": "Французский (автоматический)",
"Portuguese (auto-generated)": "Португальский (автоматический)",
"generic_count_days_0": "{{count}} день",
"generic_count_days_1": "{{count}} дня",
"generic_count_days_2": "{{count}} дней",
"preferences_quality_dash_option_auto": "Автоматическое",
"preferences_quality_dash_option_1080p": "1080p",
"preferences_quality_dash_option_720p": "720p",
"generic_subscriptions_count_0": "{{count}} подписка",
"generic_subscriptions_count_1": "{{count}} подписки",
"generic_subscriptions_count_2": "{{count}} подписок",
"preferences_quality_option_small": "Низкое",
"preferences_quality_dash_option_1440p": "1440p",
"preferences_quality_dash_option_480p": "480p",
"preferences_quality_dash_option_360p": "360p",
"generic_count_seconds_0": "{{count}} секунда",
"generic_count_seconds_1": "{{count}} секунды",
"generic_count_seconds_2": "{{count}} секунд",
"purchased": "Приобретено",
"videoinfo_started_streaming_x_ago": "Трансляция началась `x` назад",
"crash_page_switch_instance": "пробовали <a href=\"`x`\">использовать другое зеркало</a>",
"crash_page_read_the_faq": "прочли <a href=\"`x`\">Частые Вопросы (ЧаВо)</a>",
"Chinese": "Китайский",
"Chinese (Hong Kong)": "Китайский (Гонконг)",
"Japanese (auto-generated)": "Японский (автоматический)",
"Chinese (China)": "Китайский (Китай)",
"Korean (auto-generated)": "Корейский (автоматический)",
"generic_count_months_0": "{{count}} месяц",
"generic_count_months_1": "{{count}} месяца",
"generic_count_months_2": "{{count}} месяцев",
"generic_count_hours_0": "{{count}} час",
"generic_count_hours_1": "{{count}} часа",
"generic_count_hours_2": "{{count}} часов",
"Portuguese (Brazil)": "Португальский (Бразилия)",
"footer_source_code": "Исходный код",
"footer_original_source_code": "Оригинальный исходный код",
"footer_modfied_source_code": "Изменённый исходный код",
"user_saved_playlists": "`x` сохранённых плейлистов",
"crash_page_search_issue": "искали <a href=\"`x`\">похожую проблему на Github</a>",
"comments_points_count_0": "{{count}} плюс",
"comments_points_count_1": "{{count}} плюса",
"comments_points_count_2": "{{count}} плюсов",
"Spanish (Mexico)": "Испанский (Мексика)",
"footer_donate_page": "Поддержать проект",
"preferences_quality_option_dash": "DASH (автоматическое качество)",
"preferences_quality_option_hd720": "HD720",
"preferences_quality_option_medium": "Среднее",
"preferences_quality_dash_label": "Предпочтительное автоматическое качество видео: ",
"preferences_quality_dash_option_worst": "Очень низкое",
"preferences_quality_dash_option_4320p": "4320p",
"preferences_quality_dash_option_2160p": "2160p",
"preferences_quality_dash_option_240p": "240p",
"preferences_quality_dash_option_144p": "144p",
"invidious": "Invidious",
"360": "360°",
"Video unavailable": "Видео недоступно",
"preferences_save_player_pos_label": "Запоминать позицию: ",
"preferences_region_label": "Страна: "
} }

@ -1 +1,452 @@
{} {
"Albanian": "Shqip",
"Amharic": "Amharike",
"Arabic": "Arabisht",
"Armenian": "Armenisht",
"Gujarati": "Gujaratase",
"Haitian Creole": "Kreolase Haiti",
"Hausa": "Hausisht",
"Hawaiian": "Havajane",
"Hebrew": "Hebraisht",
"Hindi": "Indiane",
"Hungarian": "Hungarisht",
"Icelandic": "Islandisht",
"Igbo": "Igboisht",
"Irish": "Irlandisht",
"Javanese": "Xhavanisht",
"Kazakh": "Kazake",
"Khmer": "Khmere",
"Korean": "Koreane",
"Kurdish": "Kurdisht",
"Kyrgyz": "Kirgizisht",
"Sundanese": "Sundaneze",
"Swahili": "Suahilisht",
"Swedish": "Suedisht",
"Tajik": "Taxhike",
"Tamil": "Tamilisht",
"Telugu": "Telugu",
"Vietnamese": "Vietnamisht",
"creative_commons": "Creative Commons",
"3d": "3D",
"live": "Drejtpërsëdrejti",
"4k": "4K",
"location": "Vendndodhja",
"videoinfo_watch_on_youTube": "Shiheni në YouTube",
"videoinfo_youTube_embed_link": "Trupëzojeni",
"videoinfo_invidious_embed_link": "Lidhje Trupëzimi",
"oldest": "më të vjetrat",
"Cannot change password for Google accounts": "Smund të ndryshojë fjalëkalimin për llogari Google",
"New passwords must match": "Fjalëkalimet e rinj duhet të përputhen me njëri-tjetrin",
"Authorize token?": "Të autorizohet token-i?",
"Authorize token for `x`?": "Të autorizohet token-i për `x`?",
"Log in/register": "Hyni/regjistrohuni",
"Log in with Google": "Hyni me Google",
"User ID": "ID Përdoruesi",
"Password": "Fjalëkalim",
"Time (h:mm:ss):": "Kohë (h:mm:ss):",
"Text CAPTCHA": "CAPTCHA Tekst",
"Image CAPTCHA": "CAPTCHA Figurë",
"Sign In": "Hyni",
"Register": "Regjistrohuni",
"E-mail": "Email",
"Preferences": "Parapëlqime",
"preferences_category_player": "Parapëlqime Lojtësi",
"preferences_autoplay_label": "Vetëluaje: ",
"preferences_continue_label": "Luaj pasuesen, si parazgjedhje: ",
"preferences_continue_autoplay_label": "Vetëluaj videon pasuese: ",
"preferences_listen_label": "Si parazgjedhje, dëgjojeni me: ",
"preferences_speed_label": "Shpejtësi parazgjedhje: ",
"preferences_quality_label": "Cilësi e parapëlqyer për videot: ",
"preferences_quality_option_dash": "DASH (cilësi që përshtatet)",
"preferences_quality_option_hd720": "HD720",
"preferences_quality_option_medium": "Mesatare",
"preferences_quality_option_small": "E ulët",
"preferences_quality_dash_label": "Cilësi DASH e parapëlqyer për videot: ",
"preferences_quality_dash_option_auto": "Auto",
"preferences_quality_dash_option_best": "Më e mira",
"preferences_quality_dash_option_worst": "Më e keqja",
"preferences_quality_dash_option_4320p": "4320p",
"preferences_quality_dash_option_2160p": "2160p",
"preferences_quality_dash_option_1440p": "1440p",
"preferences_quality_dash_option_1080p": "1080p",
"preferences_quality_dash_option_720p": "720p",
"preferences_quality_dash_option_480p": "480p",
"preferences_quality_dash_option_360p": "360p",
"preferences_quality_dash_option_240p": "240p",
"preferences_quality_dash_option_144p": "144p",
"preferences_volume_label": "Volum lojtësi: ",
"preferences_comments_label": "Komente parazgjedhje: ",
"youtube": "YouTube",
"reddit": "Reddit",
"invidious": "Invidious",
"preferences_captions_label": "Titra parazgjedhje: ",
"preferences_extend_desc_label": "Zgjero automatikisht përshkrimin e videos: ",
"preferences_player_style_label": "Silt lojtësi: ",
"Dark mode: ": "Mënyra e errët: ",
"preferences_dark_mode_label": "Temë: ",
"dark": "e errët",
"light": "e çelët",
"preferences_thin_mode_label": "Mënyrë e hollë: ",
"preferences_category_misc": "Parapëlqime të ndryshme",
"preferences_automatic_instance_redirect_label": "Ridrejtim i automatizuar i instancës (si parazgjedhje, te redirect.invidious.io): ",
"preferences_category_subscription": "Parapëlqime pajtimesh",
"preferences_annotations_subscribed_label": "Të shfaqen, si parazgjedhje, shënime për kanalet e pajtuar? ",
"Redirect homepage to feed: ": "Ridrejtoje faqen hyrëse te prurje: ",
"preferences_max_results_label": "Numër videosh të shfaqura në prurje: ",
"preferences_sort_label": "Renditi videot sipas: ",
"published": "e publikuar",
"alphabetically": "alfabetikisht",
"alphabetically - reverse": "alfabetikisht - së prapthi",
"channel name": "emër kanali",
"Only show latest video from channel: ": "Shfaq vetëm videot më të reja nga kanali: ",
"Only show latest unwatched video from channel: ": "Shfaq vetëm videot më të reja të papara në kanal: ",
"preferences_unseen_only_label": "Shfaq vetëm të paparat: ",
"preferences_notifications_only_label": "Shfaq vetëm njoftime (nëse ka të tilla): ",
"Enable web notifications": "Aktivizoni njoftime web",
"`x` uploaded a video": "`x` ngarkoi një video",
"`x` is live": "`x` funksionon",
"preferences_category_data": "Parapëlqime për të dhënat",
"Clear watch history": "Spastro historik parjesh",
"Import/export data": "Importoni/eksportoni të dhëna",
"Change password": "Ndryshoni fjalëkalimin",
"Manage subscriptions": "Administroni pajtimet",
"Manage tokens": "Administroni token-ë",
"Watch history": "Shihni historikun",
"Delete account": "Fshije llogarinë",
"preferences_category_admin": "Parapëlqime përgjegjësi",
"preferences_default_home_label": "Faqe hyrëse parazgjedhje: ",
"preferences_feed_menu_label": "Menu prurjesh: ",
"Registration enabled: ": "Regjistrim i aktivizuar: ",
"Save preferences": "Ruaji parapëlqimet",
"Token": "Token",
"Subscription manager": "Përgjegjës pajtimesh",
"Token manager": "Përgjegjës token-ësh",
"Import/export": "Importim/eksportim",
"unsubscribe": "shpajtohuni",
"revoke": "shfuqizoje",
"Subscriptions": "Pajtime",
"search": "kërko",
"Log out": "Dilni",
"Released under the AGPLv3 on Github.": "Hedhur në qarkullim në Github sipas licencës AGPLv3.",
"Source available here.": "Burimi i passhëm që këtu.",
"View JavaScript license information.": "Shihni hollësi licence JavaScript.",
"View privacy policy.": "Shihni rregulla privatësie.",
"Trending": "Në modë",
"Public": "Publike",
"Unlisted": "Jo në listë",
"Private": "Private",
"View all playlists": "Shihni krejt luajlistat",
"Updated `x` ago": "Përditësuar `x` më parë",
"Delete playlist": "Fshije luajlistën",
"Delete playlist `x`?": "Të fshihet luajlista `x`?",
"Create playlist": "Krijoni luajlistë",
"Title": "Titull",
"Playlist privacy": "Privatësi luajliste",
"Editing playlist `x`": "Po përpunohet luajlista `x`",
"Show more": "Shfaq më tepër",
"Show less": "Shfaq më pak",
"Watch on YouTube": "Shiheni në YouTube",
"Switch Invidious Instance": "Ndërroni Instancë Invidious",
"Broken? Try another Invidious Instance": "E prishur? Provoni një tjetër Instancë Invidious",
"Hide annotations": "Fshihi shënimet",
"Show annotations": "Shfaq shënime",
"License: ": "Licencë: ",
"Family friendly? ": "E përshtatshme për familje? ",
"Wilson score: ": "Klasifikim Wilson: ",
"Engagement: ": "Angazhim: ",
"Whitelisted regions: ": "Rajone të lejuara: ",
"Premieres `x`": "Premiera `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.": "Njatjeta! Duket sikur keni JavaScript-in të çaktivizuar. Klikoni këtu që të shihni komentet, mbani parasysh se mund të duhet pak më tepër kohë që të ngarkohen.",
"Quota exceeded, try again in a few hours": "Janë tejkaluar kuotat, riprovoni pas pak orësh",
"Blacklisted regions: ": "Rajone të palejuara: ",
"Premieres in `x`": "Premiera në `x`",
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Sarrihet të bëhet hyrja, sigurohuni se mirëfilltësimi dyfaktorësh (me Mirëfilltësues apo SMS) është i aktivizuar.",
"Wrong answer": "Përgjigje e gabuar",
"Invalid TFA code": "Kod MDF i pavlefshëm",
"Login failed. This may be because two-factor authentication is not turned on for your account.": "Dështoi hyrja. Kjo mund të vijë ngaqë për llogarinë tuaj sështë aktivizuar mirëfilltësimi dyfaktorësh.",
"Erroneous CAPTCHA": "CAPTCHA e gabuar",
"CAPTCHA is a required field": "CAPTCHA është fushë e domosdoshme",
"User ID is a required field": "ID-ja e përdoruesit është fushë e domosdoshme",
"Password is a required field": "Fusha e fjalëkalimit është e domosdoshme",
"Wrong username or password": "Emër përdoruesi ose fjalëkalim i gabuar",
"Please sign in using 'Log in with Google'": "Ju lutemi, bëni hyrjen duke përdorur “Bëni hyrjen me Google”",
"Password cannot be empty": "Fjalëkalimi smund të jetë i zbrazët",
"Password cannot be longer than 55 characters": "Fjalëkalimi smund të jetë më i gjatë se 55 shenja",
"Please log in": "Ju lutemi, bëni hyrjen",
"Invidious Private Feed for `x`": "Prurje Private Invidious për `x`",
"channel:`x`": "kanal:`x`",
"Deleted or invalid channel": "Kanal i fshirë ose i pavlefshëm",
"This channel does not exist.": "Ky kanal sekziston.",
"Could not get channel info.": "Su morën dot hollësi kanali.",
"Could not fetch comments": "Su sollën dot komente",
"`x` ago": "`x` më parë",
"Load more": "Ngarko më tepër",
"Empty playlist": "Luajlistë e zbrazët",
"Not a playlist.": "Sështë luajlistë.",
"Playlist does not exist.": "Luajlista sekziston.",
"Hidden field \"challenge\" is a required field": "Fusha e fshehur “challenge” është fushë e domosdoshme",
"Hidden field \"token\" is a required field": "Fusha e fshehur “token” është fushë e domosdoshme",
"Erroneous token": "Token i gabuar",
"No such user": "Ska përdorues të tillë",
"Token is expired, please try again": "Token-i ka skaduar, ju lutemi, riprovoni",
"English": "Anglisht",
"English (auto-generated)": "Anglisht (të vetë-prodhuara)",
"Afrikaans": "Afrikaans",
"Azerbaijani": "Azerbajxhanase",
"Bangla": "Bangla",
"Basque": "Baske",
"Burmese": "Burmanisht",
"Catalan": "Katalane",
"Belarusian": "Bjellorusisht",
"Bosnian": "Boshnjake",
"Bulgarian": "Bullgarisht",
"Cebuano": "Cebuano",
"Chinese (Simplified)": "Kineze (E thjeshtuar)",
"Chinese (Traditional)": "Kineze (Tradicionale)",
"Corsican": "Korsikanisht",
"Croatian": "Kroatisht",
"Czech": "Çekisht",
"Danish": "Danisht",
"Dutch": "Holandisht",
"Esperanto": "Esperanto",
"Estonian": "Estonisht",
"Filipino": "Filipineze",
"Finnish": "Finlandisht",
"French": "Frëngjisht",
"Galician": "Galicisht",
"Georgian": "Gjeorgjisht",
"German": "Gjermanisht",
"Greek": "Greqisht",
"Indonesian": "Indonezisht",
"Italian": "Italisht",
"Japanese": "Japonisht",
"Lao": "Laosisht",
"Lithuanian": "Lituanisht",
"Luxembourgish": "Luksemburgisht",
"Latin": "Latinisht",
"Latvian": "Letonisht",
"Macedonian": "Maqedonisht",
"Nyanja": "Nianja",
"Pashto": "Pashtune",
"Persian": "Perisht",
"Polish": "Polonisht",
"Portuguese": "Portugalisht",
"Punjabi": "Panxhabe",
"Romanian": "Rumanisht",
"Russian": "Rusisht",
"Samoan": "Samoanisht",
"Scottish Gaelic": "Galike Skoceze",
"Serbian": "Serbisht",
"Shona": "Shonisht",
"Sindhi": "Sindi",
"Sinhala": "Sinhaleze",
"Slovak": "Slovakisht",
"Slovenian": "Sllovenisht",
"Somali": "Somalisht",
"Southern Sotho": "Sotoishte Jugore",
"Spanish": "Spanjisht",
"Spanish (Latin America)": "Spanjisht (Amerikë Latine)",
"Thai": "Tajlandeze",
"Turkish": "Turqisht",
"Ukrainian": "Ukrainase",
"Urdu": "Urdisht",
"Uzbek": "Uzbeke",
"Welsh": "Uellase",
"Western Frisian": "Frizishte Perëndimore",
"Xhosa": "Xhosa",
"Yiddish": "Jidisht",
"%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(u përpunua)",
"YouTube comment permalink": "Permalidhje komenti YouTube",
"Audio mode": "Mënyrë për audion",
"Playlists": "Luajlista",
"Community": "Bashkësi",
"relevance": "Rëndësi",
"Video mode": "Mënyrë video",
"Videos": "Video",
"rating": "Vlerësim",
"date": "Datë ngarkimi",
"views": "Numër parjesh",
"content_type": "Lloj",
"duration": "Kohëzgjatje",
"features": "Veçori",
"sort": "Renditi Sipas",
"hour": "Orën e Fundit",
"today": "Sot",
"long": "E gjatë (> 20 minuta)",
"hd": "HD",
"subtitles": "Titra/CC",
"hdr": "HDR",
"week": "Këtë javë",
"month": "Këtë muaj",
"year": "Këtë vit",
"video": "Video",
"channel": "Kanal",
"playlist": "Luajlistë",
"movie": "Film",
"show": "Shfaqe",
"short": "E shkurtër (< 4 minuta)",
"purchased": "Të blera",
"footer_modfied_source_code": "Kod Burim i ndryshuar",
"adminprefs_modified_source_code_url_label": "URL e depos së ndryshuar të kodit burim",
"none": "asnjë",
"videoinfo_started_streaming_x_ago": "Filloi transmetimin `x` më parë",
"LIVE": "DREJTPËRSËDREJTI",
"Shared `x` ago": "Ndarë me të tjerë `x` më parë",
"Unsubscribe": "Shpajtohuni",
"Subscribe": "Pajtomë",
"View channel on YouTube": "Shihni kanalin në YouTube",
"View playlist on YouTube": "Shihni luajlistën në YouTube",
"newest": "më të rejat",
"popular": "popullore",
"last": "e fundit",
"Next page": "Faqja pasuese",
"Previous page": "Faqja e mëparshme",
"Clear watch history?": "Të spastrohet historiku i parjeve?",
"New password": "Fjalëkalim i ri",
"Google verification code": "Kod verifikimi Google",
"preferences_related_videos_label": "Shfaq video të afërta: ",
"preferences_annotations_label": "Si parazgjedhje, shfaqi shënimet: ",
"preferences_show_nick_label": "Shfaqe nofkën në krye: ",
"CAPTCHA enabled: ": "Me CAPTCHA të aktivizuar: ",
"Login enabled: ": "Me hyrjen të aktivizuar: ",
"Genre: ": "Zhanër: ",
"Could not create mix.": "Su krijua dot përzierja.",
"Yoruba": "Jorubaisht",
"Zulu": "Zulu",
"Popular": "Popullore",
"Search": "Kërko",
"About": "Mbi",
"Rating: ": "Vlerësim: ",
"preferences_locale_label": "Gjuhë: ",
"View as playlist": "Shiheni si luajlistë",
"Default": "Parazgjedhje",
"Music": "Muzikë",
"Gaming": "Lojëra",
"News": "Lajme",
"Movies": "Filma",
"Download": "Shkarkoje",
"Download as: ": "Shkarkoje si: ",
"permalink": "permalidhje",
"`x` marked it with a ❤": "`x` i është vënë një ❤",
"download_subtitles": "Titra - `x` (.vtt)",
"user_created_playlists": "`x` krijoi luajlista",
"user_saved_playlists": "`x` ruajti luajlista",
"Video unavailable": "Video jo e passhme",
"Yes": "Po",
"No": "Jo",
"Import and Export Data": "Importoni dhe Eksportoni të Dhëna",
"Import": "Importo",
"Import FreeTube subscriptions (.db)": "Importoni pajtime FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importoni pajtime NewPipe (.json)",
"Import NewPipe data (.zip)": "Importoni të dhëna NewPipe (.zip)",
"Export": "Eksporto",
"Export subscriptions as OPML": "Eksportoni pajtime si OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksportoji pajtimet si OPML (për NewPipe & FreeTube)",
"Delete account?": "Të fshihet llogaria?",
"History": "Historik",
"An alternative front-end to YouTube": "Një front-end alternativ për YouTube-in",
"JavaScript license information": "Hollësi licence JavaScript",
"source": "burim",
"Log in": "Hyni",
"preferences_category_visual": "Parapëlqime pamore",
"preferences_region_label": "Vend lënde: ",
"View YouTube comments": "Shihni komente Youtube",
"View more comments on Reddit": "Shihni më tepër komente në Reddit",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Shihni `x` komente",
"": "Shihni `x` komente"
},
"View Reddit comments": "Shihni komente Reddit",
"Hide replies": "Fshihi përgjigjet",
"Show replies": "Shfaq përgjigje",
"Incorrect password": "Fjalëkalim i pasaktë",
"Malagasy": "Malagashe",
"Malay": "Malajase",
"Malayalam": "Malajalamase",
"Maltese": "Maltisht",
"Maori": "Maori",
"Marathi": "Marati",
"Mongolian": "Mongolisht",
"Nepali": "Nepaleze",
"Norwegian Bokmål": "Norvegjishte Bokmål",
"360": "360°",
"filter": "Filtroji",
"Current version: ": "Versioni i tanishëm: ",
"next_steps_error_message": "Pas të cilës duhet të provoni të: ",
"next_steps_error_message_refresh": "Rifreskoje",
"next_steps_error_message_go_to_youtube": "Kaloni në Youtube",
"footer_donate_page": "Dhuroni",
"footer_documentation": "Dokumentim",
"footer_source_code": "Kod burim",
"footer_original_source_code": "Kodim burim origjinal",
"generic_count_hours": "{{count}} orë",
"generic_count_hours_plural": "{{count}} orë",
"generic_videos_count": "{{count}} video",
"generic_videos_count_plural": "{{count}} video",
"generic_playlists_count": "{{count}} luajlistë",
"generic_playlists_count_plural": "{{count}} luajlista",
"generic_subscribers_count": "{{count}} pajtimtar",
"generic_subscribers_count_plural": "{{count}} pajtimtarë",
"subscriptions_unseen_notifs_count": "{{count}} njoftim që sështë parë",
"subscriptions_unseen_notifs_count_plural": "{{count}} njoftime që sjanë parë",
"comments_view_x_replies": "Shihni {{count}} përgjigje",
"comments_view_x_replies_plural": "Shihni {{count}} përgjigje",
"comments_points_count": "{{count}} pikë",
"comments_points_count_plural": "{{count}} pikë",
"generic_count_years": "{{count}} vit",
"generic_count_years_plural": "{{count}} vjet",
"generic_count_months": "{{count}} muaj",
"generic_count_months_plural": "{{count}} muaj",
"generic_count_weeks": "{{count}} javë",
"generic_count_weeks_plural": "{{count}} javë",
"generic_count_days": "{{count}} ditë",
"generic_count_days_plural": "{{count}} ditë",
"generic_count_minutes": "{{count}} minutë",
"generic_count_minutes_plural": "{{count}} minuta",
"generic_count_seconds": "{{count}} sekondë",
"generic_count_seconds_plural": "{{count}} sekonda",
"crash_page_you_found_a_bug": "Duket sikur gjetët një të metë në Invidious!",
"crash_page_before_reporting": "Para se të njoftoni një të metë, sigurohuni se keni:",
"crash_page_refresh": "provuar të <a href=\"`x`\">rifreskoni faqen</a>",
"crash_page_switch_instance": "provuar të <a href=\"`x`\">përdorni tjetër instancë</a>",
"crash_page_read_the_faq": "lexuar <a href=\"`x`\">Pyetje të Bëra Rëndom (PBR)</a>",
"generic_views_count": "{{count}} parje",
"generic_views_count_plural": "{{count}} parje",
"English (United Kingdom)": "Anglisht (Mbretëri e Bashkuar)",
"English (United States)": "Anglisht (Shtetet e Bashkuara)",
"Cantonese (Hong Kong)": "Kantoneze (Hong Kong)",
"Chinese": "Kinezçe",
"Chinese (China)": "Kinezçe (Kinë)",
"Chinese (Hong Kong)": "Kinezçe (Hong-Kong)",
"Chinese (Taiwan)": "Kinezçe (Tajvan)",
"Dutch (auto-generated)": "Holandisht (e prodhuar automatikisht)",
"French (auto-generated)": "Anglisht (të prodhuara automatikisht)",
"German (auto-generated)": "Gjermanisht (të prodhuara automatikisht)",
"Hmong": "Hmong",
"Indonesian (auto-generated)": "Indonezisht (të prodhuara automatikisht)",
"Interlingue": "Interlingue",
"Italian (auto-generated)": "Italisht (të prodhuara automatikisht)",
"Japanese (auto-generated)": "Japonisht (të prodhuara automatikisht)",
"Korean (auto-generated)": "Koreane (të prodhuara automatikisht)",
"Portuguese (auto-generated)": "Portugalisht (të prodhuara automatikisht)",
"Portuguese (Brazil)": "Portugeze (Brazil)",
"Russian (auto-generated)": "Rusisht (të prodhuara automatikisht)",
"Spanish (auto-generated)": "Spanjisht (të prodhuara automatikisht)",
"Spanish (Mexico)": "Spanjisht (Meksikë)",
"Spanish (Spain)": "Spanjisht (Spanjë)",
"Turkish (auto-generated)": "Turqisht (të prodhuara automatikisht)",
"Vietnamese (auto-generated)": "Vietnamisht (të prodhuara automatikisht)",
"crash_page_search_issue": "kërkuar për <a href=\"`x`\">çështje ekzistuese në Github</a>",
"crash_page_report_issue": "Nëse asnjë nga sa më sipër sndihmoi, ju lutemi, <a href=\"`x`\">hapni një çështje në GitHub</a> (mundësisht në anglisht) dhe përfshini në mesazhin tuaj tekstin vijues (MOS e përktheni këtë tekst):",
"generic_subscriptions_count": "{{count}} pajtim",
"generic_subscriptions_count_plural": "{{count}} pajtime",
"tokens_count": "{{count}} token",
"tokens_count_plural": "{{count}} tokenë",
"preferences_save_player_pos_label": "Mba mend pozicionin e luajtjes: ",
"Import Invidious data": "Importoni të dhëna JSON Invidious",
"Import YouTube subscriptions": "Importoni pajtime YouTube/OPML",
"Export data as JSON": "Eksportoji të dhënat Invidious si JSON",
"preferences_vr_mode_label": "Video me ndërveprim 360 gradë (lyp WebGL): ",
"Shared `x`": "Ndau me të tjerë `x`"
}

@ -21,15 +21,15 @@
"No": "Hayır", "No": "Hayır",
"Import and Export Data": "Verileri İçe ve Dışa Aktar", "Import and Export Data": "Verileri İçe ve Dışa Aktar",
"Import": "İçe aktar", "Import": "İçe aktar",
"Import Invidious data": "İnvidious verilerini içe aktar", "Import Invidious data": "İnvidious JSON verilerini içe aktar",
"Import YouTube subscriptions": "YouTube aboneliklerini içe aktar", "Import YouTube subscriptions": "YouTube/OPML aboneliklerini içe aktar",
"Import FreeTube subscriptions (.db)": "FreeTube aboneliklerini içe aktar (.db)", "Import FreeTube subscriptions (.db)": "FreeTube aboneliklerini içe aktar (.db)",
"Import NewPipe subscriptions (.json)": "NewPipe aboneliklerini içe aktar (.json)", "Import NewPipe subscriptions (.json)": "NewPipe aboneliklerini içe aktar (.json)",
"Import NewPipe data (.zip)": "NewPipe verilerini içe aktar (.zip)", "Import NewPipe data (.zip)": "NewPipe verilerini içe aktar (.zip)",
"Export": "Dışa aktar", "Export": "Dışa aktar",
"Export subscriptions as OPML": "Abonelikleri OPML olarak dışa aktar", "Export subscriptions as OPML": "Abonelikleri OPML olarak dışa aktar",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Abonelikleri OPML olarak dışa aktar (NewPipe ve FreeTube için)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Abonelikleri OPML olarak dışa aktar (NewPipe ve FreeTube için)",
"Export data as JSON": "Verileri JSON olarak dışa aktar", "Export data as JSON": "Invidious verilerini JSON olarak dışa aktar",
"Delete account?": "Hesap silinsin mi?", "Delete account?": "Hesap silinsin mi?",
"History": "Geçmiş", "History": "Geçmiş",
"An alternative front-end to YouTube": "YouTube için alternatif bir ön-yüz", "An alternative front-end to YouTube": "YouTube için alternatif bir ön-yüz",
@ -66,7 +66,7 @@
"preferences_related_videos_label": "İlgili videoları göster: ", "preferences_related_videos_label": "İlgili videoları göster: ",
"preferences_annotations_label": "Öntanımlı olarak ek açıklamaları göster: ", "preferences_annotations_label": "Öntanımlı olarak ek açıklamaları göster: ",
"preferences_extend_desc_label": "Video açıklamasını otomatik olarak genişlet: ", "preferences_extend_desc_label": "Video açıklamasını otomatik olarak genişlet: ",
"preferences_vr_mode_label": "Etkileşimli 360 derece videolar: ", "preferences_vr_mode_label": "Etkileşimli 360 derece videolar (WebGL gerektirir): ",
"preferences_category_visual": "Görsel tercihler", "preferences_category_visual": "Görsel tercihler",
"preferences_player_style_label": "Oynatıcı biçimi: ", "preferences_player_style_label": "Oynatıcı biçimi: ",
"Dark mode: ": "Karanlık mod: ", "Dark mode: ": "Karanlık mod: ",
@ -437,5 +437,29 @@
"crash_page_switch_instance": "<a href=\"`x`\">başka bir örnek kullanmaya</a> çalıştınız", "crash_page_switch_instance": "<a href=\"`x`\">başka bir örnek kullanmaya</a> çalıştınız",
"crash_page_read_the_faq": "<a href=\"`x`\">Sık Sorulan Soruları (SSS)</a> okudunuz", "crash_page_read_the_faq": "<a href=\"`x`\">Sık Sorulan Soruları (SSS)</a> okudunuz",
"crash_page_search_issue": "<a href=\"`x`\">Github'daki sorunlarda</a> aradınız", "crash_page_search_issue": "<a href=\"`x`\">Github'daki sorunlarda</a> aradınız",
"crash_page_report_issue": "Yukarıdakilerin hiçbiri yardımcı olmadıysa, lütfen <a href=\"`x`\">GitHub'da yeni bir sorun açın</a> (tercihen İngilizce) ve mesajınıza aşağıdaki metni ekleyin (bu metni ÇEVİRMEYİN):" "crash_page_report_issue": "Yukarıdakilerin hiçbiri yardımcı olmadıysa, lütfen <a href=\"`x`\">GitHub'da yeni bir sorun açın</a> (tercihen İngilizce) ve mesajınıza aşağıdaki metni ekleyin (bu metni ÇEVİRMEYİN):",
"English (United Kingdom)": "İngilizce (Birleşik Krallık)",
"Chinese": "Çince",
"Interlingue": "İnterlingue",
"Italian (auto-generated)": "İtalyanca (otomatik oluşturuldu)",
"Japanese (auto-generated)": "Japonca (otomatik oluşturuldu)",
"Portuguese (Brazil)": "Portekizce (Brezilya)",
"Russian (auto-generated)": "Rusça (otomatik oluşturuldu)",
"Spanish (auto-generated)": "İspanyolca (otomatik oluşturuldu)",
"Spanish (Mexico)": "İspanyolca (Meksika)",
"English (United States)": "İngilizce (ABD)",
"Cantonese (Hong Kong)": "Kantonca (Hong Kong)",
"Chinese (Taiwan)": "Çince (Tayvan)",
"Dutch (auto-generated)": "Felemenkçe (otomatik oluşturuldu)",
"Indonesian (auto-generated)": "Endonezyaca (otomatik oluşturuldu)",
"Chinese (Hong Kong)": "Çince (Hong Kong)",
"French (auto-generated)": "Fransızca (otomatik oluşturuldu)",
"Korean (auto-generated)": "Korece (otomatik oluşturuldu)",
"Turkish (auto-generated)": "Türkçe (otomatik oluşturuldu)",
"Chinese (China)": "Çince (Çin)",
"German (auto-generated)": "Almanca (otomatik oluşturuldu)",
"Portuguese (auto-generated)": "Portekizce (otomatik oluşturuldu)",
"Spanish (Spain)": "İspanyolca (İspanya)",
"Vietnamese (auto-generated)": "Vietnamca (otomatik oluşturuldu)",
"preferences_watch_history_label": "İzleme geçmişini etkinleştir: "
} }

@ -26,15 +26,15 @@
"No": "否", "No": "否",
"Import and Export Data": "导入与导出数据", "Import and Export Data": "导入与导出数据",
"Import": "导入", "Import": "导入",
"Import Invidious data": "导入 Invidious 数据", "Import Invidious data": "导入 Invidious JSON 数据",
"Import YouTube subscriptions": "导入 YouTube 订阅", "Import YouTube subscriptions": "导入 YouTube/OPML 订阅",
"Import FreeTube subscriptions (.db)": "导入 FreeTube 订阅 (.db)", "Import FreeTube subscriptions (.db)": "导入 FreeTube 订阅 (.db)",
"Import NewPipe subscriptions (.json)": "导入 NewPipe 订阅 (.json)", "Import NewPipe subscriptions (.json)": "导入 NewPipe 订阅 (.json)",
"Import NewPipe data (.zip)": "导入 NewPipe 数据 (.zip)", "Import NewPipe data (.zip)": "导入 NewPipe 数据 (.zip)",
"Export": "导出", "Export": "导出",
"Export subscriptions as OPML": "导出订阅到 OPML 格式", "Export subscriptions as OPML": "导出订阅到 OPML 格式",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "导出订阅到 OPML 格式(用于 NewPipe 及 FreeTube", "Export subscriptions as OPML (for NewPipe & FreeTube)": "导出订阅到 OPML 格式(用于 NewPipe 及 FreeTube",
"Export data as JSON": "导出数据为 JSON 格式", "Export data as JSON": "导出 Invidious 数据为 JSON 格式",
"Delete account?": "删除账户?", "Delete account?": "删除账户?",
"History": "历史", "History": "历史",
"An alternative front-end to YouTube": "另一个 YouTube 前端", "An alternative front-end to YouTube": "另一个 YouTube 前端",
@ -71,7 +71,7 @@
"preferences_related_videos_label": "是否显示相关视频: ", "preferences_related_videos_label": "是否显示相关视频: ",
"preferences_annotations_label": "是否默认显示视频注释: ", "preferences_annotations_label": "是否默认显示视频注释: ",
"preferences_extend_desc_label": "自动展开视频描述: ", "preferences_extend_desc_label": "自动展开视频描述: ",
"preferences_vr_mode_label": "互动式 360 度视频 ", "preferences_vr_mode_label": "互动式 360 度视频 (需要 WebGL): ",
"preferences_category_visual": "视觉选项", "preferences_category_visual": "视觉选项",
"preferences_player_style_label": "播放器样式: ", "preferences_player_style_label": "播放器样式: ",
"Dark mode: ": "深色模式: ", "Dark mode: ": "深色模式: ",
@ -421,5 +421,29 @@
"purchased": "已购买", "purchased": "已购买",
"360": "360°", "360": "360°",
"none": "无", "none": "无",
"preferences_save_player_pos_label": "保存播放位置: " "preferences_save_player_pos_label": "保存播放位置: ",
"Spanish (Mexico)": "西班牙语 (墨西哥)",
"Portuguese (auto-generated)": "葡萄牙语 (自动生成)",
"Portuguese (Brazil)": "葡萄牙语 (巴西)",
"English (United Kingdom)": "英语 (英国)",
"English (United States)": "英语 (美国)",
"Chinese": "中文",
"Chinese (China)": "中文 (中国)",
"Chinese (Hong Kong)": "中文 (中国香港)",
"Chinese (Taiwan)": "中文 (中国台湾)",
"German (auto-generated)": "德语 (自动生成)",
"Indonesian (auto-generated)": "印尼语 (自动生成)",
"Interlingue": "国际语",
"Italian (auto-generated)": "意大利语 (自动生成)",
"Japanese (auto-generated)": "日语 (自动生成)",
"Korean (auto-generated)": "韩语 (自动生成)",
"Russian (auto-generated)": "俄语 (自动生成)",
"Spanish (auto-generated)": "西班牙语 (自动生成)",
"Vietnamese (auto-generated)": "越南语 (自动生成)",
"Cantonese (Hong Kong)": "粤语 (中国香港)",
"Dutch (auto-generated)": "荷兰语 (自动生成)",
"French (auto-generated)": "法语 (自动生成)",
"Turkish (auto-generated)": "土耳其语 (自动生成)",
"Spanish (Spain)": "西班牙语 (西班牙)",
"preferences_watch_history_label": "启用观看历史: "
} }

@ -26,15 +26,15 @@
"No": "否", "No": "否",
"Import and Export Data": "匯入與匯出資料", "Import and Export Data": "匯入與匯出資料",
"Import": "匯入", "Import": "匯入",
"Import Invidious data": "匯入 Invidious 資料", "Import Invidious data": "匯入 Invidious JSON 資料",
"Import YouTube subscriptions": "匯入 YouTube 訂閱", "Import YouTube subscriptions": "匯入 YouTube/OPML 訂閱",
"Import FreeTube subscriptions (.db)": "匯入 FreeTube 訂閱 (.db)", "Import FreeTube subscriptions (.db)": "匯入 FreeTube 訂閱 (.db)",
"Import NewPipe subscriptions (.json)": "匯入 NewPipe 訂閱 (.json)", "Import NewPipe subscriptions (.json)": "匯入 NewPipe 訂閱 (.json)",
"Import NewPipe data (.zip)": "匯入 NewPipe 資料 (.zip)", "Import NewPipe data (.zip)": "匯入 NewPipe 資料 (.zip)",
"Export": "匯出", "Export": "匯出",
"Export subscriptions as OPML": "將訂閱匯出為 OPML", "Export subscriptions as OPML": "將訂閱匯出為 OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "將訂閱匯出為 OPML供 NewPipe 與 FreeTube 使用)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "將訂閱匯出為 OPML供 NewPipe 與 FreeTube 使用)",
"Export data as JSON": "將 JSON 匯出為 JSON", "Export data as JSON": "將 Invidious 資料匯出為 JSON",
"Delete account?": "刪除帳號?", "Delete account?": "刪除帳號?",
"History": "歷史", "History": "歷史",
"An alternative front-end to YouTube": "一個 YouTube 的替代前端", "An alternative front-end to YouTube": "一個 YouTube 的替代前端",
@ -71,7 +71,7 @@
"preferences_related_videos_label": "顯示相關的影片: ", "preferences_related_videos_label": "顯示相關的影片: ",
"preferences_annotations_label": "預設顯示註釋: ", "preferences_annotations_label": "預設顯示註釋: ",
"preferences_extend_desc_label": "自動展開影片描述: ", "preferences_extend_desc_label": "自動展開影片描述: ",
"preferences_vr_mode_label": "互動式 360 度影片 ", "preferences_vr_mode_label": "互動式 360 度影片(需要 WebGL ",
"preferences_category_visual": "視覺偏好設定", "preferences_category_visual": "視覺偏好設定",
"preferences_player_style_label": "播放器樣式: ", "preferences_player_style_label": "播放器樣式: ",
"Dark mode: ": "深色模式: ", "Dark mode: ": "深色模式: ",
@ -421,5 +421,29 @@
"crash_page_read_the_faq": "閱讀<a href=\"`x`\">常見問題解答 (FAQ)</a>", "crash_page_read_the_faq": "閱讀<a href=\"`x`\">常見問題解答 (FAQ)</a>",
"crash_page_search_issue": "搜尋 <a href=\"`x`\">GitHub 上既有的問題</a>", "crash_page_search_issue": "搜尋 <a href=\"`x`\">GitHub 上既有的問題</a>",
"crash_page_report_issue": "若以上的動作都沒有幫到忙,請<a href=\"`x`\">在 GitHub 上開啟新的議題</a>(請盡量使用英文)並在您的訊息中包含以下文字(不要翻譯文字):", "crash_page_report_issue": "若以上的動作都沒有幫到忙,請<a href=\"`x`\">在 GitHub 上開啟新的議題</a>(請盡量使用英文)並在您的訊息中包含以下文字(不要翻譯文字):",
"crash_page_before_reporting": "在回報臭蟲之前,請確保您有:" "crash_page_before_reporting": "在回報臭蟲之前,請確保您有:",
"English (United Kingdom)": "英文(英國)",
"English (United States)": "英文(美國)",
"Cantonese (Hong Kong)": "粵語(香港)",
"Chinese": "中文",
"Chinese (China)": "中文(中國)",
"Chinese (Taiwan)": "中文(台灣)",
"Dutch (auto-generated)": "荷蘭語(自動產生)",
"German (auto-generated)": "德語(自動產生)",
"Korean (auto-generated)": "韓語(自動產生)",
"Russian (auto-generated)": "俄語(自動產生)",
"Spanish (auto-generated)": "西班牙語(自動產生)",
"Spanish (Mexico)": "西班牙語(墨西哥)",
"Spanish (Spain)": "西班牙語(西班牙)",
"Turkish (auto-generated)": "土耳其語(自動產生)",
"French (auto-generated)": "法語(自動產生)",
"Vietnamese (auto-generated)": "越南語(自動產生)",
"Interlingue": "西方國際語",
"Chinese (Hong Kong)": "中文(香港)",
"Italian (auto-generated)": "義大利語(自動產生)",
"Indonesian (auto-generated)": "印尼語(自動產生)",
"Portuguese (Brazil)": "葡萄牙語(巴西)",
"Japanese (auto-generated)": "日語(自動產生)",
"Portuguese (auto-generated)": "葡萄牙語(自動產生)",
"preferences_watch_history_label": "啟用觀看紀錄: "
} }

@ -25,9 +25,9 @@ def csv_sample
CSV CSV
end end
Spectator.describe "Invidious::User::Imports" do Spectator.describe Invidious::User::Import do
it "imports CSV" do it "imports CSV" do
subscriptions = parse_subscription_export_csv(csv_sample) subscriptions = Invidious::User::Import.parse_subscription_export_csv(csv_sample)
expect(subscriptions).to be_an(Array(String)) expect(subscriptions).to be_an(Array(String))
expect(subscriptions.size).to eq(13) expect(subscriptions.size).to eq(13)

@ -29,6 +29,8 @@ require "protodec/utils"
require "./invidious/database/*" require "./invidious/database/*"
require "./invidious/helpers/*" require "./invidious/helpers/*"
require "./invidious/yt_backend/*" require "./invidious/yt_backend/*"
require "./invidious/frontend/*"
require "./invidious/*" require "./invidious/*"
require "./invidious/channels/*" require "./invidious/channels/*"
require "./invidious/user/*" require "./invidious/user/*"
@ -38,14 +40,13 @@ require "./invidious/jobs/**"
CONFIG = Config.load CONFIG = Config.load
HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32)
PG_DB = DB.open CONFIG.database_url PG_DB = DB.open CONFIG.database_url
ARCHIVE_URL = URI.parse("https://archive.org") ARCHIVE_URL = URI.parse("https://archive.org")
LOGIN_URL = URI.parse("https://accounts.google.com") LOGIN_URL = URI.parse("https://accounts.google.com")
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
REDDIT_URL = URI.parse("https://www.reddit.com") REDDIT_URL = URI.parse("https://www.reddit.com")
TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com") YT_URL = URI.parse("https://www.youtube.com")
YT_URL = URI.parse("https://www.youtube.com") HOST_URL = make_host_url(Kemal.config)
HOST_URL = make_host_url(Kemal.config)
CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"} TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
@ -114,16 +115,18 @@ LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level)
# Check table integrity # Check table integrity
Invidious::Database.check_integrity(CONFIG) Invidious::Database.check_integrity(CONFIG)
# Resolve player dependencies. This is done at compile time. {% if !flag?(:skip_videojs_download) %}
# # Resolve player dependencies. This is done at compile time.
# Running the script by itself would show some colorful feedback while this doesn't. #
# Perhaps we should just move the script to runtime in order to get that feedback? # Running the script by itself would show some colorful feedback while this doesn't.
# Perhaps we should just move the script to runtime in order to get that feedback?
{% puts "\nChecking player dependencies...\n" %}
{% if flag?(:minified_player_dependencies) %} {% puts "\nChecking player dependencies...\n" %}
{% puts run("../scripts/fetch-player-dependencies.cr", "--minified").stringify %} {% if flag?(:minified_player_dependencies) %}
{% else %} {% puts run("../scripts/fetch-player-dependencies.cr", "--minified").stringify %}
{% puts run("../scripts/fetch-player-dependencies.cr").stringify %} {% else %}
{% puts run("../scripts/fetch-player-dependencies.cr").stringify %}
{% end %}
{% end %} {% end %}
# Start jobs # Start jobs
@ -153,8 +156,8 @@ if CONFIG.popular_enabled
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
end end
connection_channel = Channel({Bool, Channel(PQ::Notification)}).new(32) CONNECTION_CHANNEL = Channel({Bool, Channel(PQ::Notification)}).new(32)
Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(connection_channel, CONFIG.database_url) Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL, CONFIG.database_url)
Invidious::Jobs.start_all Invidious::Jobs.start_all
@ -233,6 +236,7 @@ before_all do |env|
"/api/manifest/", "/api/manifest/",
"/videoplayback", "/videoplayback",
"/latest_version", "/latest_version",
"/download",
}.any? { |r| env.request.resource.starts_with? r } }.any? { |r| env.request.resource.starts_with? r }
if env.request.cookies.has_key? "SID" if env.request.cookies.has_key? "SID"
@ -323,6 +327,9 @@ end
Invidious::Routing.get "/channel/:ucid/playlists", Invidious::Routes::Channels, :playlists 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/community", Invidious::Routes::Channels, :community
Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about
Invidious::Routing.get "/channel/:ucid/live", Invidious::Routes::Channels, :live
Invidious::Routing.get "/user/:user/live", Invidious::Routes::Channels, :live
Invidious::Routing.get "/c/:user/live", Invidious::Routes::Channels, :live
["", "/videos", "/playlists", "/community", "/about"].each do |path| ["", "/videos", "/playlists", "/community", "/about"].each do |path|
# /c/LinusTechTips # /c/LinusTechTips
@ -345,6 +352,8 @@ end
Invidious::Routing.get "/e/: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 "/redirect", Invidious::Routes::Misc, :cross_instance_redirect
Invidious::Routing.post "/download", Invidious::Routes::Watch, :download
Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect
Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show
@ -359,20 +368,14 @@ end
Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax
Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show
Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix
Invidious::Routing.get "/watch_videos", Invidious::Routes::Playlists, :watch_videos
Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch
Invidious::Routing.get "/results", Invidious::Routes::Search, :results Invidious::Routing.get "/results", Invidious::Routes::Search, :results
Invidious::Routing.get "/search", Invidious::Routes::Search, :search Invidious::Routing.get "/search", Invidious::Routes::Search, :search
Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page # User routes
Invidious::Routing.post "/login", Invidious::Routes::Login, :login define_user_routes()
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 "/data_control", Invidious::Routes::PreferencesRoute, :data_control
Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control
# Feeds # Feeds
Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect
@ -412,404 +415,6 @@ define_v1_api_routes()
define_api_manifest_routes() define_api_manifest_routes()
define_video_playback_routes() define_video_playback_routes()
get "/change_password" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY)
templated "change_password"
end
post "/change_password" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
# We don't store passwords for Google accounts
if !user.password
next error_template(400, "Cannot change password for Google accounts")
end
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
next error_template(400, ex)
end
password = env.params.body["password"]?
if !password
next error_template(401, "Password is a required field")
end
new_passwords = env.params.body.select { |k, v| k.match(/^new_password\[\d+\]$/) }.map { |k, v| v }
if new_passwords.size <= 1 || new_passwords.uniq.size != 1
next error_template(400, "New passwords must match")
end
new_password = new_passwords.uniq[0]
if new_password.empty?
next error_template(401, "Password cannot be empty")
end
if new_password.bytesize > 55
next error_template(400, "Password cannot be longer than 55 characters")
end
if !Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55))
next error_template(401, "Incorrect password")
end
new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10)
Invidious::Database::Users.update_password(user, new_password.to_s)
env.redirect referer
end
get "/delete_account" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY)
templated "delete_account"
end
post "/delete_account" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
next error_template(400, ex)
end
view_name = "subscriptions_#{sha256(user.email)}"
Invidious::Database::Users.delete(user)
Invidious::Database::SessionIDs.delete(email: user.email)
PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}")
env.request.cookies.each do |cookie|
cookie.expires = Time.utc(1990, 1, 1)
env.response.cookies << cookie
end
env.redirect referer
end
get "/clear_watch_history" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY)
templated "clear_watch_history"
end
post "/clear_watch_history" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
next error_template(400, ex)
end
Invidious::Database::Users.clear_watch_history(user)
env.redirect referer
end
get "/authorize_token" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY)
scopes = env.params.query["scopes"]?.try &.split(",")
scopes ||= [] of String
callback_url = env.params.query["callback_url"]?
if callback_url
callback_url = URI.parse(callback_url)
end
expire = env.params.query["expire"]?.try &.to_i?
templated "authorize_token"
end
post "/authorize_token" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = env.get("user").as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
next error_template(400, ex)
end
scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v }
callback_url = env.params.body["callbackUrl"]?
expire = env.params.body["expire"]?.try &.to_i?
access_token = generate_token(user.email, scopes, expire, HMAC_KEY)
if callback_url
access_token = URI.encode_www_form(access_token)
url = URI.parse(callback_url)
if url.query
query = HTTP::Params.parse(url.query.not_nil!)
else
query = HTTP::Params.new
end
query["token"] = access_token
url.query = query.to_s
env.redirect url.to_s
else
csrf_token = ""
env.set "access_token", access_token
templated "authorize_token"
end
end
get "/token_manager" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env, "/subscription_manager")
if !user
next env.redirect referer
end
user = user.as(User)
tokens = Invidious::Database::SessionIDs.select_all(user.email)
templated "token_manager"
end
post "/token_ajax" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
redirect = env.params.query["redirect"]?
redirect ||= "true"
redirect = redirect == "true"
if !user
if redirect
next env.redirect referer
else
next error_json(403, "No such user")
end
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
if redirect
next error_template(400, ex)
else
next error_json(400, ex)
end
end
if env.params.query["action_revoke_token"]?
action = "action_revoke_token"
else
next env.redirect referer
end
session = env.params.query["session"]?
session ||= ""
case action
when .starts_with? "action_revoke_token"
Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
else
next error_json(400, "Unsupported action #{action}")
end
if redirect
env.redirect referer
else
env.response.content_type = "application/json"
"{}"
end
end
# Channels
{"/channel/:ucid/live", "/user/:user/live", "/c/:user/live"}.each do |route|
get route do |env|
locale = env.get("preferences").as(Preferences).locale
# Appears to be a bug in routing, having several routes configured
# as `/a/:a`, `/b/:a`, `/c/:a` results in 404
value = env.request.resource.split("/")[2]
body = ""
{"channel", "user", "c"}.each do |type|
response = YT_POOL.client &.get("/#{type}/#{value}/live?disable_polymer=1")
if response.status_code == 200
body = response.body
end
end
video_id = body.match(/'VIDEO_ID': "(?<id>[a-zA-Z0-9_-]{11})"/).try &.["id"]?
if video_id
params = [] of String
env.params.query.each do |k, v|
params << "#{k}=#{v}"
end
params = params.join("&")
url = "/watch?v=#{video_id}"
if !params.empty?
url += "&#{params}"
end
env.redirect url
else
env.redirect "/channel/#{value}"
end
end
end
# Authenticated endpoints
# The notification APIs can't be extracted yet
# due to the requirement of the `connection_channel`
# used by the `NotificationJob`
get "/api/v1/auth/notifications" do |env|
env.response.content_type = "text/event-stream"
topics = env.params.query["topics"]?.try &.split(",").uniq.first(1000)
topics ||= [] of String
create_notification_stream(env, topics, connection_channel)
end
post "/api/v1/auth/notifications" do |env|
env.response.content_type = "text/event-stream"
topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000)
topics ||= [] of String
create_notification_stream(env, topics, connection_channel)
end
get "/Captcha" do |env|
headers = HTTP::Headers{":authority" => "accounts.google.com"}
response = YT_POOL.client &.get(env.request.resource, headers)
env.response.headers["Content-Type"] = response.headers["Content-Type"]
response.body
end
# Undocumented, creates anonymous playlist with specified 'video_ids', max 50 videos
get "/watch_videos" do |env|
response = YT_POOL.client &.get(env.request.resource)
if url = response.headers["Location"]?
url = URI.parse(url).request_target
next env.redirect url
end
env.response.status_code = response.status_code
end
error 404 do |env| error 404 do |env|
if md = env.request.path.match(/^\/(?<id>([a-zA-Z0-9_-]{11})|(\w+))$/) if md = env.request.path.match(/^\/(?<id>([a-zA-Z0-9_-]{11})|(\w+))$/)
item = md["id"] item = md["id"]
@ -874,7 +479,7 @@ add_handler AuthHandler.new
add_handler DenyFrame.new add_handler DenyFrame.new
add_context_storage_type(Array(String)) add_context_storage_type(Array(String))
add_context_storage_type(Preferences) add_context_storage_type(Preferences)
add_context_storage_type(User) add_context_storage_type(Invidious::User)
Kemal.config.logger = LOGGER 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.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding

@ -144,19 +144,32 @@ def fetch_related_channels(about_channel : AboutChannel) : Array(AboutRelatedCha
return [] of AboutRelatedChannel if tab.nil? return [] of AboutRelatedChannel if tab.nil?
items = tab.dig?("tabRenderer", "content", "sectionListRenderer", "contents", 0, "itemSectionRenderer", "contents", 0, "gridRenderer", "items").try(&.as_a?) || [] of JSON::Any items = tab.dig?(
"tabRenderer", "content",
items.map do |item| "sectionListRenderer", "contents", 0,
related_id = item.dig("gridChannelRenderer", "channelId").as_s "itemSectionRenderer", "contents", 0,
related_title = item.dig("gridChannelRenderer", "title", "simpleText").as_s "gridRenderer", "items"
related_author_url = item.dig("gridChannelRenderer", "navigationEndpoint", "browseEndpoint", "canonicalBaseUrl").as_s ).try &.as_a?
related_author_thumbnail = item.dig("gridChannelRenderer", "thumbnail", "thumbnails", -1, "url").as_s
related = [] of AboutRelatedChannel
AboutRelatedChannel.new( return related if (items.nil? || items.empty?)
items.each do |item|
renderer = item["gridChannelRenderer"]?
next if !renderer
related_id = renderer.dig("channelId").as_s
related_title = renderer.dig("title", "simpleText").as_s
related_author_url = renderer.dig("navigationEndpoint", "browseEndpoint", "canonicalBaseUrl").as_s
related_author_thumbnail = HelperExtractors.get_thumbnails(renderer)
related << AboutRelatedChannel.new(
ucid: related_id, ucid: related_id,
author: related_title, author: related_title,
author_url: related_author_url, author_url: related_author_url,
author_thumbnail: related_author_thumbnail, author_thumbnail: related_author_thumbnail,
) )
end end
return related
end end

@ -78,7 +78,8 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b
when "RELOAD_CONTINUATION_SLOT_HEADER" when "RELOAD_CONTINUATION_SLOT_HEADER"
header = item["reloadContinuationItemsCommand"]["continuationItems"][0] header = item["reloadContinuationItemsCommand"]["continuationItems"][0]
when "RELOAD_CONTINUATION_SLOT_BODY" when "RELOAD_CONTINUATION_SLOT_BODY"
contents = item["reloadContinuationItemsCommand"]["continuationItems"] # continuationItems is nil when video has no comments
contents = item["reloadContinuationItemsCommand"]["continuationItems"]?
end end
elsif item["appendContinuationItemsAction"]? elsif item["appendContinuationItemsAction"]?
contents = item["appendContinuationItemsAction"]["continuationItems"] contents = item["appendContinuationItemsAction"]["continuationItems"]

@ -23,6 +23,7 @@ struct ConfigPreferences
property listen : Bool = false property listen : Bool = false
property local : Bool = false property local : Bool = false
property locale : String = "en-US" property locale : String = "en-US"
property watch_history : Bool = true
property max_results : Int32 = 40 property max_results : Int32 = 40
property notifications_only : Bool = false property notifications_only : Bool = false
property player_style : String = "invidious" property player_style : String = "invidious"
@ -56,20 +57,35 @@ end
class Config class Config
include YAML::Serializable include YAML::Serializable
property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions) # Number of threads to use for crawling videos from channels (for updating subscriptions)
property feed_threads : Int32 = 1 # Number of threads to use for updating feeds property channel_threads : Int32 = 1
property output : String = "STDOUT" # Log file path or STDOUT # Time interval between two executions of the job that crawls channel videos (subscriptions update).
property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr @[YAML::Field(converter: Preferences::TimeSpanConverter)]
property db : DBConfig? = nil # Database configuration with separate parameters (username, hostname, etc) property channel_refresh_interval : Time::Span = 30.minutes
# Number of threads to use for updating feeds
property feed_threads : Int32 = 1
# Log file path or STDOUT
property output : String = "STDOUT"
# Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr
property log_level : LogLevel = LogLevel::Info
# Database configuration with separate parameters (username, hostname, etc)
property db : DBConfig? = nil
# Database configuration using 12-Factor "Database URL" syntax
@[YAML::Field(converter: Preferences::URIConverter)] @[YAML::Field(converter: Preferences::URIConverter)]
property database_url : URI = URI.parse("") # Database configuration using 12-Factor "Database URL" syntax property database_url : URI = URI.parse("")
property decrypt_polling : Bool = true # Use polling to keep decryption function up to date # Use polling to keep decryption function up to date
property full_refresh : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel property decrypt_polling : Bool = true
property https_only : Bool? # Used to tell Invidious it is behind a proxy, so links to resources should be https:// # Used for crawling channels: threads should check all videos uploaded by a channel
property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions property full_refresh : Bool = false
property domain : String? # Domain to be used for links to resources on the site where an absolute URL is required # Used to tell Invidious it is behind a proxy, so links to resources should be https://
property use_pubsub_feeds : Bool | Int32 = false # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) property https_only : Bool?
# HMAC signing key for CSRF tokens and verifying pubsub subscriptions
property hmac_key : String?
# Domain to be used for links to resources on the site where an absolute URL is required
property domain : String?
# Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
property use_pubsub_feeds : Bool | Int32 = false
property popular_enabled : Bool = true property popular_enabled : Bool = true
property captcha_enabled : Bool = true property captcha_enabled : Bool = true
property login_enabled : Bool = true property login_enabled : Bool = true
@ -78,28 +94,42 @@ class Config
property admins : Array(String) = [] of String property admins : Array(String) = [] of String
property external_port : Int32? = nil property external_port : Int32? = nil
property default_user_preferences : ConfigPreferences = ConfigPreferences.from_yaml("") property default_user_preferences : ConfigPreferences = ConfigPreferences.from_yaml("")
property dmca_content : Array(String) = [] of String # For compliance with DMCA, disables download widget using list of video IDs # For compliance with DMCA, disables download widget using list of video IDs
property check_tables : Bool = false # Check table integrity, automatically try to add any missing columns, create tables, etc. property dmca_content : Array(String) = [] of String
property cache_annotations : Bool = false # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards # Check table integrity, automatically try to add any missing columns, create tables, etc.
property banner : String? = nil # Optional banner to be displayed along top of page for announcements, etc. property check_tables : Bool = false
property hsts : Bool? = true # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards
property disable_proxy : Bool? | Array(String)? = false # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' property cache_annotations : Bool = false
# Optional banner to be displayed along top of page for announcements, etc.
property banner : String? = nil
# Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely
property hsts : Bool? = true
# Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local'
property disable_proxy : Bool? | Array(String)? = false
# URL to the modified source code to be easily AGPL compliant # URL to the modified source code to be easily AGPL compliant
# Will display in the footer, next to the main source code link # Will display in the footer, next to the main source code link
property modified_source_code_url : String? = nil property modified_source_code_url : String? = nil
# Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729)
@[YAML::Field(converter: Preferences::FamilyConverter)] @[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 force_resolve : Socket::Family = Socket::Family::UNSPEC
property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument) # Port to listen for connections (overridden by command line argument)
property host_binding : String = "0.0.0.0" # Host to bind (overrided by command line argument) property port : Int32 = 3000
property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) # Host to bind (overridden by command line argument)
property use_quic : Bool = false # Use quic transport for youtube api property host_binding : String = "0.0.0.0"
# Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
property pool_size : Int32 = 100
# Use quic transport for youtube api
property use_quic : Bool = false
# Saved cookies in "name1=value1; name2=value2..." format
@[YAML::Field(converter: Preferences::StringToCookies)] @[YAML::Field(converter: Preferences::StringToCookies)]
property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format property cookies : HTTP::Cookies = HTTP::Cookies.new
property captcha_key : String? = nil # Key for Anti-Captcha # Key for Anti-Captcha
property captcha_api_url : String = "https://api.anti-captcha.com" # API URL for Anti-Captcha property captcha_key : String? = nil
# API URL for Anti-Captcha
property captcha_api_url : String = "https://api.anti-captcha.com"
def disabled?(option) def disabled?(option)
case disabled = CONFIG.disable_proxy case disabled = CONFIG.disable_proxy

@ -171,7 +171,7 @@ module Invidious::Database::Users
WHERE email = $2 WHERE email = $2
SQL SQL
PG_DB.exec(request, user.email, pass) PG_DB.exec(request, pass, user.email)
end end
# ------------------- # -------------------

@ -1,8 +1,12 @@
# Exception used to hold the name of the missing item # Exception used to hold the name of the missing item
# Should be used in all parsing functions # Should be used in all parsing functions
class BrokenTubeException < InfoException class BrokenTubeException < Exception
getter element : String getter element : String
def initialize(@element) def initialize(@element)
end end
def message
return "Missing JSON element \"#{@element}\""
end
end end

@ -0,0 +1,108 @@
module Invidious::Frontend::WatchPage
extend self
# A handy structure to pass many elements at
# once to the download widget function
struct VideoAssets
getter full_videos : Array(Hash(String, JSON::Any))
getter video_streams : Array(Hash(String, JSON::Any))
getter audio_streams : Array(Hash(String, JSON::Any))
getter captions : Array(Caption)
def initialize(
@full_videos,
@video_streams,
@audio_streams,
@captions
)
end
end
def download_widget(locale : String, video : Video, video_assets : VideoAssets) : String
if CONFIG.disabled?("downloads")
return "<p id=\"download\">#{translate(locale, "Download is disabled.")}</p>"
end
return String.build(4000) do |str|
str << "<form"
str << " class=\"pure-form pure-form-stacked\""
str << " action='/download'"
str << " method='post'"
str << " rel='noopener'"
str << " target='_blank'>"
str << '\n'
# Hidden inputs for video id and title
str << "<input type='hidden' name='id' value='" << video.id << "'/>\n"
str << "<input type='hidden' name='title' value='" << HTML.escape(video.title) << "'/>\n"
str << "\t<div class=\"pure-control-group\">\n"
str << "\t\t<label for='download_widget'>"
str << translate(locale, "Download as: ")
str << "</label>\n"
# TODO: remove inline style
str << "\t\t<select style=\"width:100%\" name='download_widget' id='download_widget'>\n"
# Non-DASH videos (audio+video)
video_assets.full_videos.each do |option|
mimetype = option["mimeType"].as_s.split(";")[0]
height = itag_to_metadata?(option["itag"]).try &.["height"]?
value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json
str << "\t\t\t<option value='" << value << "'>"
str << (height || "~240") << "p - " << mimetype
str << "</option>\n"
end
# DASH video streams
video_assets.video_streams.each do |option|
mimetype = option["mimeType"].as_s.split(";")[0]
value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json
str << "\t\t\t<option value='" << value << "'>"
str << option["qualityLabel"] << " - " << mimetype << " @ " << option["fps"] << "fps - video only"
str << "</option>\n"
end
# DASH audio streams
video_assets.audio_streams.each do |option|
mimetype = option["mimeType"].as_s.split(";")[0]
value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json
str << "\t\t\t<option value='" << value << "'>"
str << mimetype << " @ " << (option["bitrate"]?.try &.as_i./ 1000) << "k - audio only"
str << "</option>\n"
end
# Subtitles (a.k.a "closed captions")
video_assets.captions.each do |caption|
value = {"label": caption.name, "ext": "#{caption.language_code}.vtt"}.to_json
str << "\t\t\t<option value='" << value << "'>"
str << translate(locale, "download_subtitles", translate(locale, caption.name))
str << "</option>\n"
end
# End of form
str << "\t\t</select>\n"
str << "\t</div>\n"
str << "\t<button type=\"submit\" class=\"pure-button pure-button-primary\">\n"
str << "\t\t<b>" << translate(locale, "Download") << "</b>\n"
str << "\t</button>\n"
str << "</form>\n"
end
end
end

@ -38,12 +38,15 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce
issue_title = "#{exception.message} (#{exception.class})" issue_title = "#{exception.message} (#{exception.class})"
issue_template = %(Title: `#{issue_title}`) issue_template = <<-TEXT
issue_template += %(\nDate: `#{Time::Format::ISO_8601_DATE_TIME.format(Time.utc)}`) Title: `#{HTML.escape(issue_title)}`
issue_template += %(\nRoute: `#{env.request.resource}`) Date: `#{Time::Format::ISO_8601_DATE_TIME.format(Time.utc)}`
issue_template += %(\nVersion: `#{SOFTWARE["version"]} @ #{SOFTWARE["branch"]}`) Route: `#{HTML.escape(env.request.resource)}`
# issue_template += github_details("Preferences", env.get("preferences").as(Preferences).to_pretty_json) Version: `#{SOFTWARE["version"]} @ #{SOFTWARE["branch"]}`
issue_template += github_details("Backtrace", exception.inspect_with_backtrace)
TEXT
issue_template += github_details("Backtrace", HTML.escape(exception.inspect_with_backtrace))
# URLs for the error message below # URLs for the error message below
url_faq = "https://github.com/iv-org/documentation/blob/master/FAQ.md" url_faq = "https://github.com/iv-org/documentation/blob/master/FAQ.md"

@ -30,6 +30,7 @@ LOCALES_LIST = {
"pt-PT" => "Português de Portugal", # Portuguese (Portugal) "pt-PT" => "Português de Portugal", # Portuguese (Portugal)
"ro" => "Română", # Romanian "ro" => "Română", # Romanian
"ru" => "русский", # Russian "ru" => "русский", # Russian
"sq" => "Shqip", # Albanian
"sr" => "srpski (latinica)", # Serbian (Latin) "sr" => "srpski (latinica)", # Serbian (Latin)
"sr_Cyrl" => "српски (ћирилица)", # Serbian (Cyrillic) "sr_Cyrl" => "српски (ћирилица)", # Serbian (Cyrillic)
"sv-SE" => "Svenska", # Swedish "sv-SE" => "Svenska", # Swedish
@ -135,7 +136,7 @@ def translate_count(locale : String, key : String, count : Int, format = NumberF
# Try #2: Fallback to english # Try #2: Fallback to english
translation = translate_count("en-US", key, count) translation = translate_count("en-US", key, count)
else else
# Return key if we're already in english, as the tranlation is missing # Return key if we're already in english, as the translation is missing
LOGGER.warn("i18n: Missing translation key \"#{key}\"") LOGGER.warn("i18n: Missing translation key \"#{key}\"")
return key return key
end end

@ -44,7 +44,7 @@ def sign_token(key, hash)
# TODO: figure out which "key" variable is used # TODO: figure out which "key" variable is used
# Ameba reports a warning for "Lint/ShadowingOuterLocalVar" on this # Ameba reports a warning for "Lint/ShadowingOuterLocalVar" on this
# variable, but its preferrable to not touch that (works fine atm). # variable, but it's preferable to not touch that (works fine atm).
hash.each do |key, value| hash.each do |key, value|
next if key == "signature" next if key == "signature"

@ -51,6 +51,24 @@ def recode_length_seconds(time)
end end
end end
def decode_interval(string : String) : Time::Span
rawMinutes = string.try &.to_i32?
if !rawMinutes
hours = /(?<hours>\d+)h/.match(string).try &.["hours"].try &.to_i32
hours ||= 0
minutes = /(?<minutes>\d+)m(?!s)/.match(string).try &.["minutes"].try &.to_i32
minutes ||= 0
time = Time::Span.new(hours: hours, minutes: minutes)
else
time = Time::Span.new(minutes: rawMinutes)
end
return time
end
def decode_time(string) def decode_time(string)
time = string.try &.to_f? time = string.try &.to_f?
@ -161,11 +179,11 @@ def short_text_to_number(short_text : String) : Int32
end end
def number_to_short_text(number) def number_to_short_text(number)
seperated = number_with_separator(number).gsub(",", ".").split("") separated = number_with_separator(number).gsub(",", ".").split("")
text = seperated.first(2).join text = separated.first(2).join
if seperated[2]? && seperated[2] != "." if separated[2]? && separated[2] != "."
text += seperated[2] text += separated[2]
end end
text = text.rchop(".0") text = text.rchop(".0")
@ -323,8 +341,8 @@ def fetch_random_instance
instance_list.each do |data| instance_list.each do |data|
# TODO Check if current URL is onion instance and use .onion types if so. # TODO Check if current URL is onion instance and use .onion types if so.
if data[1]["type"] == "https" if data[1]["type"] == "https"
# Instances can have statisitics disabled, which is an requirement of version validation. # Instances can have statistics disabled, which is an requirement of version validation.
# as_nil? doesn't exist. Thus we'll have to handle the error rasied if as_nil fails. # as_nil? doesn't exist. Thus we'll have to handle the error raised if as_nil fails.
begin begin
data[1]["stats"].as_nil data[1]["stats"].as_nil
next next

@ -58,9 +58,8 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
end end
end end
# TODO: make this configurable LOGGER.debug("RefreshChannelsJob: Done, sleeping for #{CONFIG.channel_refresh_interval}")
LOGGER.debug("RefreshChannelsJob: Done, sleeping for thirty minutes") sleep CONFIG.channel_refresh_interval
sleep 30.minutes
Fiber.yield Fiber.yield
end end
end end

@ -401,7 +401,7 @@ def fetch_playlist(plid : String)
end end
def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32, video_id = nil) def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32, video_id = nil)
# Show empy playlist if requested page is out of range # Show empty playlist if requested page is out of range
# (e.g, when a new playlist has been created, offset will be negative) # (e.g, when a new playlist has been created, offset will be negative)
if offset >= playlist.video_count || offset < 0 if offset >= playlist.video_count || offset < 0
return [] of PlaylistVideo return [] of PlaylistVideo

@ -0,0 +1,358 @@
{% skip_file if flag?(:api_only) %}
module Invidious::Routes::Account
extend self
# -------------------
# Password update
# -------------------
# Show the password change interface (GET request)
def get_change_password(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY)
templated "user/change_password"
end
# Handle the password change (POST request)
def post_change_password(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
# We don't store passwords for Google accounts
if !user.password
return error_template(400, "Cannot change password for Google accounts")
end
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
end
password = env.params.body["password"]?
if !password
return error_template(401, "Password is a required field")
end
new_passwords = env.params.body.select { |k, v| k.match(/^new_password\[\d+\]$/) }.map { |k, v| v }
if new_passwords.size <= 1 || new_passwords.uniq.size != 1
return error_template(400, "New passwords must match")
end
new_password = new_passwords.uniq[0]
if new_password.empty?
return error_template(401, "Password cannot be empty")
end
if new_password.bytesize > 55
return error_template(400, "Password cannot be longer than 55 characters")
end
if !Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55))
return error_template(401, "Incorrect password")
end
new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10)
Invidious::Database::Users.update_password(user, new_password.to_s)
env.redirect referer
end
# -------------------
# Account deletion
# -------------------
# Show the account deletion confirmation prompt (GET request)
def get_delete(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY)
templated "user/delete_account"
end
# Handle the account deletion (POST request)
def post_delete(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
end
view_name = "subscriptions_#{sha256(user.email)}"
Invidious::Database::Users.delete(user)
Invidious::Database::SessionIDs.delete(email: user.email)
PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}")
env.request.cookies.each do |cookie|
cookie.expires = Time.utc(1990, 1, 1)
env.response.cookies << cookie
end
env.redirect referer
end
# -------------------
# Clear history
# -------------------
# Show the watch history deletion confirmation prompt (GET request)
def get_clear_history(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY)
templated "user/clear_watch_history"
end
# Handle the watch history clearing (POST request)
def post_clear_history(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
end
Invidious::Database::Users.clear_watch_history(user)
env.redirect referer
end
# -------------------
# Authorize tokens
# -------------------
# Show the "authorize token?" confirmation prompt (GET request)
def get_authorize_token(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY)
scopes = env.params.query["scopes"]?.try &.split(",")
scopes ||= [] of String
callback_url = env.params.query["callback_url"]?
if callback_url
callback_url = URI.parse(callback_url)
end
expire = env.params.query["expire"]?.try &.to_i?
templated "user/authorize_token"
end
# Handle token authorization (POST request)
def post_authorize_token(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = env.get("user").as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
end
scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v }
callback_url = env.params.body["callbackUrl"]?
expire = env.params.body["expire"]?.try &.to_i?
access_token = generate_token(user.email, scopes, expire, HMAC_KEY)
if callback_url
access_token = URI.encode_www_form(access_token)
url = URI.parse(callback_url)
if url.query
query = HTTP::Params.parse(url.query.not_nil!)
else
query = HTTP::Params.new
end
query["token"] = access_token
url.query = query.to_s
env.redirect url.to_s
else
csrf_token = ""
env.set "access_token", access_token
templated "user/authorize_token"
end
end
# -------------------
# Manage tokens
# -------------------
# Show the token manager page (GET request)
def token_manager(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env, "/subscription_manager")
if !user
return env.redirect referer
end
user = user.as(User)
tokens = Invidious::Database::SessionIDs.select_all(user.email)
templated "user/token_manager"
end
# -------------------
# AJAX for tokens
# -------------------
# Handle internal (non-API) token actions (POST request)
def token_ajax(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
redirect = env.params.query["redirect"]?
redirect ||= "true"
redirect = redirect == "true"
if !user
if redirect
return env.redirect referer
else
return error_json(403, "No such user")
end
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
if redirect
return error_template(400, ex)
else
return error_json(400, ex)
end
end
if env.params.query["action_revoke_token"]?
action = "action_revoke_token"
else
return env.redirect referer
end
session = env.params.query["session"]?
session ||= ""
case action
when .starts_with? "action_revoke_token"
Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
else
return error_json(400, "Unsupported action #{action}")
end
if redirect
return env.redirect referer
else
env.response.content_type = "application/json"
return "{}"
end
end
end

@ -343,7 +343,7 @@ module Invidious::Routes::API::V1::Authenticated
env.response.content_type = "text/html" env.response.content_type = "text/html"
csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, use_nonce: true) csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, use_nonce: true)
return templated "authorize_token" return templated "user/authorize_token"
else else
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -397,4 +397,14 @@ module Invidious::Routes::API::V1::Authenticated
env.response.status_code = 204 env.response.status_code = 204
end end
def self.notifications(env)
env.response.content_type = "text/event-stream"
raw_topics = env.params.body["topics"]? || env.params.query["topics"]?
topics = raw_topics.try &.split(",").uniq.first(1000)
topics ||= [] of String
create_notification_stream(env, topics, CONNECTION_CHANNEL)
end
end end

@ -96,7 +96,14 @@ module Invidious::Routes::API::V1::Channels
json.field "relatedChannels" do json.field "relatedChannels" do
json.array do json.array do
fetch_related_channels(channel).each do |related_channel| # Fetch related channels
begin
related_channels = fetch_related_channels(channel)
rescue ex
related_channels = [] of AboutRelatedChannel
end
related_channels.each do |related_channel|
json.object do json.object do
json.field "author", related_channel.author json.field "author", related_channel.author
json.field "authorId", related_channel.ucid json.field "authorId", related_channel.ucid
@ -118,7 +125,8 @@ module Invidious::Routes::API::V1::Channels
end end
end end
end end
end end # relatedChannels
end end
end end
end end

@ -4,10 +4,10 @@ module Invidious::Routes::API::V1::Misc
env.response.content_type = "application/json" env.response.content_type = "application/json"
if !CONFIG.statistics_enabled if !CONFIG.statistics_enabled
return error_json(400, "Statistics are not enabled.") return {"software" => SOFTWARE}.to_json
else
return Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json
end end
Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json
end end
# APIv1 currently uses the same logic for both # APIv1 currently uses the same logic for both

@ -23,7 +23,11 @@ module Invidious::Routes::API::V1::Videos
env.response.content_type = "application/json" env.response.content_type = "application/json"
id = env.params.url["id"] id = env.params.url["id"]
region = env.params.query["region"]? region = env.params.query["region"]? || env.params.body["region"]?
if id.nil? || id.size != 11 || !id.matches?(/^[\w-]+$/)
return error_json(400, "Invalid video ID")
end
# See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354 # See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354
# It is possible to use `/api/timedtext?type=list&v=#{id}` and # It is possible to use `/api/timedtext?type=list&v=#{id}` and
@ -130,7 +134,13 @@ module Invidious::Routes::API::V1::Videos
end end
end end
else else
# Some captions have "align:[start/end]" and "position:[num]%"
# attributes. Those are causing issues with VideoJS, which is unable
# to properly align the captions on the video, so we remove them.
#
# See: https://github.com/iv-org/invidious/issues/2391
webvtt = YT_POOL.client &.get("#{url}&format=vtt").body webvtt = YT_POOL.client &.get("#{url}&format=vtt").body
.gsub(/([0-9:.]{12} --> [0-9:.]{12}).+/, "\\1")
end end
if title = env.params.query["title"]? if title = env.params.query["title"]?

@ -147,6 +147,39 @@ module Invidious::Routes::Channels
end end
end end
def self.live(env)
locale = env.get("preferences").as(Preferences).locale
# Appears to be a bug in routing, having several routes configured
# as `/a/:a`, `/b/:a`, `/c/:a` results in 404
value = env.request.resource.split("/")[2]
body = ""
{"channel", "user", "c"}.each do |type|
response = YT_POOL.client &.get("/#{type}/#{value}/live?disable_polymer=1")
if response.status_code == 200
body = response.body
end
end
video_id = body.match(/'VIDEO_ID': "(?<id>[a-zA-Z0-9_-]{11})"/).try &.["id"]?
if video_id
params = [] of String
env.params.query.each do |k, v|
params << "#{k}=#{v}"
end
params = params.join("&")
url = "/watch?v=#{video_id}"
if !params.empty?
url += "&#{params}"
end
env.redirect url
else
env.redirect "/channel/#{value}"
end
end
private def self.fetch_basic_information(env) private def self.fetch_basic_information(env)
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale

@ -27,7 +27,7 @@ module Invidious::Routes::Login
tfa = env.params.query["tfa"]? tfa = env.params.query["tfa"]?
prompt = nil prompt = nil
templated "login" templated "user/login"
end end
def self.login(env) def self.login(env)
@ -133,7 +133,7 @@ module Invidious::Routes::Login
tfa = tfa_code tfa = tfa_code
captcha = {tokens: [token], question: ""} captcha = {tokens: [token], question: ""}
return templated "login" return templated "user/login"
end end
if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED" if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED"
@ -190,7 +190,7 @@ module Invidious::Routes::Login
tfa = nil tfa = nil
captcha = nil captcha = nil
return templated "login" return templated "user/login"
end end
tl = challenge_results[1][2] tl = challenge_results[1][2]
@ -282,18 +282,8 @@ module Invidious::Routes::Login
host = URI.parse(env.request.headers["Host"]).host host = URI.parse(env.request.headers["Host"]).host
if Kemal.config.ssl || CONFIG.https_only
secure = true
else
secure = false
end
cookies.each do |cookie| cookies.each do |cookie|
if Kemal.config.ssl || CONFIG.https_only cookie.secure = Invidious::User::Cookies::SECURE
cookie.secure = secure
else
cookie.secure = secure
end
if cookie.extension if cookie.extension
cookie.extension = cookie.extension.not_nil!.gsub(".youtube.com", host) cookie.extension = cookie.extension.not_nil!.gsub(".youtube.com", host)
@ -338,19 +328,7 @@ module Invidious::Routes::Login
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
Invidious::Database::SessionIDs.insert(sid, email) Invidious::Database::SessionIDs.insert(sid, email)
if Kemal.config.ssl || CONFIG.https_only env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
secure = true
else
secure = false
end
if CONFIG.domain
env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{CONFIG.domain}", value: sid, expires: Time.utc + 2.years,
secure: secure, http_only: true)
else
env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years,
secure: secure, http_only: true)
end
else else
return error_template(401, "Wrong username or password") return error_template(401, "Wrong username or password")
end end
@ -393,12 +371,12 @@ module Invidious::Routes::Login
prompt = "" prompt = ""
if captcha_type == "image" if captcha_type == "image"
captcha = generate_captcha(HMAC_KEY) captcha = Invidious::User::Captcha.generate_image(HMAC_KEY)
else else
captcha = generate_text_captcha(HMAC_KEY) captcha = Invidious::User::Captcha.generate_text(HMAC_KEY)
end end
return templated "login" return templated "user/login"
end end
tokens = env.params.body.select { |k, _| k.match(/^token\[\d+\]$/) }.map { |_, v| v } tokens = env.params.body.select { |k, _| k.match(/^token\[\d+\]$/) }.map { |_, v| v }
@ -455,19 +433,7 @@ module Invidious::Routes::Login
view_name = "subscriptions_#{sha256(user.email)}" view_name = "subscriptions_#{sha256(user.email)}"
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
if Kemal.config.ssl || CONFIG.https_only env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
secure = true
else
secure = false
end
if CONFIG.domain
env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{CONFIG.domain}", value: sid, expires: Time.utc + 2.years,
secure: secure, http_only: true)
else
env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years,
secure: secure, http_only: true)
end
if env.request.cookies["PREFS"]? if env.request.cookies["PREFS"]?
user.preferences = env.get("preferences").as(Preferences) user.preferences = env.get("preferences").as(Preferences)
@ -515,4 +481,11 @@ module Invidious::Routes::Login
env.redirect referer env.redirect referer
end end
def self.captcha(env)
headers = HTTP::Headers{":authority" => "accounts.google.com"}
response = YT_POOL.client &.get(env.request.resource, headers)
env.response.headers["Content-Type"] = response.headers["Content-Type"]
response.body
end
end end

@ -443,4 +443,15 @@ module Invidious::Routes::Playlists
templated "mix" templated "mix"
end end
# Undocumented, creates anonymous playlist with specified 'video_ids', max 50 videos
def self.watch_videos(env)
response = YT_POOL.client &.get(env.request.resource)
if url = response.headers["Location"]?
url = URI.parse(url).request_target
return env.redirect url
end
env.response.status_code = response.status_code
end
end end

@ -8,7 +8,7 @@ module Invidious::Routes::PreferencesRoute
preferences = env.get("preferences").as(Preferences) preferences = env.get("preferences").as(Preferences)
templated "preferences" templated "user/preferences"
end end
def self.update(env) def self.update(env)
@ -47,6 +47,10 @@ module Invidious::Routes::PreferencesRoute
local ||= "off" local ||= "off"
local = local == "on" local = local == "on"
watch_history = env.params.body["watch_history"]?.try &.as(String)
watch_history ||= "off"
watch_history = watch_history == "on"
speed = env.params.body["speed"]?.try &.as(String).to_f32? speed = env.params.body["speed"]?.try &.as(String).to_f32?
speed ||= CONFIG.default_user_preferences.speed speed ||= CONFIG.default_user_preferences.speed
@ -136,7 +140,7 @@ module Invidious::Routes::PreferencesRoute
notifications_only ||= "off" notifications_only ||= "off"
notifications_only = notifications_only == "on" notifications_only = notifications_only == "on"
# Convert to JSON and back again to take advantage of converters used for compatability # Convert to JSON and back again to take advantage of converters used for compatibility
preferences = Preferences.from_json({ preferences = Preferences.from_json({
annotations: annotations, annotations: annotations,
annotations_subscribed: annotations_subscribed, annotations_subscribed: annotations_subscribed,
@ -149,6 +153,7 @@ module Invidious::Routes::PreferencesRoute
latest_only: latest_only, latest_only: latest_only,
listen: listen, listen: listen,
local: local, local: local,
watch_history: watch_history,
locale: locale, locale: locale,
max_results: max_results, max_results: max_results,
notifications_only: notifications_only, notifications_only: notifications_only,
@ -214,19 +219,7 @@ module Invidious::Routes::PreferencesRoute
File.write("config/config.yml", CONFIG.to_yaml) File.write("config/config.yml", CONFIG.to_yaml)
end end
else else
if Kemal.config.ssl || CONFIG.https_only env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
secure = true
else
secure = false
end
if CONFIG.domain
env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years,
secure: secure, http_only: true)
else
env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years,
secure: secure, http_only: true)
end
end end
env.redirect referer env.redirect referer
@ -261,21 +254,7 @@ module Invidious::Routes::PreferencesRoute
preferences.dark_mode = "dark" preferences.dark_mode = "dark"
end end
preferences = preferences.to_json env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
if Kemal.config.ssl || CONFIG.https_only
secure = true
else
secure = false
end
if CONFIG.domain
env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: URI.encode_www_form(preferences), expires: Time.utc + 2.years,
secure: secure, http_only: true)
else
env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: URI.encode_www_form(preferences), expires: Time.utc + 2.years,
secure: secure, http_only: true)
end
end end
if redirect if redirect
@ -298,7 +277,7 @@ module Invidious::Routes::PreferencesRoute
user = user.as(User) user = user.as(User)
templated "data_control" templated "user/data_control"
end end
def self.update_data_control(env) def self.update_data_control(env)
@ -321,149 +300,27 @@ module Invidious::Routes::PreferencesRoute
# TODO: Unify into single import based on content-type # TODO: Unify into single import based on content-type
case part.name case part.name
when "import_invidious" when "import_invidious"
body = JSON.parse(body) Invidious::User::Import.from_invidious(user, body)
if body["subscriptions"]?
user.subscriptions += body["subscriptions"].as_a.map(&.as_s)
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
end
if body["watch_history"]?
user.watched += body["watch_history"].as_a.map(&.as_s)
user.watched.uniq!
Invidious::Database::Users.update_watch_history(user)
end
if body["preferences"]?
user.preferences = Preferences.from_json(body["preferences"].to_json)
Invidious::Database::Users.update_preferences(user)
end
if playlists = body["playlists"]?.try &.as_a?
playlists.each do |item|
title = item["title"]?.try &.as_s?.try &.delete("<>")
description = item["description"]?.try &.as_s?.try &.delete("\r")
privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy }
next if !title
next if !description
next if !privacy
playlist = create_playlist(title, privacy, user)
Invidious::Database::Playlists.update_description(playlist.id, description)
videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500
video_id = video_id.try &.as_s?
next if !video_id
begin
video = get_video(video_id)
rescue ex
next
end
playlist_video = PlaylistVideo.new({
title: video.title,
id: video.id,
author: video.author,
ucid: video.ucid,
length_seconds: video.length_seconds,
published: video.published,
plid: playlist.id,
live_now: video.live_now,
index: Random::Secure.rand(0_i64..Int64::MAX),
})
Invidious::Database::PlaylistVideos.insert(playlist_video)
Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index)
end
end
end
when "import_youtube" when "import_youtube"
filename = part.filename || "" filename = part.filename || ""
extension = filename.split(".").last success = Invidious::User::Import.from_youtube(user, body, filename, type)
if extension == "xml" || type == "application/xml" || type == "text/xml" if !success
subscriptions = XML.parse(body)
user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel|
channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0]
end
elsif extension == "json" || type == "application/json"
subscriptions = JSON.parse(body)
user.subscriptions += subscriptions.as_a.compact_map do |entry|
entry["snippet"]["resourceId"]["channelId"].as_s
end
elsif extension == "csv" || type == "text/csv"
subscriptions = parse_subscription_export_csv(body)
user.subscriptions += subscriptions
else
haltf(env, status_code: 415, haltf(env, status_code: 415,
response: error_template(415, "Invalid subscription file uploaded") response: error_template(415, "Invalid subscription file uploaded")
) )
end end
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
when "import_freetube" when "import_freetube"
user.subscriptions += body.scan(/"channelId":"(?<channel_id>[a-zA-Z0-9_-]{24})"/).map do |md| Invidious::User::Import.from_freetube(user, body)
md["channel_id"]
end
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
when "import_newpipe_subscriptions" when "import_newpipe_subscriptions"
body = JSON.parse(body) Invidious::User::Import.from_newpipe_subs(user, body)
user.subscriptions += body["subscriptions"].as_a.compact_map do |channel|
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")
html = XML.parse_html(response.body)
ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
next ucid if ucid
end
nil
end
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
when "import_newpipe" when "import_newpipe"
Compress::Zip::Reader.open(IO::Memory.new(body)) do |file| success = Invidious::User::Import.from_newpipe(user, body)
file.each_entry do |entry|
if entry.filename == "newpipe.db"
tempfile = File.tempfile(".db")
File.write(tempfile.path, entry.io.gets_to_end)
db = DB.open("sqlite3://" + tempfile.path)
user.watched += db.query_all("SELECT url FROM streams", as: String).map(&.lchop("https://www.youtube.com/watch?v="))
user.watched.uniq!
Invidious::Database::Users.update_watch_history(user)
user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map(&.lchop("https://www.youtube.com/channel/"))
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions) if !success
haltf(env, status_code: 415,
Invidious::Database::Users.update_subscriptions(user) response: error_template(415, "Uploaded file is too large")
)
db.close
tempfile.delete
end
end
end end
else nil # Ignore else nil # Ignore
end end

@ -163,6 +163,6 @@ module Invidious::Routes::Subscriptions
end end
end end
templated "subscription_manager" templated "user/subscription_manager"
end end
end end

@ -14,12 +14,18 @@ module Invidious::Routes::VideoPlayback
end end
if query_params["host"]? && !query_params["host"].empty? if query_params["host"]? && !query_params["host"].empty?
host = "https://#{query_params["host"]}" host = query_params["host"]
query_params.delete("host") query_params.delete("host")
else else
host = "https://r#{fvip}---#{mns.pop}.googlevideo.com" host = "r#{fvip}---#{mns.pop}.googlevideo.com"
end end
# Sanity check, to avoid being used as an open proxy
if !host.matches?(/[\w-]+.googlevideo.com/)
return error_template(400, "Invalid \"host\" parameter.")
end
host = "https://#{host}"
url = "/videoplayback?#{query_params}" url = "/videoplayback?#{query_params}"
headers = HTTP::Headers.new headers = HTTP::Headers.new
@ -158,7 +164,9 @@ module Invidious::Routes::VideoPlayback
if title = query_params["title"]? if title = query_params["title"]?
# https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/
env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" filename = URI.encode_www_form(title, space_to_plus: false)
header = "attachment; filename=\"#{filename}\"; filename*=UTF-8''#{filename}"
env.response.headers["Content-Disposition"] = header
end end
if !resp.headers.includes_word?("Transfer-Encoding", "chunked") if !resp.headers.includes_word?("Transfer-Encoding", "chunked")
@ -236,31 +244,25 @@ module Invidious::Routes::VideoPlayback
# YouTube /videoplayback links expire after 6 hours, # YouTube /videoplayback links expire after 6 hours,
# so we have a mechanism here to redirect to the latest version # so we have a mechanism here to redirect to the latest version
def self.latest_version(env) def self.latest_version(env)
if env.params.query["download_widget"]? id = env.params.query["id"]?
download_widget = JSON.parse(env.params.query["download_widget"]) itag = env.params.query["itag"]?.try &.to_i?
id = download_widget["id"].as_s # Sanity checks
title = URI.decode_www_form(download_widget["title"].as_s) if id.nil? || id.size != 11 || !id.matches?(/^[\w-]+$/)
return error_template(400, "Invalid video ID")
if label = download_widget["label"]?
return env.redirect "/api/v1/captions/#{id}?label=#{label}&title=#{title}"
else
itag = download_widget["itag"].as_s.to_i
local = "true"
end
end end
id ||= env.params.query["id"]? if itag.nil? || itag <= 0 || itag >= 1000
itag ||= env.params.query["itag"]?.try &.to_i return error_template(400, "Invalid itag")
end
region = env.params.query["region"]? region = env.params.query["region"]?
local = (env.params.query["local"]? == "true")
local ||= env.params.query["local"]? title = env.params.query["title"]?
local ||= "false"
local = local == "true"
if !id || !itag if title && CONFIG.disabled?("downloads")
haltf env, status_code: 400, response: "TESTING" return error_template(403, "Administrator has disabled this endpoint.")
end end
video = get_video(id, region: region) video = get_video(id, region: region)
@ -272,8 +274,10 @@ module Invidious::Routes::VideoPlayback
haltf env, status_code: 404 haltf env, status_code: 404
end end
url = URI.parse(url).request_target.not_nil! if local if local
url = "#{url}&title=#{title}" if title url = URI.parse(url).request_target.not_nil!
url += "&title=#{URI.encode_www_form(title, space_to_plus: false)}" if title
end
return env.redirect url return env.redirect url
end end

@ -75,7 +75,7 @@ module Invidious::Routes::Watch
end end
env.params.query.delete_all("iv_load_policy") env.params.query.delete_all("iv_load_policy")
if watched && !watched.includes? id if watched && preferences.watch_history && !watched.includes? id
Invidious::Database::Users.mark_watched(user.as(User), id) Invidious::Database::Users.mark_watched(user.as(User), id)
end end
@ -189,6 +189,14 @@ module Invidious::Routes::Watch
return env.redirect url return env.redirect url
end end
# Structure used for the download widget
video_assets = Invidious::Frontend::WatchPage::VideoAssets.new(
full_videos: fmt_stream,
video_streams: video_streams,
audio_streams: audio_streams,
captions: video.captions
)
templated "watch" templated "watch"
end end
@ -281,4 +289,49 @@ module Invidious::Routes::Watch
return error_template(404, "The requested clip doesn't exist") return error_template(404, "The requested clip doesn't exist")
end end
end end
def self.download(env)
if CONFIG.disabled?("downloads")
return error_template(403, "Administrator has disabled this endpoint.")
end
title = env.params.body["title"]? || ""
video_id = env.params.body["id"]? || ""
selection = env.params.body["download_widget"]?
if title.empty? || video_id.empty? || selection.nil?
return error_template(400, "Missing form data")
end
download_widget = JSON.parse(selection)
extension = download_widget["ext"].as_s
filename = "#{video_id}-#{title}.#{extension}"
# Pass form parameters as URL parameters for the handlers of both
# /latest_version and /api/v1/captions. This avoids an un-necessary
# redirect and duplicated (and hazardous) sanity checks.
env.params.query["id"] = video_id
env.params.query["title"] = filename
# Delete the useless ones
env.params.body.delete("id")
env.params.body.delete("title")
env.params.body.delete("download_widget")
if label = download_widget["label"]?
# URL params specific to /api/v1/captions/:id
env.params.query["label"] = URI.encode_www_form(label.as_s, space_to_plus: false)
return Invidious::Routes::API::V1::Videos.captions(env)
elsif itag = download_widget["itag"]?.try &.as_i
# URL params specific to /latest_version
env.params.query["itag"] = itag.to_s
env.params.query["local"] = "true"
return Invidious::Routes::VideoPlayback.latest_version(env)
else
return error_template(400, "Invalid label or itag")
end
end
end end

@ -10,6 +10,33 @@ module Invidious::Routing
{% end %} {% end %}
end end
macro define_user_routes
# User login/out
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 "/Captcha", Invidious::Routes::Login, :captcha
# User preferences
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 "/data_control", Invidious::Routes::PreferencesRoute, :data_control
Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control
# User account management
Invidious::Routing.get "/change_password", Invidious::Routes::Account, :get_change_password
Invidious::Routing.post "/change_password", Invidious::Routes::Account, :post_change_password
Invidious::Routing.get "/delete_account", Invidious::Routes::Account, :get_delete
Invidious::Routing.post "/delete_account", Invidious::Routes::Account, :post_delete
Invidious::Routing.get "/clear_watch_history", Invidious::Routes::Account, :get_clear_history
Invidious::Routing.post "/clear_watch_history", Invidious::Routes::Account, :post_clear_history
Invidious::Routing.get "/authorize_token", Invidious::Routes::Account, :get_authorize_token
Invidious::Routing.post "/authorize_token", Invidious::Routes::Account, :post_authorize_token
Invidious::Routing.get "/token_manager", Invidious::Routes::Account, :token_manager
Invidious::Routing.post "/token_ajax", Invidious::Routes::Account, :token_ajax
end
macro define_v1_api_routes macro define_v1_api_routes
{{namespace = Invidious::Routes::API::V1}} {{namespace = Invidious::Routes::API::V1}}
# Videos # Videos
@ -69,6 +96,9 @@ macro define_v1_api_routes
Invidious::Routing.post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token Invidious::Routing.post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token
Invidious::Routing.post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token Invidious::Routing.post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token
Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
# Misc # Misc
Invidious::Routing.get "/api/v1/stats", {{namespace}}::Misc, :stats 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/playlists/:plid", {{namespace}}::Misc, :get_playlist

@ -176,7 +176,7 @@ end
def process_search_query(query, page, user, region) def process_search_query(query, page, user, region)
if user if user
user = user.as(User) user = user.as(Invidious::User)
view_name = "subscriptions_#{sha256(user.email)}" view_name = "subscriptions_#{sha256(user.email)}"
end end

@ -0,0 +1,78 @@
require "openssl/hmac"
struct Invidious::User
module Captcha
extend self
private TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com")
def generate_image(key)
second = Random::Secure.rand(12)
second_angle = second * 30
second = second * 5
minute = Random::Secure.rand(12)
minute_angle = minute * 30
minute = minute * 5
hour = Random::Secure.rand(12)
hour_angle = hour * 30 + minute_angle.to_f / 12
if hour == 0
hour = 12
end
clock_svg = <<-END_SVG
<svg viewBox="0 0 100 100" width="200px" height="200px">
<circle cx="50" cy="50" r="45" fill="#eee" stroke="black" stroke-width="2"></circle>
<text x="69" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 1</text>
<text x="82.909" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 2</text>
<text x="88" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 3</text>
<text x="82.909" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 4</text>
<text x="69" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 5</text>
<text x="50" y="91" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 6</text>
<text x="31" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 7</text>
<text x="17.091" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 8</text>
<text x="12" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 9</text>
<text x="17.091" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">10</text>
<text x="31" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">11</text>
<text x="50" y="15" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">12</text>
<circle cx="50" cy="50" r="3" fill="black"></circle>
<line id="second" transform="rotate(#{second_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="12" fill="black" stroke="black" stroke-width="1"></line>
<line id="minute" transform="rotate(#{minute_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="16" fill="black" stroke="black" stroke-width="2"></line>
<line id="hour" transform="rotate(#{hour_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="24" fill="black" stroke="black" stroke-width="2"></line>
</svg>
END_SVG
image = "data:image/png;base64,"
image += Process.run(%(rsvg-convert -w 400 -h 400 -b none -f png), shell: true,
input: IO::Memory.new(clock_svg), output: Process::Redirect::Pipe
) do |proc|
Base64.strict_encode(proc.output.gets_to_end)
end
answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}"
answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer)
return {
question: image,
tokens: {generate_response(answer, {":login"}, key, use_nonce: true)},
}
end
def generate_text(key)
response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body)
response = JSON.parse(response)
tokens = response["a"].as_a.map do |answer|
generate_response(answer.as_s, {":login"}, key, use_nonce: true)
end
return {
question: response["q"].as_s,
tokens: tokens,
}
end
end
end

@ -0,0 +1,37 @@
require "http/cookie"
struct Invidious::User
module Cookies
extend self
# Note: we use ternary operator because the two variables
# used in here are not booleans.
SECURE = (Kemal.config.ssl || CONFIG.https_only) ? true : false
# Session ID (SID) cookie
# Parameter "domain" comes from the global config
def sid(domain : String?, sid) : HTTP::Cookie
return HTTP::Cookie.new(
name: "SID",
domain: domain,
value: sid,
expires: Time.utc + 2.years,
secure: SECURE,
http_only: true
)
end
# Preferences (PREFS) cookie
# Parameter "domain" comes from the global config
def prefs(domain : String?, preferences : Preferences) : HTTP::Cookie
return HTTP::Cookie.new(
name: "PREFS",
domain: domain,
value: URI.encode_www_form(preferences.to_json),
expires: Time.utc + 2.years,
secure: SECURE,
http_only: true
)
end
end
end

@ -1,27 +1,242 @@
require "csv" require "csv"
def parse_subscription_export_csv(csv_content : String) struct Invidious::User
rows = CSV.new(csv_content, headers: true) module Import
subscriptions = Array(String).new extend self
# Counter to limit the amount of imports. # Parse a youtube CSV subscription file
# This is intended to prevent DoS. def parse_subscription_export_csv(csv_content : String)
row_counter = 0 rows = CSV.new(csv_content, headers: true)
subscriptions = Array(String).new
rows.each do |row| # Counter to limit the amount of imports.
# Limit to 1200 # This is intended to prevent DoS.
row_counter += 1 row_counter = 0
break if row_counter > 1_200
# Channel ID is the first column in the csv export we can't use the header rows.each do |row|
# name, because the header name is localized depending on the # Limit to 1200
# language the user has set on their account row_counter += 1
channel_id = row[0].strip break if row_counter > 1_200
next if channel_id.empty? # Channel ID is the first column in the csv export we can't use the header
# name, because the header name is localized depending on the
# language the user has set on their account
channel_id = row[0].strip
subscriptions << channel_id next if channel_id.empty?
end subscriptions << channel_id
end
return subscriptions return subscriptions
end
# -------------------
# Invidious
# -------------------
# Import from another invidious account
def from_invidious(user : User, body : String)
data = JSON.parse(body)
if data["subscriptions"]?
user.subscriptions += data["subscriptions"].as_a.map(&.as_s)
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
end
if data["watch_history"]?
user.watched += data["watch_history"].as_a.map(&.as_s)
user.watched.uniq!
Invidious::Database::Users.update_watch_history(user)
end
if data["preferences"]?
user.preferences = Preferences.from_json(data["preferences"].to_json)
Invidious::Database::Users.update_preferences(user)
end
if playlists = data["playlists"]?.try &.as_a?
playlists.each do |item|
title = item["title"]?.try &.as_s?.try &.delete("<>")
description = item["description"]?.try &.as_s?.try &.delete("\r")
privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy }
next if !title
next if !description
next if !privacy
playlist = create_playlist(title, privacy, user)
Invidious::Database::Playlists.update_description(playlist.id, description)
videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500
video_id = video_id.try &.as_s?
next if !video_id
begin
video = get_video(video_id)
rescue ex
next
end
playlist_video = PlaylistVideo.new({
title: video.title,
id: video.id,
author: video.author,
ucid: video.ucid,
length_seconds: video.length_seconds,
published: video.published,
plid: playlist.id,
live_now: video.live_now,
index: Random::Secure.rand(0_i64..Int64::MAX),
})
Invidious::Database::PlaylistVideos.insert(playlist_video)
Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index)
end
end
end
end
# -------------------
# Youtube
# -------------------
private def is_opml?(mimetype : String, extension : String)
opml_mimetypes = [
"application/xml",
"text/xml",
"text/x-opml",
"text/x-opml+xml",
]
opml_extensions = ["xml", "opml"]
return opml_mimetypes.any?(&.== mimetype) || opml_extensions.any?(&.== extension)
end
# Import subscribed channels from Youtube
# Returns success status
def from_youtube(user : User, body : String, filename : String, type : String) : Bool
extension = filename.split(".").last
if is_opml?(type, extension)
subscriptions = XML.parse(body)
user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel|
channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0]
end
elsif extension == "json" || type == "application/json"
subscriptions = JSON.parse(body)
user.subscriptions += subscriptions.as_a.compact_map do |entry|
entry["snippet"]["resourceId"]["channelId"].as_s
end
elsif extension == "csv" || type == "text/csv"
subscriptions = parse_subscription_export_csv(body)
user.subscriptions += subscriptions
else
return false
end
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
return true
end
# -------------------
# Freetube
# -------------------
def from_freetube(user : User, body : String)
# Legacy import?
matches = body.scan(/"channelId":"(?<channel_id>[a-zA-Z0-9_-]{24})"/)
subs = matches.map(&.["channel_id"])
if subs.empty?
data = JSON.parse(body)["subscriptions"]
subs = data.as_a.map(&.["id"].as_s)
end
user.subscriptions += subs
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
end
# -------------------
# Newpipe
# -------------------
def from_newpipe_subs(user : User, body : String)
data = JSON.parse(body)
user.subscriptions += data["subscriptions"].as_a.compact_map do |channel|
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>.+)/)
# Resolve URL using the API
resolved_url = YoutubeAPI.resolve_url("https://www.youtube.com/user/#{match["user"]}")
ucid = resolved_url.dig?("endpoint", "browseEndpoint", "browseId")
next ucid.as_s if ucid
end
nil
end
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
end
def from_newpipe(user : User, body : String) : Bool
io = IO::Memory.new(body)
Compress::Zip::File.open(io) do |file|
file.entries.each do |entry|
entry.open do |file_io|
# Ensure max size of 4MB
io_sized = IO::Sized.new(file_io, 0x400000)
next if entry.filename != "newpipe.db"
tempfile = File.tempfile(".db")
begin
File.write(tempfile.path, io_sized.gets_to_end)
rescue
return false
end
db = DB.open("sqlite3://" + tempfile.path)
user.watched += db.query_all("SELECT url FROM streams", as: String)
.map(&.lchop("https://www.youtube.com/watch?v="))
user.watched.uniq!
Invidious::Database::Users.update_watch_history(user)
user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String)
.map(&.lchop("https://www.youtube.com/channel/"))
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
db.close
tempfile.delete
end
end
end
# Success!
return true
end
end # module
end end

@ -23,6 +23,7 @@ struct Preferences
property latest_only : Bool = CONFIG.default_user_preferences.latest_only property latest_only : Bool = CONFIG.default_user_preferences.latest_only
property listen : Bool = CONFIG.default_user_preferences.listen property listen : Bool = CONFIG.default_user_preferences.listen
property local : Bool = CONFIG.default_user_preferences.local property local : Bool = CONFIG.default_user_preferences.local
property watch_history : Bool = CONFIG.default_user_preferences.watch_history
property vr_mode : Bool = CONFIG.default_user_preferences.vr_mode property vr_mode : Bool = CONFIG.default_user_preferences.vr_mode
property show_nick : Bool = CONFIG.default_user_preferences.show_nick property show_nick : Bool = CONFIG.default_user_preferences.show_nick
@ -256,4 +257,18 @@ struct Preferences
cookies cookies
end end
end end
module TimeSpanConverter
def self.to_yaml(value : Time::Span, yaml : YAML::Nodes::Builder)
return yaml.scalar value.total_minutes.to_i32
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Time::Span
if node.is_a?(YAML::Nodes::Scalar)
return decode_interval(node.value)
else
node.raise "Expected scalar, not #{node.class}"
end
end
end
end end

@ -0,0 +1,27 @@
require "db"
struct Invidious::User
include DB::Serializable
property updated : Time
property notifications : Array(String)
property subscriptions : Array(String)
property email : String
@[DB::Field(converter: Invidious::User::PreferencesConverter)]
property preferences : Preferences
property password : String?
property token : String
property watched : Array(String)
property feed_needs_update : Bool?
module PreferencesConverter
def self.from_rs(rs)
begin
Preferences.from_json(rs.read(String))
rescue ex
Preferences.from_json("{}")
end
end
end
end

@ -3,32 +3,6 @@ require "crypto/bcrypt/password"
# Materialized views may not be defined using bound parameters (`$1` as used elsewhere) # Materialized views may not be defined using bound parameters (`$1` as used elsewhere)
MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" } MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" }
struct User
include DB::Serializable
property updated : Time
property notifications : Array(String)
property subscriptions : Array(String)
property email : String
@[DB::Field(converter: User::PreferencesConverter)]
property preferences : Preferences
property password : String?
property token : String
property watched : Array(String)
property feed_needs_update : Bool?
module PreferencesConverter
def self.from_rs(rs)
begin
Preferences.from_json(rs.read(String))
rescue ex
Preferences.from_json("{}")
end
end
end
end
def get_user(sid, headers, refresh = true) def get_user(sid, headers, refresh = true)
if email = Invidious::Database::SessionIDs.select_email(sid) if email = Invidious::Database::SessionIDs.select_email(sid)
user = Invidious::Database::Users.select!(email: email) user = Invidious::Database::Users.select!(email: email)
@ -84,7 +58,7 @@ def fetch_user(sid, headers)
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
user = User.new({ user = Invidious::User.new({
updated: Time.utc, updated: Time.utc,
notifications: [] of String, notifications: [] of String,
subscriptions: channels, subscriptions: channels,
@ -102,7 +76,7 @@ def create_user(sid, email, password)
password = Crypto::Bcrypt::Password.create(password, cost: 10) password = Crypto::Bcrypt::Password.create(password, cost: 10)
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
user = User.new({ user = Invidious::User.new({
updated: Time.utc, updated: Time.utc,
notifications: [] of String, notifications: [] of String,
subscriptions: [] of String, subscriptions: [] of String,
@ -117,75 +91,6 @@ def create_user(sid, email, password)
return user, sid return user, sid
end end
def generate_captcha(key)
second = Random::Secure.rand(12)
second_angle = second * 30
second = second * 5
minute = Random::Secure.rand(12)
minute_angle = minute * 30
minute = minute * 5
hour = Random::Secure.rand(12)
hour_angle = hour * 30 + minute_angle.to_f / 12
if hour == 0
hour = 12
end
clock_svg = <<-END_SVG
<svg viewBox="0 0 100 100" width="200px" height="200px">
<circle cx="50" cy="50" r="45" fill="#eee" stroke="black" stroke-width="2"></circle>
<text x="69" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 1</text>
<text x="82.909" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 2</text>
<text x="88" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 3</text>
<text x="82.909" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 4</text>
<text x="69" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 5</text>
<text x="50" y="91" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 6</text>
<text x="31" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 7</text>
<text x="17.091" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 8</text>
<text x="12" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 9</text>
<text x="17.091" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">10</text>
<text x="31" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">11</text>
<text x="50" y="15" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">12</text>
<circle cx="50" cy="50" r="3" fill="black"></circle>
<line id="second" transform="rotate(#{second_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="12" fill="black" stroke="black" stroke-width="1"></line>
<line id="minute" transform="rotate(#{minute_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="16" fill="black" stroke="black" stroke-width="2"></line>
<line id="hour" transform="rotate(#{hour_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="24" fill="black" stroke="black" stroke-width="2"></line>
</svg>
END_SVG
image = "data:image/png;base64,"
image += Process.run(%(rsvg-convert -w 400 -h 400 -b none -f png), shell: true,
input: IO::Memory.new(clock_svg), output: Process::Redirect::Pipe
) do |proc|
Base64.strict_encode(proc.output.gets_to_end)
end
answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}"
answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer)
return {
question: image,
tokens: {generate_response(answer, {":login"}, key, use_nonce: true)},
}
end
def generate_text_captcha(key)
response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body)
response = JSON.parse(response)
tokens = response["a"].as_a.map do |answer|
generate_response(answer.as_s, {":login"}, key, use_nonce: true)
end
return {
question: response["q"].as_s,
tokens: tokens,
}
end
def subscribe_ajax(channel_id, action, env_headers) def subscribe_ajax(channel_id, action, env_headers)
headers = HTTP::Headers.new headers = HTTP::Headers.new
headers["Cookie"] = env_headers["Cookie"] headers["Cookie"] = env_headers["Cookie"]

@ -2,6 +2,8 @@ CAPTION_LANGUAGES = {
"", "",
"English", "English",
"English (auto-generated)", "English (auto-generated)",
"English (United Kingdom)",
"English (United States)",
"Afrikaans", "Afrikaans",
"Albanian", "Albanian",
"Amharic", "Amharic",
@ -14,23 +16,31 @@ CAPTION_LANGUAGES = {
"Bosnian", "Bosnian",
"Bulgarian", "Bulgarian",
"Burmese", "Burmese",
"Cantonese (Hong Kong)",
"Catalan", "Catalan",
"Cebuano", "Cebuano",
"Chinese",
"Chinese (China)",
"Chinese (Hong Kong)",
"Chinese (Simplified)", "Chinese (Simplified)",
"Chinese (Taiwan)",
"Chinese (Traditional)", "Chinese (Traditional)",
"Corsican", "Corsican",
"Croatian", "Croatian",
"Czech", "Czech",
"Danish", "Danish",
"Dutch", "Dutch",
"Dutch (auto-generated)",
"Esperanto", "Esperanto",
"Estonian", "Estonian",
"Filipino", "Filipino",
"Finnish", "Finnish",
"French", "French",
"French (auto-generated)",
"Galician", "Galician",
"Georgian", "Georgian",
"German", "German",
"German (auto-generated)",
"Greek", "Greek",
"Gujarati", "Gujarati",
"Haitian Creole", "Haitian Creole",
@ -43,14 +53,19 @@ CAPTION_LANGUAGES = {
"Icelandic", "Icelandic",
"Igbo", "Igbo",
"Indonesian", "Indonesian",
"Indonesian (auto-generated)",
"Interlingue",
"Irish", "Irish",
"Italian", "Italian",
"Italian (auto-generated)",
"Japanese", "Japanese",
"Japanese (auto-generated)",
"Javanese", "Javanese",
"Kannada", "Kannada",
"Kazakh", "Kazakh",
"Khmer", "Khmer",
"Korean", "Korean",
"Korean (auto-generated)",
"Kurdish", "Kurdish",
"Kyrgyz", "Kyrgyz",
"Lao", "Lao",
@ -73,9 +88,12 @@ CAPTION_LANGUAGES = {
"Persian", "Persian",
"Polish", "Polish",
"Portuguese", "Portuguese",
"Portuguese (auto-generated)",
"Portuguese (Brazil)",
"Punjabi", "Punjabi",
"Romanian", "Romanian",
"Russian", "Russian",
"Russian (auto-generated)",
"Samoan", "Samoan",
"Scottish Gaelic", "Scottish Gaelic",
"Serbian", "Serbian",
@ -87,7 +105,10 @@ CAPTION_LANGUAGES = {
"Somali", "Somali",
"Southern Sotho", "Southern Sotho",
"Spanish", "Spanish",
"Spanish (auto-generated)",
"Spanish (Latin America)", "Spanish (Latin America)",
"Spanish (Mexico)",
"Spanish (Spain)",
"Sundanese", "Sundanese",
"Swahili", "Swahili",
"Swedish", "Swedish",
@ -96,10 +117,12 @@ CAPTION_LANGUAGES = {
"Telugu", "Telugu",
"Thai", "Thai",
"Turkish", "Turkish",
"Turkish (auto-generated)",
"Ukrainian", "Ukrainian",
"Urdu", "Urdu",
"Uzbek", "Uzbek",
"Vietnamese", "Vietnamese",
"Vietnamese (auto-generated)",
"Welsh", "Welsh",
"Western Frisian", "Western Frisian",
"Xhosa", "Xhosa",
@ -868,11 +891,13 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
if player_response["playabilityStatus"]?.try &.["status"]?.try &.as_s != "OK" if player_response.dig?("playabilityStatus", "status").try &.as_s != "OK"
reason = player_response["playabilityStatus"]["errorScreen"]?.try &.["playerErrorMessageRenderer"]?.try &.["subreason"]?.try { |s| subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason")
s["simpleText"]?.try &.as_s || s["runs"].as_a.map { |r| r["text"] }.join("") reason = subreason.try &.[]?("simpleText").try &.as_s
} || player_response["playabilityStatus"]["reason"].as_s reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("")
reason ||= player_response.dig("playabilityStatus", "reason").as_s
params["reason"] = JSON::Any.new(reason) params["reason"] = JSON::Any.new(reason)
return params
end end
params["shortDescription"] = player_response.dig?("videoDetails", "shortDescription") || JSON::Any.new(nil) params["shortDescription"] = player_response.dig?("videoDetails", "shortDescription") || JSON::Any.new(nil)
@ -915,11 +940,8 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results
primary_results = main_results.dig?("results", "results", "contents") primary_results = main_results.dig?("results", "results", "contents")
secondary_results = main_results
.dig?("secondaryResults", "secondaryResults", "results")
raise BrokenTubeException.new("results") if !primary_results raise BrokenTubeException.new("results") if !primary_results
raise BrokenTubeException.new("secondaryResults") if !secondary_results
video_primary_renderer = primary_results video_primary_renderer = primary_results
.as_a.find(&.["videoPrimaryInfoRenderer"]?) .as_a.find(&.["videoPrimaryInfoRenderer"]?)
@ -939,7 +961,9 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
related = [] of JSON::Any related = [] of JSON::Any
# Parse "compactVideoRenderer" items (under secondary results) # Parse "compactVideoRenderer" items (under secondary results)
secondary_results.as_a.each do |element| secondary_results = main_results
.dig?("secondaryResults", "secondaryResults", "results")
secondary_results.try &.as_a.each do |element|
if item = element["compactVideoRenderer"]? if item = element["compactVideoRenderer"]?
related_video = parse_related_video(item) related_video = parse_related_video(item)
related << JSON::Any.new(related_video) if related_video related << JSON::Any.new(related_video) if related_video
@ -1108,7 +1132,9 @@ def fetch_video(id, region)
info = embed_info if !embed_info["reason"]? info = embed_info if !embed_info["reason"]?
end end
raise InfoException.new(info["reason"]?.try &.as_s || "") if !info["videoDetails"]? if reason = info["reason"]?
raise InfoException.new(reason.as_s || "")
end
video = Video.new({ video = Video.new({
id: id, id: id,

@ -54,7 +54,7 @@
<img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> <img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% if plid = env.get?("remove_playlist_items") %> <% if plid = env.get?("remove_playlist_items") %>
<form data-onsubmit="return_false" action="/playlist_ajax?action_remove_video=1&set_video_id=<%= item.index %>&playlist_id=<%= plid %>&referer=<%= env.get("current_page") %>" method="post"> <form data-onsubmit="return_false" action="/playlist_ajax?action_remove_video=1&set_video_id=<%= item.index %>&playlist_id=<%= plid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<p class="watched"> <p class="watched">
<a data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid %>" href="javascript:void(0)"> <a data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid %>" href="javascript:void(0)">
<button type="submit" style="all:unset"> <button type="submit" style="all:unset">
@ -106,7 +106,7 @@
<img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> <img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% if env.get? "show_watched" %> <% if env.get? "show_watched" %>
<form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post"> <form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<p class="watched"> <p class="watched">
<a data-onclick="mark_watched" data-id="<%= item.id %>" href="javascript:void(0)"> <a data-onclick="mark_watched" data-id="<%= item.id %>" href="javascript:void(0)">
<button type="submit" style="all:unset"> <button type="submit" style="all:unset">
@ -119,7 +119,7 @@
</form> </form>
<% elsif plid = env.get? "add_playlist_items" %> <% elsif plid = env.get? "add_playlist_items" %>
<form data-onsubmit="return_false" action="/playlist_ajax?action_add_video=1&video_id=<%= item.id %>&playlist_id=<%= plid %>&referer=<%= env.get("current_page") %>" method="post"> <form data-onsubmit="return_false" action="/playlist_ajax?action_add_video=1&video_id=<%= item.id %>&playlist_id=<%= plid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<p class="watched"> <p class="watched">
<a data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid %>" href="javascript:void(0)"> <a data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid %>" href="javascript:void(0)">
<button type="submit" style="all:unset"> <button type="submit" style="all:unset">

@ -2,7 +2,7 @@
<% if subscriptions.includes? ucid %> <% if subscriptions.includes? ucid %>
<p> <p>
<form action="/subscription_ajax?action_remove_subscriptions=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post"> <form action="/subscription_ajax?action_remove_subscriptions=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button data-type="unsubscribe" id="subscribe" class="pure-button pure-button-primary"> <button data-type="unsubscribe" id="subscribe" class="pure-button pure-button-primary">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b> <b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b>
</button> </button>
@ -11,7 +11,7 @@
<% else %> <% else %>
<p> <p>
<form action="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post"> <form action="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button data-type="subscribe" id="subscribe" class="pure-button pure-button-primary"> <button data-type="subscribe" id="subscribe" class="pure-button pure-button-primary">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b> <b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b>
</button> </button>

@ -30,7 +30,7 @@
</button> </button>
</div> </div>
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>">
</fieldset> </fieldset>
</form> </form>
</div> </div>

@ -19,6 +19,6 @@
</div> </div>
</div> </div>
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>">
</form> </form>
</div> </div>

@ -41,7 +41,7 @@
<div class="h-box"> <div class="h-box">
<textarea maxlength="5000" name="description" style="margin-top:10px;max-width:100%;height:20vh" class="pure-input-1"><%= playlist.description %></textarea> <textarea maxlength="5000" name="description" style="margin-top:10px;max-width:100%;height:20vh" class="pure-input-1"><%= playlist.description %></textarea>
</div> </div>
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>">
</form> </form>
<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> <% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %>

@ -7,7 +7,7 @@
<meta name="thumbnail" content="<%= thumbnail %>"> <meta name="thumbnail" content="<%= thumbnail %>">
<%= rendered "components/player_sources" %> <%= rendered "components/player_sources" %>
<link rel="stylesheet" href="/videojs/videojs-overlay/videojs-overlay.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/videojs/videojs-overlay/videojs-overlay.css?v=<%= ASSET_COMMIT %>">
<script src="videojs/videojs-overlay/videojs-overlay.js?v=<%= ASSET_COMMIT %>"></script> <script src="/videojs/videojs-overlay/videojs-overlay.js?v=<%= ASSET_COMMIT %>"></script>
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/embed.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/embed.css?v=<%= ASSET_COMMIT %>">
<title><%= HTML.escape(video.title) %> - Invidious</title> <title><%= HTML.escape(video.title) %> - Invidious</title>

@ -52,7 +52,7 @@
</div> </div>
<div class="pure-u-1-4"> <div class="pure-u-1-4">
<a id="notification_ticker" title="<%= translate(locale, "Subscriptions") %>" href="/feed/subscriptions" class="pure-menu-heading"> <a id="notification_ticker" title="<%= translate(locale, "Subscriptions") %>" href="/feed/subscriptions" class="pure-menu-heading">
<% notification_count = env.get("user").as(User).notifications.size %> <% notification_count = env.get("user").as(Invidious::User).notifications.size %>
<% if notification_count > 0 %> <% if notification_count > 0 %>
<span id="notification_count"><%= notification_count %></span> <i class="icon ion-ios-notifications"></i> <span id="notification_count"><%= notification_count %></span> <i class="icon ion-ios-notifications"></i>
<% else %> <% else %>
@ -67,12 +67,12 @@
</div> </div>
<% if env.get("preferences").as(Preferences).show_nick %> <% if env.get("preferences").as(Preferences).show_nick %>
<div class="pure-u-1-4"> <div class="pure-u-1-4">
<span id="user_name"><%= env.get("user").as(User).email %></span> <span id="user_name"><%= env.get("user").as(Invidious::User).email %></span>
</div> </div>
<% end %> <% end %>
<div class="pure-u-1-4"> <div class="pure-u-1-4">
<form action="/signout?referer=<%= env.get?("current_page") %>" method="post"> <form action="/signout?referer=<%= env.get?("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<a class="pure-menu-heading" href="#"> <a class="pure-menu-heading" href="#">
<input style="all:unset" type="submit" value="<%= translate(locale, "Log out") %>"> <input style="all:unset" type="submit" value="<%= translate(locale, "Log out") %>">
</a> </a>

@ -72,7 +72,7 @@
<input type="hidden" name="expire" value="<%= expire %>"> <input type="hidden" name="expire" value="<%= expire %>">
<% end %> <% end %>
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>">
</form> </form>
</div> </div>
<% end %> <% end %>

@ -23,7 +23,7 @@
<%= translate(locale, "Change password") %> <%= translate(locale, "Change password") %>
</button> </button>
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>">
</fieldset> </fieldset>
</form> </form>
</div> </div>

@ -19,6 +19,6 @@
</div> </div>
</div> </div>
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>">
</form> </form>
</div> </div>

@ -19,6 +19,6 @@
</div> </div>
</div> </div>
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>">
</form> </form>
</div> </div>

@ -66,7 +66,7 @@
<% captcha = captcha.not_nil! %> <% captcha = captcha.not_nil! %>
<img style="width:50%" src='<%= captcha[:question] %>'/> <img style="width:50%" src='<%= captcha[:question] %>'/>
<% captcha[:tokens].each_with_index do |token, i| %> <% captcha[:tokens].each_with_index do |token, i| %>
<input type="hidden" name="token[<%= i %>]" value="<%= URI.encode_www_form(token) %>"> <input type="hidden" name="token[<%= i %>]" value="<%= HTML.escape(token) %>">
<% end %> <% end %>
<input type="hidden" name="captcha_type" value="image"> <input type="hidden" name="captcha_type" value="image">
<label for="answer"><%= translate(locale, "Time (h:mm:ss):") %></label> <label for="answer"><%= translate(locale, "Time (h:mm:ss):") %></label>
@ -74,7 +74,7 @@
<% else # "text" %> <% else # "text" %>
<% captcha = captcha.not_nil! %> <% captcha = captcha.not_nil! %>
<% captcha[:tokens].each_with_index do |token, i| %> <% captcha[:tokens].each_with_index do |token, i| %>
<input type="hidden" name="token[<%= i %>]" value="<%= URI.encode_www_form(token) %>"> <input type="hidden" name="token[<%= i %>]" value="<%= HTML.escape(token) %>">
<% end %> <% end %>
<input type="hidden" name="captcha_type" value="text"> <input type="hidden" name="captcha_type" value="text">
<label for="answer"><%= captcha[:question] %></label> <label for="answer"><%= captcha[:question] %></label>

@ -206,6 +206,11 @@
<% if env.get? "user" %> <% if env.get? "user" %>
<legend><%= translate(locale, "preferences_category_subscription") %></legend> <legend><%= translate(locale, "preferences_category_subscription") %></legend>
<div class="pure-control-group">
<label for="watch_history"><%= translate(locale, "preferences_watch_history_label") %></label>
<input name="watch_history" id="watch_history" type="checkbox" <% if preferences.watch_history %>checked<% end %>>
</div>
<div class="pure-control-group"> <div class="pure-control-group">
<label for="annotations_subscribed"><%= translate(locale, "preferences_annotations_subscribed_label") %></label> <label for="annotations_subscribed"><%= translate(locale, "preferences_annotations_subscribed_label") %></label>
<input name="annotations_subscribed" id="annotations_subscribed" type="checkbox" <% if preferences.annotations_subscribed %>checked<% end %>> <input name="annotations_subscribed" id="annotations_subscribed" type="checkbox" <% if preferences.annotations_subscribed %>checked<% end %>>
@ -252,7 +257,7 @@
<% end %> <% end %>
<% end %> <% end %>
<% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(User).email %> <% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(Invidious::User).email %>
<legend><%= translate(locale, "preferences_category_admin") %></legend> <legend><%= translate(locale, "preferences_category_admin") %></legend>
<div class="pure-control-group"> <div class="pure-control-group">

@ -38,7 +38,7 @@
<div class="pure-u-1-5" style="text-align:right"> <div class="pure-u-1-5" style="text-align:right">
<h3 style="padding-right:0.5em"> <h3 style="padding-right:0.5em">
<form data-onsubmit="return_false" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post"> <form data-onsubmit="return_false" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<a data-onclick="remove_subscription" data-ucid="<%= channel.id %>" href="#"> <a data-onclick="remove_subscription" data-ucid="<%= channel.id %>" href="#">
<input style="all:unset" type="submit" value="<%= translate(locale, "unsubscribe") %>"> <input style="all:unset" type="submit" value="<%= translate(locale, "unsubscribe") %>">
</a> </a>

@ -30,7 +30,7 @@
<div class="pure-u-1-5" style="text-align:right"> <div class="pure-u-1-5" style="text-align:right">
<h3 style="padding-right:0.5em"> <h3 style="padding-right:0.5em">
<form data-onsubmit="return_false" action="/token_ajax?action_revoke_token=1&session=<%= token[:session] %>&referer=<%= env.get("current_page") %>" method="post"> <form data-onsubmit="return_false" action="/token_ajax?action_revoke_token=1&session=<%= token[:session] %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<a data-onclick="revoke_token" data-session="<%= token[:session] %>" href="#"> <a data-onclick="revoke_token" data-session="<%= token[:session] %>" href="#">
<input style="all:unset" type="submit" value="<%= translate(locale, "revoke") %>"> <input style="all:unset" type="submit" value="<%= translate(locale, "revoke") %>">
</a> </a>

@ -168,41 +168,7 @@ we're going to need to do it here in order to allow for translations.
<% end %> <% end %>
<% end %> <% end %>
<% if CONFIG.dmca_content.includes?(video.id) || CONFIG.disabled?("downloads") %> <%= Invidious::Frontend::WatchPage.download_widget(locale, video, video_assets) %>
<p id="download"><%= translate(locale, "Download is disabled.") %></p>
<% else %>
<form class="pure-form pure-form-stacked" action="/latest_version" method="get" rel="noopener" target="_blank">
<div class="pure-control-group">
<label for="download_widget"><%= translate(locale, "Download as: ") %></label>
<select style="width:100%" name="download_widget" id="download_widget">
<% fmt_stream.each do |option| %>
<option value='{"id":"<%= video.id %>","itag":"<%= option["itag"] %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= option["mimeType"].as_s.split(";")[0].split("/")[1] %>"}'>
<%= itag_to_metadata?(option["itag"]).try &.["height"]? || "~240" %>p - <%= option["mimeType"].as_s.split(";")[0] %>
</option>
<% end %>
<% video_streams.each do |option| %>
<option value='{"id":"<%= video.id %>","itag":"<%= option["itag"] %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= option["mimeType"].as_s.split(";")[0].split("/")[1] %>"}'>
<%= option["qualityLabel"] %> - <%= option["mimeType"].as_s.split(";")[0] %> @ <%= option["fps"] %>fps - video only
</option>
<% end %>
<% audio_streams.each do |option| %>
<option value='{"id":"<%= video.id %>","itag":"<%= option["itag"] %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= option["mimeType"].as_s.split(";")[0].split("/")[1] %>"}'>
<%= option["mimeType"].as_s.split(";")[0] %> @ <%= option["bitrate"]?.try &.as_i./ 1000 %>k - audio only
</option>
<% end %>
<% captions.each do |caption| %>
<option value='{"id":"<%= video.id %>","label":"<%= caption.name %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= caption.language_code %>.vtt"}'>
<%= translate(locale, "download_subtitles", translate(locale, caption.name)) %>
</option>
<% end %>
</select>
</div>
<button type="submit" class="pure-button pure-button-primary">
<b><%= translate(locale, "Download") %></b>
</button>
</form>
<% end %>
<p id="views"><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p> <p id="views"><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p>
<p id="likes"><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p> <p id="likes"><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p>

@ -588,7 +588,7 @@ def extract_item(item : JSON::Any, author_fallback : String? = "",
# Cycles through all of the item parsers and attempt to parse the raw YT JSON data. # 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 # 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. # applicable to itself. If not nil is returned and the next parser is attempted.
ITEM_PARSERS.each do |parser| ITEM_PARSERS.each do |parser|
LOGGER.trace("extract_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)") LOGGER.trace("extract_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)")

@ -90,7 +90,7 @@ module YoutubeAPI
property client_type : ClientType property client_type : ClientType
# Region to provide to youtube, e.g to alter search results # Region to provide to youtube, e.g to alter search results
# (this is passed as the `gl` parmeter). # (this is passed as the `gl` parameter).
property region : String | Nil property region : String | Nil
# ISO code of country where the proxy is located. # ISO code of country where the proxy is located.
@ -205,7 +205,7 @@ module YoutubeAPI
# :ditto: # :ditto:
def browse( def browse(
browse_id : String, browse_id : String,
*, # Force the following paramters to be passed by name *, # Force the following parameters to be passed by name
params : String, params : String,
client_config : ClientConfig | Nil = nil client_config : ClientConfig | Nil = nil
) )
@ -215,7 +215,7 @@ module YoutubeAPI
"context" => self.make_context(client_config), "context" => self.make_context(client_config),
} }
# Append the additionnal parameters if those were provided # Append the additional parameters if those were provided
# (this is required for channel info, playlist and community, e.g) # (this is required for channel info, playlist and community, e.g)
if params != "" if params != ""
data["params"] = params data["params"] = params
@ -292,14 +292,14 @@ module YoutubeAPI
# and POST data in order to get a JSON reply. # and POST data in order to get a JSON reply.
# #
# The requested data is a video ID (`v=` parameter), with some # The requested data is a video ID (`v=` parameter), with some
# additional paramters, formatted as a base64 string. # additional parameters, formatted as a base64 string.
# #
# An optional ClientConfig parameter can be passed, too (see # An optional ClientConfig parameter can be passed, too (see
# `struct ClientConfig` above for more details). # `struct ClientConfig` above for more details).
# #
def player( def player(
video_id : String, video_id : String,
*, # Force the following paramters to be passed by name *, # Force the following parameters to be passed by name
params : String, params : String,
client_config : ClientConfig | Nil = nil client_config : ClientConfig | Nil = nil
) )
@ -309,7 +309,7 @@ module YoutubeAPI
"context" => self.make_context(client_config), "context" => self.make_context(client_config),
} }
# Append the additionnal parameters if those were provided # Append the additional parameters if those were provided
if params != "" if params != ""
data["params"] = params data["params"] = params
end end
@ -363,7 +363,7 @@ module YoutubeAPI
# order to get non-US results. # order to get non-US results.
# #
# The requested data is a search string, with some additional # The requested data is a search string, with some additional
# paramters, formatted as a base64 string. # parameters, formatted as a base64 string.
# #
# An optional ClientConfig parameter can be passed, too (see # An optional ClientConfig parameter can be passed, too (see
# `struct ClientConfig` above for more details). # `struct ClientConfig` above for more details).

Loading…
Cancel
Save