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

This commit is contained in:
Jonas 2022-02-25 19:29:12 +01:00 committed by GitHub
commit 6de449811d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 2498 additions and 1241 deletions

View file

@ -62,7 +62,8 @@ test:
crystal spec
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:
echo "Targets available in this Makefile:"
echo ""
echo "get-libs Fetch Crystal libraries"
echo "invidious Build Invidious"
echo "run Launch Invidious"
echo ""
echo "format Run the Crystal formatter"
echo "test Run tests"
echo "verify Just make sure that the code compiles, but without"
echo " generating any binaries. Useful to search for errors"
echo ""
echo "clean Remove build artifacts"
echo "distclean Remove build artifacts and libraries"
echo ""
echo ""
echo "Build options available for this Makefile:"
echo ""
echo "RELEASE Make a release build (Default: 1)"
echo "STATIC Link libraries statically (Default: 0)"
echo ""
echo "DISABLE_QUIC Disable support for QUIC (Default: 0)"
echo "NO_DBG_SYMBOLS Strip debug symbols (Default: 0)"
@echo "Targets available in this Makefile:"
@echo ""
@echo " get-libs Fetch Crystal libraries"
@echo " invidious Build Invidious"
@echo " run Launch Invidious"
@echo ""
@echo " format Run the Crystal formatter"
@echo " test Run tests"
@echo " verify Just make sure that the code compiles, but without"
@echo " generating any binaries. Useful to search for errors"
@echo ""
@echo " clean Remove build artifacts"
@echo " distclean Remove build artifacts and libraries"
@echo ""
@echo ""
@echo "Build options available for this Makefile:"
@echo ""
@echo " RELEASE Make a release build (Default: 1)"
@echo " STATIC Link libraries statically (Default: 0)"
@echo ""
@echo " DISABLE_QUIC Disable support for QUIC (Default: 0)"
@echo " NO_DBG_SYMBOLS Strip debug symbols (Default: 0)"

View file

@ -51,8 +51,8 @@
<img alt="Mastodon: @invidious@social.tchncs.de" src="https://img.shields.io/badge/Mastodon-%40invidious%40social.tchncs.de-darkgreen">
</a>
<br>
<a href="#contact-the-team-directly">
<img alt="Contact the team directly" src="https://img.shields.io/badge/E%2d%2dmail-darkgreen">
<a href="https://invidious.io/contact/">
<img alt="E-mail" src="https://img.shields.io/badge/E%2d%2dmail-darkgreen">
</a>
</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.
## 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
We take no responsibility for the use of our tool, or external instances

View file

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

View file

@ -35,21 +35,11 @@ if (player_data.aspect_ratio) {
var embed_url = new URL(location);
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;
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) {
if (options.uri.indexOf('videoplayback') === -1 && options.uri.indexOf('local=true') === -1) {
options.uri = options.uri + '?local=true';
@ -59,34 +49,55 @@ videojs.Vhs.xhr.beforeRequest = function(options) {
var player = videojs('player', options);
/**
* Function for add time argument to url
* @param {String} url
* @returns urlWithTimeArg
*/
function addCurrentTimeToURL(url) {
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);
}
try { if (localStorage.length !== -1) return localStorage; }
catch (e) { console.info('No storage available: ' + e); }
return undefined;
})();
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({
overlays: [{
start: 'loadstart',
content: '<h1><a rel="noopener" target="_blank" href="' + location.origin + '/watch?v=' + video_data.id + '">' + player_data.title + '</a></h1>',
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'
}]
overlays: [
{ start: 'loadstart', content: overlay_content, end: 'playing', align: 'top'},
{ start: 'pause', content: overlay_content, 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
function isMobile() {
@ -99,9 +110,7 @@ if (isMobile()) {
buttons = ["playToggle", "volumePanel", "captionsButton"];
if (video_data.params.quality !== 'dash') {
buttons.push("qualitySelector")
}
if (video_data.params.quality !== 'dash') buttons.push("qualitySelector")
// Create new control bar object for operation buttons
const ControlBar = videojs.getComponent("controlBar");
@ -119,7 +128,7 @@ if (isMobile()) {
operations_bar_element.className += " mobile-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]
operations_bar_element.append(playback_element)
@ -138,7 +147,7 @@ if (isMobile()) {
player.on('error', function (event) {
if (player.error().code === 2 || player.error().code === 4) {
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 playbackRate = player.playbackRate();
@ -146,16 +155,12 @@ player.on('error', function (event) {
player.load();
if (currentTime > 0.5) {
currentTime -= 0.5;
}
if (currentTime > 0.5) currentTime -= 0.5;
player.currentTime(currentTime);
player.playbackRate(playbackRate);
if (!paused) {
player.play();
}
if (!paused) player.play();
}, 5000);
}
});
@ -183,13 +188,8 @@ if (video_data.params.video_start > 0 || video_data.params.video_end > 0) {
player.markers({
onMarkerReached: function (marker) {
if (marker.text === 'End') {
if (player.loop()) {
player.markers.prev('Start');
} else {
player.pause();
}
}
if (marker.text === 'End')
player.loop() ? player.markers.prev('Start') : player.pause();
},
markers: markers
});
@ -217,9 +217,7 @@ if (video_data.params.save_player_pos) {
const remeberedTime = get_video_time();
let lastUpdated = 0;
if(!hasTimeParam) {
set_seconds_after_start(remeberedTime);
}
if(!hasTimeParam) set_seconds_after_start(remeberedTime);
const updateTime = () => {
const raw = player.currentTime();
@ -233,9 +231,7 @@ if (video_data.params.save_player_pos) {
player.on("timeupdate", updateTime);
}
else {
remove_all_video_times();
}
else remove_all_video_times();
if (video_data.params.autoplay) {
var bpb = player.getChild('bigPlayButton');
@ -433,26 +429,10 @@ function set_time_percent(percent) {
player.currentTime(newTime);
}
function play() {
player.play();
}
function pause() {
player.pause();
}
function stop() {
player.pause();
player.currentTime(0);
}
function toggle_play() {
if (player.paused()) {
play();
} else {
pause();
}
}
function play() { player.play(); }
function pause() { player.pause(); }
function stop() { player.pause(); player.currentTime(0); }
function toggle_play() { player.paused() ? play() : pause(); }
const toggle_captions = (function () {
let toggledTrack = null;
@ -490,9 +470,7 @@ const toggle_captions = (function () {
const tracks = player.textTracks();
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (track.kind !== 'captions') {
continue;
}
if (track.kind !== 'captions') continue;
if (fallbackCaptionsTrack === null) {
fallbackCaptionsTrack = track;
@ -513,11 +491,7 @@ const toggle_captions = (function () {
})();
function toggle_fullscreen() {
if (player.isFullscreen()) {
player.exitFullscreen();
} else {
player.requestFullscreen();
}
player.isFullscreen() ? player.exitFullscreen() : player.requestFullscreen();
}
function increase_playback_rate(steps) {
@ -560,27 +534,15 @@ window.addEventListener('keydown', e => {
action = toggle_play;
break;
case 'MediaPlay':
action = play;
break;
case 'MediaPause':
action = pause;
break;
case 'MediaStop':
action = stop;
break;
case 'MediaPlay': action = play; break;
case 'MediaPause': action = pause; break;
case 'MediaStop': action = stop; break;
case 'ArrowUp':
if (isPlayerFocused) {
action = increase_volume.bind(this, 0.1);
}
if (isPlayerFocused) action = increase_volume.bind(this, 0.1);
break;
case 'ArrowDown':
if (isPlayerFocused) {
action = increase_volume.bind(this, -0.1);
}
if (isPlayerFocused) action = increase_volume.bind(this, -0.1);
break;
case 'm':
@ -612,16 +574,15 @@ window.addEventListener('keydown', e => {
case '7':
case '8':
case '9':
// Ignore numpad numbers
if (code > 57) break;
const percent = (code - 48) * 10;
action = set_time_percent.bind(this, percent);
break;
case 'c':
action = toggle_captions;
break;
case 'f':
action = toggle_fullscreen;
break;
case 'c': action = toggle_captions; break;
case 'f': action = toggle_fullscreen; break;
case 'N':
case 'MediaTrackNext':
@ -639,12 +600,8 @@ window.addEventListener('keydown', e => {
// TODO: Add support for previous-frame-stepping.
break;
case '>':
action = increase_playback_rate.bind(this, 1);
break;
case '<':
action = increase_playback_rate.bind(this, -1);
break;
case '>': action = increase_playback_rate.bind(this, 1); break;
case '<': action = increase_playback_rate.bind(this, -1); break;
default:
console.info('Unhandled key down event: %s:', decoratedKey, e);

View file

@ -77,7 +77,7 @@ function update_mode (mode) {
// If preference for dark mode indicated
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
set_mode(false);
}

View file

@ -163,7 +163,7 @@ https_only: 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..."
## Default: <none>
@ -188,7 +188,7 @@ https_only: false
##
## 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.
##
## Accepted values: a filesystem path or 'STDOUT'
@ -197,7 +197,7 @@ https_only: false
#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.
##
## Accepted values: All, Trace, Debug, Info, Warn, Error, Fatal, Off
@ -306,7 +306,7 @@ https_only: false
##
## Notes:
## - 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.
##
## Accepted values: a positive integer
@ -314,6 +314,15 @@ https_only: false
##
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
## videos when crawling channel (during subscriptions update).
@ -328,7 +337,7 @@ full_refresh: false
##
## Notes:
## - 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.
##
## Accepted values: a positive integer
@ -371,7 +380,7 @@ feed_threads: 1
# -----------------------------
# Miscellanous
# Miscellaneous
# -----------------------------
##
@ -433,7 +442,7 @@ feed_threads: 1
#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.
##
## Accepted values: a string
@ -520,9 +529,9 @@ default_user_preferences:
#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
## to not penalize people using other languages.
##
@ -594,7 +603,7 @@ default_user_preferences:
#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
## 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
##
#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

View file

@ -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:
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:
build:
context: .
@ -21,27 +15,42 @@ services:
ports:
- "127.0.0.1:3000:3000"
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: |
channel_threads: 1
check_tables: true
feed_threads: 1
db:
dbname: invidious
user: kemal
password: kemal
host: postgres
host: invidious-db
port: 5432
dbname: invidious
full_refresh: false
https_only: false
domain:
check_tables: true
# external_port:
# domain:
# https_only: false
# statistics_enabled: false
healthcheck:
test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/comments/jNQXAC9IVRw || exit 1
interval: 30s
timeout: 5s
retries: 2
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:
postgresdata:

View file

@ -42,7 +42,7 @@ RUN addgroup -g 1000 -S invidious && \
adduser -u 1000 -S invidious -G invidious
COPY --chown=invidious ./config/config.* ./config/
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 ./locales/ ./locales/
COPY --from=builder /invidious/assets ./assets/

View file

@ -41,7 +41,7 @@ RUN addgroup -g 1000 -S invidious && \
adduser -u 1000 -S invidious -G invidious
COPY --chown=invidious ./config/config.* ./config/
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 ./locales/ ./locales/
COPY --from=builder /invidious/assets ./assets/

View file

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

View file

@ -21,15 +21,15 @@
"No": "Όχι",
"Import and Export Data": "Εισαγωγή και Εξαγωγή Δεδομένων",
"Import": "Εισαγωγή",
"Import Invidious data": "Εισαγωγή δεδομένων Invidious",
"Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube",
"Import Invidious data": "Εsαγωγή δεδομένων Invidious JSON",
"Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Εισαγωγή συνδρομών FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Εισαγωγή συνδρομών NewPipe (.json)",
"Import NewPipe data (.zip)": "Εισαγωγή δεδομένων NewPipe (.zip)",
"Export": "Εξαγωγή",
"Export subscriptions as OPML": "Εξαγωγή συνδρομών ως OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Εξαγωγή συνδρομών ως OPML (για NewPipe & FreeTube)",
"Export data as JSON": "Εξαγωγή δεδομένων ως JSON",
"Export data as JSON": "Εξαγωγή δεδομένων Invidious ως JSON",
"Delete account?": "Διαγραφή λογαριασμού;",
"History": "Ιστορικό",
"An alternative front-end to YouTube": "Μία εναλλακτική πλατφόρμα για το YouTube",
@ -419,7 +419,34 @@
"Search": "Αναζήτηση",
"hdr": "HDR",
"preferences_extend_desc_label": "Αυτόματη επέκταση της περιγραφής του βίντεο: ",
"preferences_vr_mode_label": "Διαδραστικά βίντεο 360 μοιρών: ",
"preferences_vr_mode_label": "Διαδραστικά βίντεο 360 μοιρών (απαιτεί WebGL): ",
"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": "Μπάρα προόδου διαβάσματος"
}

View file

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

View file

@ -21,15 +21,15 @@
"No": "No",
"Import and Export Data": "Importación y exportación de datos",
"Import": "Importar",
"Import Invidious data": "Importar datos de Invidious",
"Import YouTube subscriptions": "Importar suscripciones de YouTube",
"Import Invidious data": "Importar datos JSON de Invidious",
"Import YouTube subscriptions": "Importar suscripciones de YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Importar suscripciones de FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importar suscripciones de NewPipe (.json)",
"Import NewPipe data (.zip)": "Importar datos de NewPipe (.zip)",
"Export": "Exportar",
"Export subscriptions as OPML": "Exportar suscripciones como OPML",
"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?",
"History": "Historial",
"An alternative front-end to YouTube": "Una interfaz alternativa para YouTube",
@ -66,7 +66,7 @@
"preferences_related_videos_label": "¿Mostrar vídeos relacionados? ",
"preferences_annotations_label": "¿Mostrar anotaciones por defecto? ",
"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_player_style_label": "Estilo de reproductor: ",
"Dark mode: ": "Modo oscuro: ",
@ -199,7 +199,7 @@
"No such user": "Usuario no válido",
"Token is expired, please try again": "El símbolo ha caducado, inténtelo de nuevo",
"English": "Inglés",
"English (auto-generated)": "Inglés (autogenerado)",
"English (auto-generated)": "Inglés (generados automáticamente)",
"Afrikaans": "Afrikáans",
"Albanian": "Albanés",
"Amharic": "Amárico",
@ -435,5 +435,29 @@
"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_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: "
}

View file

@ -31,15 +31,15 @@
"No": "Non",
"Import and Export Data": "Importer et exporter des données",
"Import": "Importer",
"Import Invidious data": "Importer des données Invidious",
"Import YouTube subscriptions": "Importer des abonnements YouTube",
"Import Invidious data": "Importer des données Invidious au format JSON",
"Import YouTube subscriptions": "Importer des abonnements YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Importer des abonnements FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importer des abonnements NewPipe (.json)",
"Import NewPipe data (.zip)": "Importer des données NewPipe (.zip)",
"Export": "Exporter",
"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 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 ?",
"History": "Historique",
"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_annotations_label": "Afficher les annotations par défaut : ",
"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_player_style_label": "Style du lecteur : ",
"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_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_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 : "
}

View file

@ -21,15 +21,15 @@
"No": "Ne",
"Import and Export Data": "Uvezi i izvezi podatke",
"Import": "Uvezi",
"Import Invidious data": "Uvezi Invidious podatke",
"Import YouTube subscriptions": "Uvezi YouTube pretplate",
"Import Invidious data": "Uvezi Invidious JSON podatke",
"Import YouTube subscriptions": "Uvezi YouTube/OPML pretplate",
"Import FreeTube subscriptions (.db)": "Uvezi FreeTube pretplate (.db)",
"Import NewPipe subscriptions (.json)": "Uvezi NewPipe pretplate (.json)",
"Import NewPipe data (.zip)": "Uvezi NewPipe podatke (.zip)",
"Export": "Izvezi",
"Export subscriptions as OPML": "Izvezi pretplate kao OPML",
"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?",
"History": "Povijest",
"An alternative front-end to YouTube": "Alternativa za YouTube",
@ -66,7 +66,7 @@
"preferences_related_videos_label": "Prikaži povezana videa: ",
"preferences_annotations_label": "Standardno prikaži napomene: ",
"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_player_style_label": "Stil playera: ",
"Dark mode: ": "Tamni modus: ",
@ -446,5 +446,35 @@
"generic_views_count_2": "{{count}} prikaza",
"comments_view_x_replies_0": "Prikaži {{count}} odgovor",
"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)"
}

View file

@ -76,7 +76,7 @@
"preferences_related_videos_label": "Hasonló videók ajánlása: ",
"preferences_annotations_label": "Szövegmagyarázat alapértelmezett mutatása: ",
"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_player_style_label": "Lejátszó kinézete: ",
"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_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_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)"
}

View file

@ -325,7 +325,7 @@
"Search": "Cari",
"Top": "Teratas",
"About": "Tentang",
"Rating: ": "Rating: ",
"Rating: ": "Penilaian: ",
"preferences_locale_label": "Bahasa: ",
"View as playlist": "Lihat sebagai daftar putar",
"Default": "Baku",
@ -346,7 +346,7 @@
"Playlists": "Daftar putar",
"Community": "Komunitas",
"relevance": "Relevansi",
"rating": "Rating",
"rating": "Penilaian",
"date": "Tanggal unggah",
"views": "Jumlah ditonton",
"content_type": "Tipe",
@ -414,5 +414,7 @@
"preferences_quality_dash_option_auto": "Otomatis",
"preferences_quality_dash_option_480p": "480p",
"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:"
}

View file

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

View file

@ -21,8 +21,8 @@
"No": "Nei",
"Import and Export Data": "Importer- og eksporter data",
"Import": "Importer",
"Import Invidious data": "Importer Invidious-data",
"Import YouTube subscriptions": "Importer YouTube-abonnementer",
"Import Invidious data": "Importer Invidious-JSON-data",
"Import YouTube subscriptions": "Importer YouTube/OPML-abonnementer",
"Import FreeTube subscriptions (.db)": "Importer FreeTube-abonnementer (.db)",
"Import NewPipe subscriptions (.json)": "Importer NewPipe-abonnementer (.json)",
"Import NewPipe data (.zip)": "Importer NewPipe-data (.zip)",
@ -430,5 +430,12 @@
"generic_count_minutes": "{{count}} minutt",
"generic_count_minutes_plural": "{{count}} minutter",
"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):"
}

View file

@ -21,15 +21,15 @@
"No": "Nie",
"Import and Export Data": "Import i eksport danych",
"Import": "Import",
"Import Invidious data": "Importuj dane Invidious",
"Import YouTube subscriptions": "Importuj subskrybcje z YouTube",
"Import Invidious data": "Importuj dane JSON Invidious",
"Import YouTube subscriptions": "Importuj subskrybcje z YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Importuj subskrybcje z FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importuj subskrybcje z NewPipe (.json)",
"Import NewPipe data (.zip)": "Importuj dane NewPipe (.zip)",
"Export": "Eksport",
"Export subscriptions as OPML": "Eksportuj subskrybcje jako OPML",
"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?",
"History": "Historia",
"An alternative front-end to YouTube": "Alternatywny front-end dla YouTube",
@ -66,7 +66,7 @@
"preferences_related_videos_label": "Pokaż powiązane filmy? ",
"preferences_annotations_label": "Domyślnie pokazuj adnotacje: ",
"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_player_style_label": "Styl odtwarzacza: ",
"Dark mode: ": "Ciemny motyw: ",
@ -446,12 +446,35 @@
"Video unavailable": "Film niedostępny",
"preferences_save_player_pos_label": "Zapisz pozycję odtwarzania: ",
"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)",
"long": "Długie (> 20 minutes)",
"footer_documentation": "Dokumentacja",
"footer_source_code": "Kod źródłowy",
"footer_modfied_source_code": "Zmodyfikowany 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)"
}

View file

@ -388,7 +388,7 @@
"preferences_quality_dash_label": "Qualidade de vídeo DASH preferida: ",
"preferences_quality_option_small": "Baixa",
"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_4320p": "4320p",
"preferences_quality_dash_option_2160p": "2160p",
@ -397,12 +397,12 @@
"preferences_quality_dash_option_360p": "360p",
"preferences_quality_dash_option_240p": "240p",
"preferences_quality_dash_option_144p": "144p",
"purchased": "Adquirido",
"purchased": "Comprado",
"360": "360°",
"videoinfo_invidious_embed_link": "Incorporar hiperligação",
"Video unavailable": "Vídeo não disponível",
"invidious": "Invidious",
"preferences_quality_option_medium": "Médio",
"preferences_quality_option_medium": "Média",
"preferences_quality_option_dash": "DASH (qualidade adaptativa)",
"preferences_quality_dash_option_1440p": "1440p",
"preferences_quality_dash_option_480p": "480p",
@ -410,5 +410,32 @@
"preferences_quality_dash_option_worst": "Pior",
"none": "nenhum",
"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"
}

View file

@ -21,15 +21,15 @@
"No": "Нет",
"Import and Export Data": "Импорт и экспорт данных",
"Import": "Импорт",
"Import Invidious data": "Импортировать данные Invidious",
"Import YouTube subscriptions": "Импортировать подписки из YouTube",
"Import Invidious data": "Импортировать JSON с данными Invidious",
"Import YouTube subscriptions": "Импортировать подписки из YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Импортировать подписки из FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Импортировать подписки из NewPipe (.json)",
"Import NewPipe data (.zip)": "Импортировать данные из NewPipe (.zip)",
"Export": "Экспорт",
"Export subscriptions as OPML": "Экспортировать подписки в формате OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Экспортировать подписки в формате OPML (для NewPipe и FreeTube)",
"Export data as JSON": "Экспортировать данные в формате JSON",
"Export data as JSON": "Экспортировать данные Invidious в формате JSON",
"Delete account?": "Удалить аккаунт?",
"History": "История",
"An alternative front-end to YouTube": "Альтернативный фронтенд для YouTube",
@ -66,7 +66,7 @@
"preferences_related_videos_label": "Показывать похожие видео? ",
"preferences_annotations_label": "Всегда показывать аннотации? ",
"preferences_extend_desc_label": "Автоматически раскрывать описание видео: ",
"preferences_vr_mode_label": "Интерактивные 360-градусные видео: ",
"preferences_vr_mode_label": "Интерактивные 360-градусные видео (необходим WebGL): ",
"preferences_category_visual": "Настройки сайта",
"preferences_player_style_label": "Стиль проигрывателя: ",
"Dark mode: ": "Тёмное оформление: ",
@ -75,7 +75,7 @@
"light": "светлая",
"preferences_thin_mode_label": "Облегчённое оформление: ",
"preferences_category_misc": "Прочие предпочтения",
"preferences_automatic_instance_redirect_label": "Автоматическое перенаправление экземпляра (резервный вариант redirect.invidious.io): ",
"preferences_automatic_instance_redirect_label": "Автоматическое перенаправление на зеркало сайта (резервный вариант redirect.invidious.io): ",
"preferences_category_subscription": "Настройки подписок",
"preferences_annotations_subscribed_label": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ",
"Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ",
@ -361,5 +361,120 @@
"next_steps_error_message_refresh": "Обновить",
"next_steps_error_message_go_to_youtube": "Перейти на YouTube",
"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": "Страна: "
}

View file

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

View file

@ -21,15 +21,15 @@
"No": "Hayır",
"Import and Export Data": "Verileri İçe ve Dışa Aktar",
"Import": "İçe aktar",
"Import Invidious data": "İnvidious verilerini içe aktar",
"Import YouTube subscriptions": "YouTube aboneliklerini içe aktar",
"Import Invidious data": "İnvidious JSON verilerini içe aktar",
"Import YouTube subscriptions": "YouTube/OPML aboneliklerini içe aktar",
"Import FreeTube subscriptions (.db)": "FreeTube aboneliklerini içe aktar (.db)",
"Import NewPipe subscriptions (.json)": "NewPipe aboneliklerini içe aktar (.json)",
"Import NewPipe data (.zip)": "NewPipe verilerini içe aktar (.zip)",
"Export": "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 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?",
"History": "Geçmiş",
"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_annotations_label": "Öntanımlı olarak ek açıklamaları göster: ",
"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_player_style_label": "Oynatıcı biçimi: ",
"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_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_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: "
}

View file

@ -26,15 +26,15 @@
"No": "否",
"Import and Export Data": "导入与导出数据",
"Import": "导入",
"Import Invidious data": "导入 Invidious 数据",
"Import YouTube subscriptions": "导入 YouTube 订阅",
"Import Invidious data": "导入 Invidious JSON 数据",
"Import YouTube subscriptions": "导入 YouTube/OPML 订阅",
"Import FreeTube subscriptions (.db)": "导入 FreeTube 订阅 (.db)",
"Import NewPipe subscriptions (.json)": "导入 NewPipe 订阅 (.json)",
"Import NewPipe data (.zip)": "导入 NewPipe 数据 (.zip)",
"Export": "导出",
"Export subscriptions as OPML": "导出订阅到 OPML 格式",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "导出订阅到 OPML 格式(用于 NewPipe 及 FreeTube",
"Export data as JSON": "导出数据为 JSON 格式",
"Export data as JSON": "导出 Invidious 数据为 JSON 格式",
"Delete account?": "删除账户?",
"History": "历史",
"An alternative front-end to YouTube": "另一个 YouTube 前端",
@ -71,7 +71,7 @@
"preferences_related_videos_label": "是否显示相关视频: ",
"preferences_annotations_label": "是否默认显示视频注释: ",
"preferences_extend_desc_label": "自动展开视频描述: ",
"preferences_vr_mode_label": "互动式 360 度视频 ",
"preferences_vr_mode_label": "互动式 360 度视频 (需要 WebGL): ",
"preferences_category_visual": "视觉选项",
"preferences_player_style_label": "播放器样式: ",
"Dark mode: ": "深色模式: ",
@ -421,5 +421,29 @@
"purchased": "已购买",
"360": "360°",
"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": "启用观看历史: "
}

View file

@ -26,15 +26,15 @@
"No": "否",
"Import and Export Data": "匯入與匯出資料",
"Import": "匯入",
"Import Invidious data": "匯入 Invidious 資料",
"Import YouTube subscriptions": "匯入 YouTube 訂閱",
"Import Invidious data": "匯入 Invidious JSON 資料",
"Import YouTube subscriptions": "匯入 YouTube/OPML 訂閱",
"Import FreeTube subscriptions (.db)": "匯入 FreeTube 訂閱 (.db)",
"Import NewPipe subscriptions (.json)": "匯入 NewPipe 訂閱 (.json)",
"Import NewPipe data (.zip)": "匯入 NewPipe 資料 (.zip)",
"Export": "匯出",
"Export subscriptions as OPML": "將訂閱匯出為 OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "將訂閱匯出為 OPML供 NewPipe 與 FreeTube 使用)",
"Export data as JSON": "將 JSON 匯出為 JSON",
"Export data as JSON": "將 Invidious 資料匯出為 JSON",
"Delete account?": "刪除帳號?",
"History": "歷史",
"An alternative front-end to YouTube": "一個 YouTube 的替代前端",
@ -71,7 +71,7 @@
"preferences_related_videos_label": "顯示相關的影片: ",
"preferences_annotations_label": "預設顯示註釋: ",
"preferences_extend_desc_label": "自動展開影片描述: ",
"preferences_vr_mode_label": "互動式 360 度影片 ",
"preferences_vr_mode_label": "互動式 360 度影片(需要 WebGL ",
"preferences_category_visual": "視覺偏好設定",
"preferences_player_style_label": "播放器樣式: ",
"Dark mode: ": "深色模式: ",
@ -421,5 +421,29 @@
"crash_page_read_the_faq": "閱讀<a href=\"`x`\">常見問題解答 (FAQ)</a>",
"crash_page_search_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": "啟用觀看紀錄: "
}

View file

@ -25,9 +25,9 @@ def csv_sample
CSV
end
Spectator.describe "Invidious::User::Imports" do
Spectator.describe Invidious::User::Import 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.size).to eq(13)

View file

@ -29,6 +29,8 @@ require "protodec/utils"
require "./invidious/database/*"
require "./invidious/helpers/*"
require "./invidious/yt_backend/*"
require "./invidious/frontend/*"
require "./invidious/*"
require "./invidious/channels/*"
require "./invidious/user/*"
@ -43,7 +45,6 @@ ARCHIVE_URL = URI.parse("https://archive.org")
LOGIN_URL = URI.parse("https://accounts.google.com")
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
REDDIT_URL = URI.parse("https://www.reddit.com")
TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com")
YT_URL = URI.parse("https://www.youtube.com")
HOST_URL = make_host_url(Kemal.config)
@ -114,6 +115,7 @@ LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level)
# Check table integrity
Invidious::Database.check_integrity(CONFIG)
{% 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.
@ -125,6 +127,7 @@ Invidious::Database.check_integrity(CONFIG)
{% else %}
{% puts run("../scripts/fetch-player-dependencies.cr").stringify %}
{% end %}
{% end %}
# Start jobs
@ -153,8 +156,8 @@ if CONFIG.popular_enabled
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
end
connection_channel = Channel({Bool, Channel(PQ::Notification)}).new(32)
Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(connection_channel, CONFIG.database_url)
CONNECTION_CHANNEL = Channel({Bool, Channel(PQ::Notification)}).new(32)
Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL, CONFIG.database_url)
Invidious::Jobs.start_all
@ -233,6 +236,7 @@ before_all do |env|
"/api/manifest/",
"/videoplayback",
"/latest_version",
"/download",
}.any? { |r| env.request.resource.starts_with? r }
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/community", Invidious::Routes::Channels, :community
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|
# /c/LinusTechTips
@ -345,6 +352,8 @@ end
Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :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/:id", Invidious::Routes::Embed, :show
@ -359,20 +368,14 @@ end
Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax
Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show
Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix
Invidious::Routing.get "/watch_videos", Invidious::Routes::Playlists, :watch_videos
Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch
Invidious::Routing.get "/results", Invidious::Routes::Search, :results
Invidious::Routing.get "/search", Invidious::Routes::Search, :search
Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page
Invidious::Routing.post "/login", Invidious::Routes::Login, :login
Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout
Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show
Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update
Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control
Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control
# User routes
define_user_routes()
# Feeds
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_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|
if md = env.request.path.match(/^\/(?<id>([a-zA-Z0-9_-]{11})|(\w+))$/)
item = md["id"]
@ -874,7 +479,7 @@ add_handler AuthHandler.new
add_handler DenyFrame.new
add_context_storage_type(Array(String))
add_context_storage_type(Preferences)
add_context_storage_type(User)
add_context_storage_type(Invidious::User)
Kemal.config.logger = LOGGER
Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding

View file

@ -144,19 +144,32 @@ def fetch_related_channels(about_channel : AboutChannel) : Array(AboutRelatedCha
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",
"sectionListRenderer", "contents", 0,
"itemSectionRenderer", "contents", 0,
"gridRenderer", "items"
).try &.as_a?
items.map do |item|
related_id = item.dig("gridChannelRenderer", "channelId").as_s
related_title = item.dig("gridChannelRenderer", "title", "simpleText").as_s
related_author_url = item.dig("gridChannelRenderer", "navigationEndpoint", "browseEndpoint", "canonicalBaseUrl").as_s
related_author_thumbnail = item.dig("gridChannelRenderer", "thumbnail", "thumbnails", -1, "url").as_s
related = [] of AboutRelatedChannel
return related if (items.nil? || items.empty?)
AboutRelatedChannel.new(
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,
author: related_title,
author_url: related_author_url,
author_thumbnail: related_author_thumbnail,
)
end
return related
end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,6 +30,7 @@ LOCALES_LIST = {
"pt-PT" => "Português de Portugal", # Portuguese (Portugal)
"ro" => "Română", # Romanian
"ru" => "русский", # Russian
"sq" => "Shqip", # Albanian
"sr" => "srpski (latinica)", # Serbian (Latin)
"sr_Cyrl" => "српски (ћирилица)", # Serbian (Cyrillic)
"sv-SE" => "Svenska", # Swedish
@ -135,7 +136,7 @@ def translate_count(locale : String, key : String, count : Int, format = NumberF
# Try #2: Fallback to english
translation = translate_count("en-US", key, count)
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}\"")
return key
end

View file

@ -44,7 +44,7 @@ def sign_token(key, hash)
# TODO: figure out which "key" variable is used
# 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|
next if key == "signature"

View file

@ -51,6 +51,24 @@ def recode_length_seconds(time)
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)
time = string.try &.to_f?
@ -161,11 +179,11 @@ def short_text_to_number(short_text : String) : Int32
end
def number_to_short_text(number)
seperated = number_with_separator(number).gsub(",", ".").split("")
text = seperated.first(2).join
separated = number_with_separator(number).gsub(",", ".").split("")
text = separated.first(2).join
if seperated[2]? && seperated[2] != "."
text += seperated[2]
if separated[2]? && separated[2] != "."
text += separated[2]
end
text = text.rchop(".0")
@ -323,8 +341,8 @@ def fetch_random_instance
instance_list.each do |data|
# TODO Check if current URL is onion instance and use .onion types if so.
if data[1]["type"] == "https"
# Instances can have statisitics 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.
# 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 raised if as_nil fails.
begin
data[1]["stats"].as_nil
next

View file

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

View file

@ -401,7 +401,7 @@ def fetch_playlist(plid : String)
end
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)
if offset >= playlist.video_count || offset < 0
return [] of PlaylistVideo

View file

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

View file

@ -343,7 +343,7 @@ module Invidious::Routes::API::V1::Authenticated
env.response.content_type = "text/html"
csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, use_nonce: true)
return templated "authorize_token"
return templated "user/authorize_token"
else
env.response.content_type = "application/json"
@ -397,4 +397,14 @@ module Invidious::Routes::API::V1::Authenticated
env.response.status_code = 204
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

View file

@ -96,7 +96,14 @@ module Invidious::Routes::API::V1::Channels
json.field "relatedChannels" 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.field "author", related_channel.author
json.field "authorId", related_channel.ucid
@ -118,7 +125,8 @@ module Invidious::Routes::API::V1::Channels
end
end
end
end
end # relatedChannels
end
end
end

View file

@ -4,10 +4,10 @@ module Invidious::Routes::API::V1::Misc
env.response.content_type = "application/json"
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
Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json
end
# APIv1 currently uses the same logic for both

View file

@ -23,7 +23,11 @@ module Invidious::Routes::API::V1::Videos
env.response.content_type = "application/json"
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
# It is possible to use `/api/timedtext?type=list&v=#{id}` and
@ -130,7 +134,13 @@ module Invidious::Routes::API::V1::Videos
end
end
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
.gsub(/([0-9:.]{12} --> [0-9:.]{12}).+/, "\\1")
end
if title = env.params.query["title"]?

View file

@ -147,6 +147,39 @@ module Invidious::Routes::Channels
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)
locale = env.get("preferences").as(Preferences).locale

View file

@ -27,7 +27,7 @@ module Invidious::Routes::Login
tfa = env.params.query["tfa"]?
prompt = nil
templated "login"
templated "user/login"
end
def self.login(env)
@ -133,7 +133,7 @@ module Invidious::Routes::Login
tfa = tfa_code
captcha = {tokens: [token], question: ""}
return templated "login"
return templated "user/login"
end
if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED"
@ -190,7 +190,7 @@ module Invidious::Routes::Login
tfa = nil
captcha = nil
return templated "login"
return templated "user/login"
end
tl = challenge_results[1][2]
@ -282,18 +282,8 @@ module Invidious::Routes::Login
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|
if Kemal.config.ssl || CONFIG.https_only
cookie.secure = secure
else
cookie.secure = secure
end
cookie.secure = Invidious::User::Cookies::SECURE
if cookie.extension
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))
Invidious::Database::SessionIDs.insert(sid, email)
if Kemal.config.ssl || CONFIG.https_only
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
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
else
return error_template(401, "Wrong username or password")
end
@ -393,12 +371,12 @@ module Invidious::Routes::Login
prompt = ""
if captcha_type == "image"
captcha = generate_captcha(HMAC_KEY)
captcha = Invidious::User::Captcha.generate_image(HMAC_KEY)
else
captcha = generate_text_captcha(HMAC_KEY)
captcha = Invidious::User::Captcha.generate_text(HMAC_KEY)
end
return templated "login"
return templated "user/login"
end
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)}"
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
if Kemal.config.ssl || CONFIG.https_only
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
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
if env.request.cookies["PREFS"]?
user.preferences = env.get("preferences").as(Preferences)
@ -515,4 +481,11 @@ module Invidious::Routes::Login
env.redirect referer
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

View file

@ -443,4 +443,15 @@ module Invidious::Routes::Playlists
templated "mix"
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

View file

@ -8,7 +8,7 @@ module Invidious::Routes::PreferencesRoute
preferences = env.get("preferences").as(Preferences)
templated "preferences"
templated "user/preferences"
end
def self.update(env)
@ -47,6 +47,10 @@ module Invidious::Routes::PreferencesRoute
local ||= "off"
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 ||= CONFIG.default_user_preferences.speed
@ -136,7 +140,7 @@ module Invidious::Routes::PreferencesRoute
notifications_only ||= "off"
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({
annotations: annotations,
annotations_subscribed: annotations_subscribed,
@ -149,6 +153,7 @@ module Invidious::Routes::PreferencesRoute
latest_only: latest_only,
listen: listen,
local: local,
watch_history: watch_history,
locale: locale,
max_results: max_results,
notifications_only: notifications_only,
@ -214,19 +219,7 @@ module Invidious::Routes::PreferencesRoute
File.write("config/config.yml", CONFIG.to_yaml)
end
else
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.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
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
end
env.redirect referer
@ -261,21 +254,7 @@ module Invidious::Routes::PreferencesRoute
preferences.dark_mode = "dark"
end
preferences = preferences.to_json
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
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
end
if redirect
@ -298,7 +277,7 @@ module Invidious::Routes::PreferencesRoute
user = user.as(User)
templated "data_control"
templated "user/data_control"
end
def self.update_data_control(env)
@ -321,149 +300,27 @@ module Invidious::Routes::PreferencesRoute
# TODO: Unify into single import based on content-type
case part.name
when "import_invidious"
body = JSON.parse(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
Invidious::User::Import.from_invidious(user, body)
when "import_youtube"
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"
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
if !success
haltf(env, status_code: 415,
response: error_template(415, "Invalid subscription file uploaded")
)
end
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
when "import_freetube"
user.subscriptions += body.scan(/"channelId":"(?<channel_id>[a-zA-Z0-9_-]{24})"/).map do |md|
md["channel_id"]
end
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
Invidious::User::Import.from_freetube(user, body)
when "import_newpipe_subscriptions"
body = JSON.parse(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)
Invidious::User::Import.from_newpipe_subs(user, body)
when "import_newpipe"
Compress::Zip::Reader.open(IO::Memory.new(body)) do |file|
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)
success = Invidious::User::Import.from_newpipe(user, body)
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
if !success
haltf(env, status_code: 415,
response: error_template(415, "Uploaded file is too large")
)
end
else nil # Ignore
end

View file

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

View file

@ -14,12 +14,18 @@ module Invidious::Routes::VideoPlayback
end
if query_params["host"]? && !query_params["host"].empty?
host = "https://#{query_params["host"]}"
host = query_params["host"]
query_params.delete("host")
else
host = "https://r#{fvip}---#{mns.pop}.googlevideo.com"
host = "r#{fvip}---#{mns.pop}.googlevideo.com"
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}"
headers = HTTP::Headers.new
@ -158,7 +164,9 @@ module Invidious::Routes::VideoPlayback
if title = query_params["title"]?
# 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
if !resp.headers.includes_word?("Transfer-Encoding", "chunked")
@ -236,31 +244,25 @@ module Invidious::Routes::VideoPlayback
# YouTube /videoplayback links expire after 6 hours,
# so we have a mechanism here to redirect to the latest version
def self.latest_version(env)
if env.params.query["download_widget"]?
download_widget = JSON.parse(env.params.query["download_widget"])
id = env.params.query["id"]?
itag = env.params.query["itag"]?.try &.to_i?
id = download_widget["id"].as_s
title = URI.decode_www_form(download_widget["title"].as_s)
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
# Sanity checks
if id.nil? || id.size != 11 || !id.matches?(/^[\w-]+$/)
return error_template(400, "Invalid video ID")
end
id ||= env.params.query["id"]?
itag ||= env.params.query["itag"]?.try &.to_i
if itag.nil? || itag <= 0 || itag >= 1000
return error_template(400, "Invalid itag")
end
region = env.params.query["region"]?
local = (env.params.query["local"]? == "true")
local ||= env.params.query["local"]?
local ||= "false"
local = local == "true"
title = env.params.query["title"]?
if !id || !itag
haltf env, status_code: 400, response: "TESTING"
if title && CONFIG.disabled?("downloads")
return error_template(403, "Administrator has disabled this endpoint.")
end
video = get_video(id, region: region)
@ -272,8 +274,10 @@ module Invidious::Routes::VideoPlayback
haltf env, status_code: 404
end
url = URI.parse(url).request_target.not_nil! if local
url = "#{url}&title=#{title}" if title
if local
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
end

View file

@ -75,7 +75,7 @@ module Invidious::Routes::Watch
end
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)
end
@ -189,6 +189,14 @@ module Invidious::Routes::Watch
return env.redirect url
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"
end
@ -281,4 +289,49 @@ module Invidious::Routes::Watch
return error_template(404, "The requested clip doesn't exist")
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

View file

@ -10,6 +10,33 @@ module Invidious::Routing
{% 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
{{namespace = Invidious::Routes::API::V1}}
# 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/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
Invidious::Routing.get "/api/v1/stats", {{namespace}}::Misc, :stats
Invidious::Routing.get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist

View file

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

View file

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

View file

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

View file

@ -1,5 +1,10 @@
require "csv"
struct Invidious::User
module Import
extend self
# Parse a youtube CSV subscription file
def parse_subscription_export_csv(csv_content : String)
rows = CSV.new(csv_content, headers: true)
subscriptions = Array(String).new
@ -19,9 +24,219 @@ def parse_subscription_export_csv(csv_content : String)
channel_id = row[0].strip
next if channel_id.empty?
subscriptions << channel_id
end
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

View file

@ -23,6 +23,7 @@ struct Preferences
property latest_only : Bool = CONFIG.default_user_preferences.latest_only
property listen : Bool = CONFIG.default_user_preferences.listen
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 show_nick : Bool = CONFIG.default_user_preferences.show_nick
@ -256,4 +257,18 @@ struct Preferences
cookies
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

View file

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

View file

@ -3,32 +3,6 @@ require "crypto/bcrypt/password"
# 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" }
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)
if email = Invidious::Database::SessionIDs.select_email(sid)
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))
user = User.new({
user = Invidious::User.new({
updated: Time.utc,
notifications: [] of String,
subscriptions: channels,
@ -102,7 +76,7 @@ def create_user(sid, email, password)
password = Crypto::Bcrypt::Password.create(password, cost: 10)
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
user = User.new({
user = Invidious::User.new({
updated: Time.utc,
notifications: [] of String,
subscriptions: [] of String,
@ -117,75 +91,6 @@ def create_user(sid, email, password)
return user, sid
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)
headers = HTTP::Headers.new
headers["Cookie"] = env_headers["Cookie"]

View file

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

View file

@ -54,7 +54,7 @@
<img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% 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">
<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">
<a data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid %>" href="javascript:void(0)">
<button type="submit" style="all:unset">
@ -106,7 +106,7 @@
<img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% 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">
<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">
<a data-onclick="mark_watched" data-id="<%= item.id %>" href="javascript:void(0)">
<button type="submit" style="all:unset">
@ -119,7 +119,7 @@
</form>
<% 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">
<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">
<a data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid %>" href="javascript:void(0)">
<button type="submit" style="all:unset">

View file

@ -2,7 +2,7 @@
<% if subscriptions.includes? ucid %>
<p>
<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">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b>
</button>
@ -11,7 +11,7 @@
<% else %>
<p>
<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">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b>
</button>

View file

@ -30,7 +30,7 @@
</button>
</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>
</form>
</div>

View file

@ -19,6 +19,6 @@
</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>
</div>

View file

@ -41,7 +41,7 @@
<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>
</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>
<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %>

View file

@ -7,7 +7,7 @@
<meta name="thumbnail" content="<%= thumbnail %>">
<%= rendered "components/player_sources" %>
<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/embed.css?v=<%= ASSET_COMMIT %>">
<title><%= HTML.escape(video.title) %> - Invidious</title>

View file

@ -52,7 +52,7 @@
</div>
<div class="pure-u-1-4">
<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 %>
<span id="notification_count"><%= notification_count %></span> <i class="icon ion-ios-notifications"></i>
<% else %>
@ -67,12 +67,12 @@
</div>
<% if env.get("preferences").as(Preferences).show_nick %>
<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>
<% end %>
<div class="pure-u-1-4">
<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="#">
<input style="all:unset" type="submit" value="<%= translate(locale, "Log out") %>">
</a>

View file

@ -72,7 +72,7 @@
<input type="hidden" name="expire" value="<%= expire %>">
<% 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>
</div>
<% end %>

View file

@ -23,7 +23,7 @@
<%= translate(locale, "Change password") %>
</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>
</form>
</div>

View file

@ -19,6 +19,6 @@
</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>
</div>

View file

@ -19,6 +19,6 @@
</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>
</div>

View file

@ -66,7 +66,7 @@
<% captcha = captcha.not_nil! %>
<img style="width:50%" src='<%= captcha[:question] %>'/>
<% 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 %>
<input type="hidden" name="captcha_type" value="image">
<label for="answer"><%= translate(locale, "Time (h:mm:ss):") %></label>
@ -74,7 +74,7 @@
<% else # "text" %>
<% captcha = captcha.not_nil! %>
<% 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 %>
<input type="hidden" name="captcha_type" value="text">
<label for="answer"><%= captcha[:question] %></label>

View file

@ -206,6 +206,11 @@
<% if env.get? "user" %>
<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">
<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 %>>
@ -252,7 +257,7 @@
<% 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>
<div class="pure-control-group">

View file

@ -38,7 +38,7 @@
<div class="pure-u-1-5" style="text-align:right">
<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">
<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="#">
<input style="all:unset" type="submit" value="<%= translate(locale, "unsubscribe") %>">
</a>

View file

@ -30,7 +30,7 @@
<div class="pure-u-1-5" style="text-align:right">
<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">
<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="#">
<input style="all:unset" type="submit" value="<%= translate(locale, "revoke") %>">
</a>

View file

@ -168,41 +168,7 @@ we're going to need to do it here in order to allow for translations.
<% end %>
<% end %>
<% if CONFIG.dmca_content.includes?(video.id) || CONFIG.disabled?("downloads") %>
<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 %>
<%= Invidious::Frontend::WatchPage.download_widget(locale, video, video_assets) %>
<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>

View file

@ -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.
# 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|
LOGGER.trace("extract_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)")

View file

@ -90,7 +90,7 @@ module YoutubeAPI
property client_type : ClientType
# 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
# ISO code of country where the proxy is located.
@ -205,7 +205,7 @@ module YoutubeAPI
# :ditto:
def browse(
browse_id : String,
*, # Force the following paramters to be passed by name
*, # Force the following parameters to be passed by name
params : String,
client_config : ClientConfig | Nil = nil
)
@ -215,7 +215,7 @@ module YoutubeAPI
"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)
if params != ""
data["params"] = params
@ -292,14 +292,14 @@ module YoutubeAPI
# and POST data in order to get a JSON reply.
#
# 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
# `struct ClientConfig` above for more details).
#
def player(
video_id : String,
*, # Force the following paramters to be passed by name
*, # Force the following parameters to be passed by name
params : String,
client_config : ClientConfig | Nil = nil
)
@ -309,7 +309,7 @@ module YoutubeAPI
"context" => self.make_context(client_config),
}
# Append the additionnal parameters if those were provided
# Append the additional parameters if those were provided
if params != ""
data["params"] = params
end
@ -363,7 +363,7 @@ module YoutubeAPI
# order to get non-US results.
#
# 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
# `struct ClientConfig` above for more details).