Compare commits

..

2 Commits

Author SHA1 Message Date
Émilien (perso) 209f27bb32 Document supported proxy types in config.example.yml
Added note about supported proxy types in configuration.
2026-01-08 11:56:44 +01:00
Émilien (perso) 4b020414c9 doc: Update HTTP proxy configuration comments
Added information about proxy configuration for YouTube streams.
2026-01-08 10:30:25 +01:00
104 changed files with 1594 additions and 1972 deletions
+6 -27
View File
@@ -29,7 +29,7 @@ jobs:
- os: ubuntu-24.04-arm
platform: linux/arm64/v8
name: "ARM64"
dockerfile: "docker/Dockerfile"
dockerfile: "docker/Dockerfile.arm64"
tag_suffix: "-arm64"
runs-on: ${{ matrix.os }}
@@ -39,10 +39,10 @@ jobs:
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
@@ -50,7 +50,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: quay.io/invidious/invidious
flavor: |
@@ -62,34 +62,13 @@ jobs:
quay.expires-after=12w
- name: Build and push Docker ${{ matrix.name }} image for Push Event
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
push: true
tags: ${{ steps.meta.outputs.tags }}
build-args: |
"release=1"
combine-multiarch-images:
needs: release
runs-on: ubuntu-latest
steps:
- name: Login to registry
uses: docker/login-action@v4
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_PASSWORD }}
# https://github.com/marketplace/actions/docker-manifest-create-action
- name: Create and push manifest
uses: int128/docker-manifest-create-action@v2.22.0
with:
push: true
tags: quay.io/invidious/invidious:master
sources: |
quay.io/invidious/invidious:master
quay.io/invidious/invidious:master-arm64
+6 -27
View File
@@ -20,7 +20,7 @@ jobs:
- os: ubuntu-24.04-arm
platform: linux/arm64/v8
name: "ARM64"
dockerfile: "docker/Dockerfile"
dockerfile: "docker/Dockerfile.arm64"
tag_suffix: "-arm64"
runs-on: ${{ matrix.os }}
@@ -30,10 +30,10 @@ jobs:
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
@@ -41,7 +41,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: quay.io/invidious/invidious
flavor: |
@@ -54,34 +54,13 @@ jobs:
quay.expires-after=12w
- name: Build and push Docker ${{ matrix.name }} image for Push Event
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
push: true
tags: ${{ steps.meta.outputs.tags }}
build-args: |
"release=1"
combine-multiarch-images:
needs: release
runs-on: ubuntu-latest
steps:
- name: Login to registry
uses: docker/login-action@v4
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_PASSWORD }}
# https://github.com/marketplace/actions/docker-manifest-create-action
- name: Create and push manifest
uses: int128/docker-manifest-create-action@v2.22.0
with:
push: true
tags: quay.io/invidious/invidious:latest
sources: |
quay.io/invidious/invidious:latest
quay.io/invidious/invidious:latest-arm64
+7 -5
View File
@@ -43,8 +43,6 @@ jobs:
- 1.16.3
- 1.17.1
- 1.18.2
- 1.19.2
- 1.20.2
include:
- crystal: nightly
stable: false
@@ -60,7 +58,7 @@ jobs:
shell: bash
- name: Install Crystal
uses: crystal-lang/install-crystal@v1.9.2
uses: crystal-lang/install-crystal@v1.9.1
with:
crystal: ${{ matrix.crystal }}
@@ -82,7 +80,7 @@ jobs:
run: crystal spec
- name: Build
run: crystal build --warnings all --error-on-warnings --stats --time --progress --error-trace src/invidious.cr
run: crystal build --warnings all --error-on-warnings --error-trace src/invidious.cr
build-docker:
strategy:
@@ -100,6 +98,10 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Use ARM64 Dockerfile if ARM64
if: ${{ matrix.name == 'ARM64' }}
run: sed -i 's/Dockerfile/Dockerfile.arm64/' docker-compose.yml
- name: Build Docker
run: docker compose build
@@ -132,7 +134,7 @@ jobs:
- name: Install Crystal
id: lint_step_install_crystal
uses: crystal-lang/install-crystal@v1.9.2
uses: crystal-lang/install-crystal@v1.9.1
with:
crystal: latest
-90
View File
@@ -2,96 +2,6 @@
## vX.Y.0 (future)
## v2.20260207.0
### Wrap-up
This release hardens the Invidious companion pipeline and cleans up a long list of UI papercuts. Companion downloads now work end-to-end, CSP headers and check identifiers are generated once and reused, proxy responses strip stray headers, and the final traces of the legacy signature helper are gone so the helper can be rolled out safely.
Livestream navigation, playlists, and channel metadata also see overdue fixes: Trending once again lists livestreams, "Watch on YouTube" buttons stop jumping to arbitrary timestamps, playlist imports/API calls handle missing data, and channel pages now display creator pronouns and playlist thumbnails. Deployments benefit from compiling OpenSSL into docker images to mitigate a long-standing memory leak observed with Alpine-provided OpenSSL, Crystal pinned back to 1.16.3 for docker and OCI builds, a rewritten static file handler, clarified README/HTTP proxy/unix socket docs, and dozens of smaller cleanups.
### New features & important changes
#### For Users
- Livestream experiences are restored: Trending shows livestreams again, the gaming feed remains accessible, and "Watch on YouTube" links stop carrying stale timestamps (#5480, #5555, #5481)
- Channel and playlist metadata is richer thanks to pronoun support, topic playlist thumbnails, and accurate related video counts (#5617, #5616, #5446)
- Downloads get smoother because download actions are URL-safe and downloads can flow through Invidious companion when available (#5367, #5561)
- Users see clearer feedback with Erroneous CAPTCHA messages, DMCA controls restored, and a footer link pointing at the current release (#5508, #5228, #4702)
#### For instance owners
- Companion integration is sturdier: CSP is generated once, check identifiers persist, and the helper hyperlink is fixed (#5497, #5575, #5491)
- Proxied images and videoplayback strip unwanted response headers (shared header-strip list) (#5595)
- Runtime and packaging updates pin docker/OCI builds to Crystal 1.16.3, bring an optional Crystal 1.18.2 + Alpine 3.23 image, and compile OpenSSL from source to mitigate the memory leak seen with Alpine-provided OpenSSL (#5604, #5577, #5574, #5441)
- Configuration docs saw polish with unix socket instructions, refreshed HTTP proxy comments, and corrected README commands (#5347, #5586, #5607)
- Server stability improves via a larger `max_request_line_size` that is required to be able to access some next pages of Youtube channels videos and a rewritten static file handler (#5566, #5338)
#### For developers
- Top-level constants moved into dedicated modules, preferences handling was cleaned up, and the legacy signature helper is finally removed (#5596, #5450, #5550)
- Crystal API updates replaced the deprecated `Socket#blocking` property and restored the shard target plus SPDX license metadata (#5538, #5608, #5552)
- CI/tooling stayed current with newer GitHub Actions, install-crystal releases, and cache/checkout bumps (#5569, #5544, #5530, #5499)
### Bugs fixed
#### User-side
- Playlist importer edge cases, playlist API author URLs, and channel continuation tokens now handle empty values without crashing (#4787, #5618, #5614)
- Thin mode community posts, posts that reference unavailable videos, and DMCA content toggles work again (#5567, #5549, #5228)
- UI cleanups prevent channel name/button overflow, show explicit Erroneous CAPTCHA errors, and keep livestream timestamps clean (#5553, #5452, #5508, #5481)
- Trending feeds and related video counts regained accuracy alongside livestream/gaming categories (#5555, #5480, #5446)
#### For instance owners
- Companion downloads, CSP reuse, and check id generation behave predictably even under load (#5561, #5497, #5575)
- Proxy responses drop stray headers and HTTP proxy examples in the config were clarified (#5595, #5586)
- Docker/OCI builds were pinned to stable Crystal releases with OpenSSL bundled to avoid memory leaks (#5604, #5577, #5441)
#### For developers
- README commit instructions, shard targets, and unix socket docs were corrected (#5607, #5608, #5347)
- Thin mode preference comparisons no longer convert unnecessary strings (#5568)
- URL encoding fixes in the download widget and socket API updates prevent regressions when upgrading Crystal (#5367, #5538)
### Full list of pull requests merged since the last release (newest first)
* refactor: Move top level constants to it's own modules (https://github.com/iv-org/invidious/pull/5596, by @Fijxu)
* pages/watch: URL encode 'action' in download widget (https://github.com/iv-org/invidious/pull/5367, by @SamantazFox)
* Document use of unix sockets for `db` (https://github.com/iv-org/invidious/pull/5347, by @Fijxu)
* Generate companion CSP only once to reuse it (https://github.com/iv-org/invidious/pull/5497, by @Fijxu)
* Fix youtube CSV playlist importer (https://github.com/iv-org/invidious/pull/4787, by @ThatMatrix)
* Playlist API: return empty author url if ucid is empty (https://github.com/iv-org/invidious/pull/5618, by @radmorecameron)
* Channels: parse pronouns and display them on channel page (https://github.com/iv-org/invidious/pull/5617, by @radmorecameron)
* playlist: parse playlist thumbnails for topic autogenerated playlists (https://github.com/iv-org/invidious/pull/5616, by @radmorecameron)
* fix: add missing embedded protobuf message in continuation token for channel videos (https://github.com/iv-org/invidious/pull/5614, by @Fijxu)
* Update shard.yml to include target that was removed in commit 9d54cf9 (https://github.com/iv-org/invidious/pull/5608, by @Harm133)
* chore: Do not convert thin_mode preference to string to compare it in before_all (https://github.com/iv-org/invidious/pull/5568, by @Fijxu)
* Fix thin_mode preference for channel community page (https://github.com/iv-org/invidious/pull/5567, by @Fijxu)
* Fix commit command in README instructions, as per #5606 (https://github.com/iv-org/invidious/pull/5607, by @kirisakow)
* Revert "Bump crystallang/crystal from 1.16.3-alpine to 1.19.0-alpine in /docker" (https://github.com/iv-org/invidious/pull/5604, by @unixfox)
* Bump crystallang/crystal from 1.16.3-alpine to 1.19.0-alpine in /docker (https://github.com/iv-org/invidious/pull/5603, by @dependabot[bot])
* doc: Update HTTP proxy configuration comments (https://github.com/iv-org/invidious/pull/5586, by @unixfox)
* Strip unwanted headers from response headers in images and videoplayback (https://github.com/iv-org/invidious/pull/5595, by @Fijxu)
* Generate companion check id one time and add missing companion check id on captions (https://github.com/iv-org/invidious/pull/5575, by @Fijxu)
* Downgrade Crystal to 1.16.3 in OCI (https://github.com/iv-org/invidious/pull/5577, by @Fijxu)
* Allow downloading via companion (https://github.com/iv-org/invidious/pull/5561, by @JeroenBoersma)
* chore: crystal 1.8.2 + alpine 3.23 (https://github.com/iv-org/invidious/pull/5574, by @unixfox)
* Replace deprecated `blocking` property of `Socket` (https://github.com/iv-org/invidious/pull/5538, by @Fijxu)
* Replace `Kemal::StaticFileHandler` with direct subclass of stdlib `HTTP::StaticFileHandler` on Crystal >= 1.17.0 (https://github.com/iv-org/invidious/pull/5338, by @syeopite)
* dockerfile: compile openssl instead of using the one bundled on the crystal alpine image. (https://github.com/iv-org/invidious/pull/5441, by @Fijxu)
* Bump actions/cache from 4 to 5 (https://github.com/iv-org/invidious/pull/5569, by @dependabot[bot])
* Set Kemal `max_request_line_size` to 16384 for large channel continuation query parameters. (https://github.com/iv-org/invidious/pull/5566, by @Fijxu)
* Add link to GitHub release/tag/commit in footer (https://github.com/iv-org/invidious/pull/4702, by @shaedrich)
* Display "Erroneous CAPTCHA" for invalid captchas (https://github.com/iv-org/invidious/pull/5508, by @Fijxu)
* Fix channel name overflow (https://github.com/iv-org/invidious/pull/5553, by @Fijxu)
* Fix trending page by leaving livestream and gaming trending pages (https://github.com/iv-org/invidious/pull/5555, by @Fijxu)
* fix: restore dmca_content functionality (https://github.com/iv-org/invidious/pull/5228, by @Fijxu)
* Remove signature helper completely from Invidious (https://github.com/iv-org/invidious/pull/5550, by @Fijxu)
* Fix community posts when there is a unavailable video in a post (https://github.com/iv-org/invidious/pull/5549, by @Fijxu)
* chore: Update shard.yml to use SPDX license identifier (https://github.com/iv-org/invidious/pull/5552, by @Fijxu)
* Store `preferences` in a variable when reused and rename `prefs` to `preferences` (https://github.com/iv-org/invidious/pull/5450, by @Fijxu)
* Bump actions/checkout from 5 to 6 (https://github.com/iv-org/invidious/pull/5544, by @dependabot[bot])
* Bump crystal-lang/install-crystal from 1.8.3 to 1.9.1 (https://github.com/iv-org/invidious/pull/5530, by @dependabot[bot])
* Fix 0 view count on related videos section (https://github.com/iv-org/invidious/pull/5446, by @shiny-comic)
* Prevent timestamp from being set for Livestreams on "Watch on Youtube" links (https://github.com/iv-org/invidious/pull/5481, by @Fijxu)
* Add Livestreams to trending page (https://github.com/iv-org/invidious/pull/5480, by @Fijxu)
* Fix button overflow (https://github.com/iv-org/invidious/pull/5452, by @Fijxu)
* Bump crystal-lang/install-crystal from 1.8.2 to 1.8.3 (https://github.com/iv-org/invidious/pull/5499, by @dependabot[bot])
* Fixed broken companion hyperlink (https://github.com/iv-org/invidious/pull/5491, by @ndsvw)
## v2.20250913.0
### Wrap-up
+1 -1
View File
@@ -129,7 +129,7 @@ You can read more here: https://docs.invidious.io/applications/
1. Fork it ( https://github.com/iv-org/invidious/fork ).
1. Create your feature branch (`git checkout -b my-new-feature`).
1. Stage your files (`git add .`).
1. Commit your changes (`git commit -m 'Add some feature'`).
1. Commit your changes (`git commit -am 'Add some feature'`).
1. Push to the branch (`git push origin my-new-feature`).
1. Create a new pull request ( https://github.com/iv-org/invidious/compare ).
+1 -23
View File
@@ -75,16 +75,6 @@ body {
height: auto;
}
.channel-profile > .channel-name-pronouns {
display: inline-block;
}
.channel-profile > .channel-name-pronouns > .channel-pronouns {
font-style: italic;
font-size: .8em;
font-weight: lighter;
}
body a.channel-owner {
background-color: #008bec;
color: #fff;
@@ -416,12 +406,7 @@ input[type="search"]::-webkit-search-cancel-button {
p.channel-name { margin: 0; overflow-wrap: anywhere;}
p.video-data { margin: 0; font-weight: bold; font-size: 80%; }
.channel-profile > .channel-name,
.channel-profile > .channel-name-pronouns > .channel-name
{
overflow-wrap: anywhere;
}
.channel-profile > .channel-name { overflow-wrap: anywhere;}
/*
@@ -902,11 +887,4 @@ h1, h2, h3, h4, h5, p,
.error-issue-template {
padding: 20px;
background: rgba(0, 0, 0, 0.12345);
}
.preference-description {
width: 250px;
padding-left: 10px;
display: inline-block;
vertical-align: top;
}
+2 -2
View File
@@ -211,9 +211,9 @@ window.helpers = window.helpers || {
helpers.storage.remove(key);
}
},
set: function (key, value) {
set: function (key, value) {
let encoded_value = encodeURIComponent(JSON.stringify(value))
localStorage.setItem(key, encoded_value);
localStorage.setItem(key, encoded_value);
},
remove: function (key) { localStorage.removeItem(key); }
};
+8 -15
View File
@@ -104,15 +104,14 @@ if (video_data.params.quality === 'dash') {
*
* @param {String} url
* @param {String} [base]
* @param {'t' | 'start'} param
* @returns {URL} urlWithTimeArg
*/
function addCurrentTimeToURL(url, base, param = 't') {
function addCurrentTimeToURL(url, base) {
var urlUsed = new URL(url, base);
urlUsed.searchParams.delete('start');
var currentTime = Math.ceil(player.currentTime());
if (currentTime > 0)
urlUsed.searchParams.set(param, currentTime);
urlUsed.searchParams.set('t', currentTime);
else if (urlUsed.searchParams.has('t'))
urlUsed.searchParams.delete('t');
return urlUsed;
@@ -133,7 +132,7 @@ var timeupdate_last_ts = 5;
player.on('timeupdate', function () {
// Only update once every second
let current_ts = Math.floor(player.currentTime());
if (current_ts != timeupdate_last_ts) timeupdate_last_ts = current_ts;
if (current_ts > timeupdate_last_ts) timeupdate_last_ts = current_ts;
else return;
// YouTube links
@@ -144,11 +143,11 @@ player.on('timeupdate', function () {
let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url');
elem_yt_watch.href = addCurrentTimeToURL(base_url_yt_watch);
}
let elem_yt_embed = document.getElementById('link-yt-embed');
if (elem_yt_embed) {
let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url');
elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed, undefined, 'start');
elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed);
}
}
@@ -161,18 +160,12 @@ player.on('timeupdate', function () {
let base_url_iv_embed = elem_iv_embed.getAttribute('data-base-url');
elem_iv_embed.href = addCurrentTimeToURL(base_url_iv_embed, domain);
}
let elem_iv_other = document.getElementById('link-iv-other');
if (elem_iv_other) {
let base_url_iv_other = elem_iv_other.getAttribute('data-base-url');
elem_iv_other.href = addCurrentTimeToURL(base_url_iv_other, domain);
}
let elem_iv_listen = document.getElementById('link-iv-listen');
if (elem_iv_listen) {
let base_url_iv_listen = elem_iv_listen.getAttribute('data-base-url');
elem_iv_listen.href = addCurrentTimeToURL(base_url_iv_listen, domain);
}
});
@@ -635,7 +628,7 @@ function toggle_caption_window() {
player.textTrackSettings.setValues({ windowOpacity: options.windowOpacity[newIndex] });
update_captions();
}
function toggle_caption_opacity() {
const numOptions = options.textOpacity.length;
const textOpacity = player.textTrackSettings.getValues().textOpacity || '1';
@@ -740,7 +733,7 @@ addEventListener('keydown', function (e) {
case '>': action = increase_playback_rate.bind(this, 1); break;
case '<': action = increase_playback_rate.bind(this, -1); break;
case '=': action = increase_caption_size.bind(this, 1); break;
case '-': action = increase_caption_size.bind(this, -1); break;
+4 -31
View File
@@ -8,13 +8,6 @@
## Database configuration with separate parameters.
## This setting is MANDATORY, unless 'database_url' is used.
##
## Note: The 'db' setting allows the use of UNIX
## sockets. To do so, set 'host' to ""
## E.g:
## password: kemal
## host: ""
## port: 5432
##
db:
user: kemal
password: kemal
@@ -53,7 +46,7 @@ db:
##
## When this setting is commented out, Invidious companion is not used.
## Otherwise, Invidious will proxy the requests to Invidious companion.
##
##
## Note: multiple URL can be configured. In this case, Invidious will
## randomly pick one every time video data needs to be retrieved. This
## URL is then kept in the video metadata cache to allow video playback
@@ -63,7 +56,7 @@ db:
## The parameter private_url is required for the internal communication
## between Invidious companion and Invidious.
##
## The optional parameter public_url is the public URL from which
## The optional parameter public_url is the public URL from which
## Invidious companion is listening to the requests from the user(s).
## When this setting is commented out, Invidious proxy all requests to
## Invidious companion. Useful for simple setups.
@@ -151,26 +144,6 @@ db:
##
domain:
##
## List of alternative domains where the invidious instance is being served.
## This needs to be set in order to be able to login and update user preferences
## when using a domain that is not the same as the `domain` configuration,
## like a .`onion` address, `.i2p` address, `.b32.i2p` address, etc.
##
## It will detect the alternative domain trough the `X-Forwarded-Host` header.
## https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-Host
##
## Accepted values: a list of fully qualified domain names (FQDN)
## Default: <none>
##
## Example:
## alternative_domains:
## - invidious.example.com
## - inv.example.com
## - videos.example.com
##
alternative_domains:
##
## Tell Invidious that it is behind a proxy that provides only
## HTTPS, so all links must use the https:// scheme. This
@@ -252,7 +225,7 @@ https_only: false
## Configuration for using a HTTP proxy
## If unset, then no HTTP proxy will be used.
## Proxy type supported: HTTP, HTTPS
##
##
## This is not used for loading the video streams from YouTube servers (circumvent YouTube restrictions)
## Please instead configure the proxy in Invidious companion:
## https://github.com/iv-org/invidious-companion/blob/master/config/config.example.toml
@@ -905,7 +878,7 @@ default_user_preferences:
## Default: true
##
#vr_mode: true
##
## Save the playback position
## Allow to continue watching at the previous position when
+5 -5
View File
@@ -1,8 +1,8 @@
# https://github.com/openssl/openssl/releases/tag/openssl-3.6.2
ARG OPENSSL_VERSION='3.6.2'
ARG OPENSSL_SHA256='aaf51a1fe064384f811daeaeb4ec4dce7340ec8bd893027eee676af31e83a04f'
# https://github.com/openssl/openssl/releases/tag/openssl-3.5.2
ARG OPENSSL_VERSION='3.5.2'
ARG OPENSSL_SHA256='c53a47e5e441c930c3928cf7bf6fb00e5d129b630e0aa873b08258656e7345ec'
FROM 84codes/crystal:1.20.2-alpine AS dependabot-crystal
FROM crystallang/crystal:1.16.3-alpine AS dependabot-crystal
# We compile openssl ourselves due to a memory leak in how crystal interacts
# with openssl
@@ -43,7 +43,7 @@ COPY ./assets/ ./assets/
COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
RUN crystal spec --warnings all \
--link-flags "-lxml2 -llzma"
--link-flags "-lxml2 -llzma"
ARG OPENSSL_VERSION
COPY --from=openssl-builder /openssl-${OPENSSL_VERSION} /openssl-${OPENSSL_VERSION}
+83
View File
@@ -0,0 +1,83 @@
# https://github.com/openssl/openssl/releases/tag/openssl-3.5.2
ARG OPENSSL_VERSION='3.5.2'
ARG OPENSSL_SHA256='c53a47e5e441c930c3928cf7bf6fb00e5d129b630e0aa873b08258656e7345ec'
FROM alpine:3.22 AS dependabot-alpine
# We compile openssl ourselves due to a memory leak in how crystal interacts
# with openssl
# Reference: https://github.com/iv-org/invidious/issues/1438#issuecomment-3087636228
FROM dependabot-alpine AS openssl-builder
RUN apk add --no-cache curl perl linux-headers build-base
WORKDIR /
ARG OPENSSL_VERSION
ARG OPENSSL_SHA256
RUN curl -Ls "https://github.com/openssl/openssl/releases/download/openssl-${OPENSSL_VERSION}/openssl-${OPENSSL_VERSION}.tar.gz" --output openssl-${OPENSSL_VERSION}.tar.gz
RUN echo "${OPENSSL_SHA256} openssl-${OPENSSL_VERSION}.tar.gz" | sha256sum -c
RUN tar -xzvf openssl-${OPENSSL_VERSION}.tar.gz
RUN cd openssl-${OPENSSL_VERSION} && ./Configure --openssldir=/etc/ssl && make -j$(nproc)
FROM dependabot-alpine AS builder
RUN apk add --no-cache 'crystal=1.16.3-r0' shards \
sqlite-static yaml-static yaml-dev \
pcre2-static gc-static \
libxml2-static zlib-static \
openssl-libs-static openssl-dev musl-dev xz-static
ARG release
WORKDIR /invidious
COPY ./shard.yml ./shard.yml
COPY ./shard.lock ./shard.lock
RUN shards install --production
COPY ./src/ ./src/
# TODO: .git folder is required for building this is destructive.
# See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION.
COPY ./.git/ ./.git/
# Required for fetching player dependencies
COPY ./scripts/ ./scripts/
COPY ./assets/ ./assets/
COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
RUN crystal spec --warnings all \
--link-flags "-lxml2 -llzma"
ARG OPENSSL_VERSION
COPY --from=openssl-builder /openssl-${OPENSSL_VERSION} /openssl-${OPENSSL_VERSION}
RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ; then \
PKG_CONFIG_PATH=/openssl-${OPENSSL_VERSION} \
crystal build ./src/invidious.cr \
--release \
--static --warnings all \
--link-flags "-lxml2 -llzma"; \
else \
PKG_CONFIG_PATH=/openssl-${OPENSSL_VERSION} \
crystal build ./src/invidious.cr \
--static --warnings all \
--link-flags "-lxml2 -llzma"; \
fi
FROM alpine:3.22
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata
WORKDIR /invidious
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: invidious-db/' config/config.yml
COPY ./config/sql/ ./config/sql/
COPY ./locales/ ./locales/
COPY --from=builder /invidious/assets ./assets/
COPY --from=builder /invidious/invidious .
RUN chmod o+rX -R ./assets ./config ./locales
EXPOSE 3000
USER invidious
ENTRYPOINT ["/sbin/tini", "--"]
CMD [ "/invidious/invidious" ]
-2
View File
@@ -124,8 +124,6 @@
"preferences_sort_label": "Sort videos by: ",
"preferences_default_playlist": "Default playlist: ",
"preferences_default_playlist_none": "No default playlist set",
"preferences_search_privacy_label": "Search privacy: ",
"preferences_search_privacy_description": "Enabling this preference will prevent your search queries from being saved in your browser history.",
"published": "published",
"published - reverse": "published - reverse",
"alphabetically": "alphabetically",
+1 -1
View File
@@ -24,7 +24,7 @@ def create_licence_tr(path, file_name, licence_name, licence_link, source_locati
"<tr>
<td><a href=\\"/#{path}\\">#{file_name}</a></td>
<td><a href=\\"#{licence_link}\\">#{licence_name}</a></td>
<td><a href=\\"#{source_location}\\">\#{I18n.translate(locale, "source")}</a></td>
<td><a href=\\"#{source_location}\\">\#{translate(locale, "source")}</a></td>
</tr>"
HTML
+1 -1
View File
@@ -3,7 +3,7 @@
# Crystal linter
# This is a modified version of the pre-commit hook from the crystal repo. https://github.com/crystal-lang/crystal/blob/master/scripts/git/pre-commit
# Please refer to that if you'd like an version that doesn't automatically format staged files.
# Please refer to that if you'd like an version that doesn't automatically format staged files.
changed_cr_files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.cr$')
if [ ! -z "$changed_cr_files" ]; then
if [ -x bin/crystal ]; then
+1 -5
View File
@@ -1,14 +1,10 @@
name: invidious
version: 2.20260207.0-dev
version: 2.20250913.0-dev
authors:
- Invidious team <contact@invidious.io>
- Contributors!
targets:
invidious:
main: src/invidious.cr
description: |
Invidious is an alternative front-end to YouTube
+2
View File
@@ -48,7 +48,9 @@ FEATURE_FILTERS = {
SORT_FILTERS = {
Invidious::Search::Filters::Sort::Relevance => "8AEB",
Invidious::Search::Filters::Sort::Date => "CALwAQE%3D",
Invidious::Search::Filters::Sort::Views => "CAPwAQE%3D",
Invidious::Search::Filters::Sort::Rating => "CAHwAQE%3D",
}
Spectator.describe Invidious::Search::Filters do
@@ -7,7 +7,7 @@ Spectator.describe "parse_video_info" do
_next = load_mock("video/regular_mrbeast.next")
raw_data = _player.merge!(_next)
info = Invidious::Videos::Parser.parse_video_info("2isYuQZMbdU", raw_data)
info = parse_video_info("2isYuQZMbdU", raw_data)
# Some basic verifications
expect(typeof(info)).to eq(Hash(String, JSON::Any))
@@ -88,7 +88,7 @@ Spectator.describe "parse_video_info" do
_next = load_mock("video/regular_no-description.next")
raw_data = _player.merge!(_next)
info = Invidious::Videos::Parser.parse_video_info("iuevw6218F0", raw_data)
info = parse_video_info("iuevw6218F0", raw_data)
# Some basic verifications
expect(typeof(info)).to eq(Hash(String, JSON::Any))
@@ -7,7 +7,7 @@ Spectator.describe "parse_video_info" do
_next = load_mock("video/scheduled_live_PBD-Podcast.next")
raw_data = _player.merge!(_next)
info = Invidious::Videos::Parser.parse_video_info("N-yVic7BbY0", raw_data)
info = parse_video_info("N-yVic7BbY0", raw_data)
# Some basic verifications
expect(typeof(info)).to eq(Hash(String, JSON::Any))
+13 -2
View File
@@ -67,9 +67,20 @@ rescue ex
puts "Check your 'config.yml' database settings or PostgreSQL settings."
exit(1)
end
HOST_URL = make_host_url(Kemal.config)
ARCHIVE_URL = URI.parse("https://archive.org")
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
REDDIT_URL = URI.parse("https://www.reddit.com")
YT_URL = URI.parse("https://www.youtube.com")
HOST_URL = make_host_url(Kemal.config)
CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
MAX_ITEMS_PER_PAGE = 1500
REQUEST_HEADERS_WHITELIST = {"accept", "accept-encoding", "cache-control", "content-length", "if-none-match", "range"}
RESPONSE_HEADERS_BLACKLIST = {"access-control-allow-origin", "alt-svc", "server"}
HTTP_CHUNK_SIZE = 10485760 # ~10MB
CURRENT_BRANCH = {{ "#{`git branch | sed -n '/* /s///p'`.strip}" }}
CURRENT_COMMIT = {{ "#{`git rev-list HEAD --max-count=1 --abbrev-commit`.strip}" }}
CURRENT_VERSION = {{ "#{`git log -1 --format=%ci | awk '{print $1}' | sed s/-/./g`.strip}" }}
@@ -86,7 +97,7 @@ SOFTWARE = {
"branch" => "#{CURRENT_BRANCH}",
}
YT_POOL = YoutubeConnectionPool.new(URI.parse("https://www.youtube.com"), capacity: CONFIG.pool_size)
YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size)
# Image request pool
+6 -20
View File
@@ -12,7 +12,6 @@ record AboutChannel,
sub_count : Int32,
joined : Time,
is_family_friendly : Bool,
pronouns : String?,
allowed_regions : Array(String),
tabs : Array(String),
tags : Array(String),
@@ -83,16 +82,11 @@ def get_about_info(ucid, locale) : AboutChannel
author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s
author_badge = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "title", "dynamicTextViewModel", "text", "attachmentRuns", 0, "element", "type", "imageType", "image", "sources", 0, "clientResource", "imageName")
.try &.as_s
# CHECK_CIRCLE_FILLED is used for normal channels and AUDIO_BADGE if used For
# music/artist channels
# TODO: Maybe separate verified author from verified artist?
author_verified = author_badge.try { |badge| badge == "CHECK_CIRCLE_FILLED" || badge == "AUDIO_BADGE" } || false
author_verified = has_verified_badge?(initdata.dig?("header", "c4TabbedHeaderRenderer", "badges"))
ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s
# Raises a KeyError on failure.
# TODO: Check if `c4TabbedHeaderRenderer` still exists on some channels.
banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
banners ||= initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "banner", "imageBannerViewModel", "image", "sources")
banner = banners.try &.[-1]?.try &.["url"].as_s?
@@ -166,21 +160,14 @@ def get_about_info(ucid, locale) : AboutChannel
end
sub_count = 0
pronouns = nil
if (metadata_rows = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "metadata", "contentMetadataViewModel", "metadataRows").try &.as_a)
metadata_rows.each do |row|
subscribe_metadata_part = row.dig?("metadataParts").try &.as_a.find { |i| i.dig?("text", "content").try &.as_s.includes?("subscribers") }
if !subscribe_metadata_part.nil?
sub_count = short_text_to_number(subscribe_metadata_part.dig("text", "content").as_s.split(" ")[0]).to_i32
metadata_part = row.dig?("metadataParts").try &.as_a.find { |i| i.dig?("text", "content").try &.as_s.includes?("subscribers") }
if !metadata_part.nil?
sub_count = short_text_to_number(metadata_part.dig("text", "content").as_s.split(" ")[0]).to_i32
end
pronoun_metadata_part = row.dig?("metadataParts").try &.as_a.find { |i| i.dig?("tooltip").try &.as_s.includes?("Pronouns") }
if !pronoun_metadata_part.nil?
pronouns = pronoun_metadata_part.dig("text", "content").as_s
end
break if sub_count != 0 && !pronouns.nil?
break if sub_count != 0
end
end
@@ -197,7 +184,6 @@ def get_about_info(ucid, locale) : AboutChannel
sub_count: sub_count,
joined: joined,
is_family_friendly: is_family_friendly,
pronouns: pronouns,
allowed_regions: allowed_regions,
tabs: tab_names,
tags: tags,
+1 -1
View File
@@ -38,7 +38,7 @@ struct ChannelVideo
json.field "authorId", self.ucid
json.field "authorUrl", "/channel/#{self.ucid}"
json.field "published", self.published.to_unix
json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(self.published, locale))
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
json.field "viewCount", self.views
end
+2 -2
View File
@@ -127,11 +127,11 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_sing
reply_count = short_text_to_number(post.dig?("actionButtons", "commentActionButtonsRenderer", "replyButton", "buttonRenderer", "text", "simpleText").try &.as_s || "0")
json.field "content", Helpers.html_to_content(content_html)
json.field "content", html_to_content(content_html)
json.field "contentHtml", content_html
json.field "published", published.to_unix
json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(published, locale))
json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
json.field "likeCount", like_count
json.field "replyCount", reply_count
+3 -15
View File
@@ -114,11 +114,7 @@ module Invidious::Channel::Tabs
"2:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
},
"4:varint" => sort_options_videos_short(sort_by),
"8:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
"3:varint" => sort_options_videos_short(sort_by),
},
"4:varint" => sort_options_videos_short(sort_by),
},
}
@@ -134,11 +130,7 @@ module Invidious::Channel::Tabs
"2:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
},
"4:varint" => sort_options_videos_short(sort_by),
"7:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
"3:varint" => sort_options_videos_short(sort_by),
},
"4:varint" => sort_options_videos_short(sort_by),
},
}
@@ -162,11 +154,7 @@ module Invidious::Channel::Tabs
"2:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
},
"5:varint" => sort_by_numerical,
"8:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
"3:varint" => sort_by_numerical,
},
"5:varint" => sort_by_numerical,
},
}
-1
View File
@@ -1,6 +1,5 @@
module Invidious::Comments
extend self
private REDDIT_URL = URI.parse("https://www.reddit.com")
def fetch_reddit(id, sort_by = "confidence")
client = make_client(REDDIT_URL)
+2 -2
View File
@@ -254,7 +254,7 @@ module Invidious::Comments
end
content_html = html_content || ""
json.field "content", Helpers.html_to_content(content_html)
json.field "content", html_to_content(content_html)
json.field "contentHtml", content_html
if published_text != nil
@@ -268,7 +268,7 @@ module Invidious::Comments
end
json.field "published", published.to_unix
json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(published, locale))
json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
end
if node_replies && !response["commentRepliesContinuation"]?
-3
View File
@@ -54,7 +54,6 @@ struct ConfigPreferences
property save_player_pos : Bool = false
@[YAML::Field(ignore: true)]
property default_playlist : String? = nil
property search_privacy : Bool = false
def to_tuple
{% begin %}
@@ -121,8 +120,6 @@ class Config
property hmac_key : String = ""
# Domain to be used for links to resources on the site where an absolute URL is required
property domain : String?
# Additional domain list that is going to be used for cookie domain validation
property alternative_domains : Array(String) = [] of String
# Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
property use_pubsub_feeds : Bool | Int32 = false
property popular_enabled : Bool = true
+2 -2
View File
@@ -28,14 +28,14 @@ module Invidious::Frontend::ChannelPage
if tab == selected_tab
str << "\t<b>"
str << I18n.translate(locale, "channel_tab_#{tab_name}_label")
str << translate(locale, "channel_tab_#{tab_name}_label")
str << "</b>\n"
else
# Video tab doesn't have the last path component
url = tab.videos? ? base_url : "#{base_url}/#{tab_name}"
str << %(\t<a href=") << url << %(">)
str << I18n.translate(locale, "channel_tab_#{tab_name}_label")
str << translate(locale, "channel_tab_#{tab_name}_label")
str << "</a>\n"
end
+3 -3
View File
@@ -32,9 +32,9 @@ module Invidious::Frontend::Comments
<p>
<a href="javascript:void(0)" data-onclick="toggle_parent">[ ]</a>
<b><a href="https://www.reddit.com/user/#{child.author}">#{child.author}</a></b>
#{I18n.translate_count(locale, "comments_points_count", child.score, I18n::NumberFormatting::Separator)}
<span title="#{child.created_utc.to_s("%a %B %-d %T %Y UTC")}">#{I18n.translate(locale, "`x` ago", recode_date(child.created_utc, locale))}</span>
<a href="https://www.reddit.com#{child.permalink}" title="#{I18n.translate(locale, "permalink")}">#{I18n.translate(locale, "permalink")}</a>
#{translate_count(locale, "comments_points_count", child.score, NumberFormatting::Separator)}
<span title="#{child.created_utc.to_s("%a %B %-d %T %Y UTC")}">#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}</span>
<a href="https://www.reddit.com#{child.permalink}" title="#{translate(locale, "permalink")}">#{translate(locale, "permalink")}</a>
</p>
<div>
#{body_html}
+13 -13
View File
@@ -6,10 +6,10 @@ module Invidious::Frontend::Comments
root = comments["comments"].as_a
root.each do |child|
if child["replies"]?
replies_count_text = I18n.translate_count(locale,
replies_count_text = translate_count(locale,
"comments_view_x_replies",
child["replies"]["replyCount"].as_i64 || 0,
I18n::NumberFormatting::Separator
NumberFormatting::Separator
)
replies_html = <<-END_HTML
@@ -25,10 +25,10 @@ module Invidious::Frontend::Comments
END_HTML
elsif comments["authorId"]? && !comments["singlePost"]?
# for posts we should display a link to the post
replies_count_text = I18n.translate_count(locale,
replies_count_text = translate_count(locale,
"comments_view_x_replies",
child["replyCount"].as_i64 || 0,
I18n::NumberFormatting::Separator
NumberFormatting::Separator
)
replies_html = <<-END_HTML
@@ -61,7 +61,7 @@ module Invidious::Frontend::Comments
sponsor_icon = String.build do |str|
str << %(<img alt="" )
str << %(src="/ggpht) << URI.parse(child["sponsorIconUrl"].as_s).request_target << "\" "
str << %(title=") << I18n.translate(locale, "Channel Sponsor") << "\" "
str << %(title=") << translate(locale, "Channel Sponsor") << "\" "
str << %(width="16" height="16" />)
end
end
@@ -110,14 +110,14 @@ module Invidious::Frontend::Comments
when "multiImage"
html << <<-END_HTML
<section class="carousel">
<a class="skip-link" href="#skip-#{child["commentId"]}">#{I18n.translate(locale, "carousel_skip")}</a>
<a class="skip-link" href="#skip-#{child["commentId"]}">#{translate(locale, "carousel_skip")}</a>
<div class="slides">
END_HTML
image_array = attachment["images"].as_a
image_array.each_index do |i|
html << <<-END_HTML
<div class="slides-item slide-#{i + 1}" id="#{child["commentId"]}-slide-#{i + 1}" aria-label="#{I18n.translate(locale, "carousel_slide", {"current" => (i + 1).to_s, "total" => image_array.size.to_s})}" tabindex="0">
<div class="slides-item slide-#{i + 1}" id="#{child["commentId"]}-slide-#{i + 1}" aria-label="#{translate(locale, "carousel_slide", {"current" => (i + 1).to_s, "total" => image_array.size.to_s})}" tabindex="0">
<img loading="lazy" src="/ggpht#{URI.parse(image_array[i][1]["url"].as_s).request_target}" alt="" />
</div>
END_HTML
@@ -129,7 +129,7 @@ module Invidious::Frontend::Comments
END_HTML
attachment["images"].as_a.each_index do |i|
html << <<-END_HTML
<a class="slider-nav" href="##{child["commentId"]}-slide-#{i + 1}" aria-label="#{I18n.translate(locale, "carousel_go_to", (i + 1).to_s)}" tabindex="-1" aria-hidden="true">#{i + 1}</a>
<a class="slider-nav" href="##{child["commentId"]}-slide-#{i + 1}" aria-label="#{translate(locale, "carousel_go_to", (i + 1).to_s)}" tabindex="-1" aria-hidden="true">#{i + 1}</a>
END_HTML
end
html << <<-END_HTML
@@ -143,18 +143,18 @@ module Invidious::Frontend::Comments
html << <<-END_HTML
<p>
<span title="#{Time.unix(child["published"].as_i64).to_s(I18n.translate(locale, "%A %B %-d, %Y"))}">#{I18n.translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? I18n.translate(locale, "(edited)") : ""}</span>
<span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""}</span>
|
END_HTML
if comments["videoId"]?
html << <<-END_HTML
<a rel="noreferrer noopener" href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{I18n.translate(locale, "YouTube comment permalink")}">[YT]</a>
<a rel="noreferrer noopener" href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
END_HTML
elsif comments["authorId"]?
html << <<-END_HTML
<a rel="noreferrer noopener" href="https://www.youtube.com/channel/#{comments["authorId"]}/community?lb=#{child["commentId"]}" title="#{I18n.translate(locale, "YouTube comment permalink")}">[YT]</a>
<a rel="noreferrer noopener" href="https://www.youtube.com/channel/#{comments["authorId"]}/community?lb=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
END_HTML
end
@@ -172,7 +172,7 @@ module Invidious::Frontend::Comments
html << <<-END_HTML
&nbsp;
<span class="creator-heart-container" title="#{I18n.translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}">
<span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}">
<span class="creator-heart">
<img loading="lazy" class="creator-heart-background-hearted" src="#{creator_thumbnail}" alt="" />
<span class="creator-heart-small-hearted">
@@ -197,7 +197,7 @@ module Invidious::Frontend::Comments
<div class="pure-u-1">
<p>
<a href="javascript:void(0)" data-continuation="#{comments["continuation"]}"
data-onclick="get_youtube_replies" data-load-more #{"data-load-replies" if is_replies}>#{I18n.translate(locale, "Load more")}</a>
data-onclick="get_youtube_replies" data-load-more #{"data-load-replies" if is_replies}>#{translate(locale, "Load more")}</a>
</p>
</div>
</div>
+9 -9
View File
@@ -6,16 +6,16 @@ module Invidious::Frontend::Pagination
private def first_page(str : String::Builder, locale : String?, url : String)
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
if I18n.locale_is_rtl?(locale)
if locale_is_rtl?(locale)
# Inverted arrow ("first" points to the right)
str << I18n.translate(locale, "First page")
str << translate(locale, "First page")
str << "&nbsp;&nbsp;"
str << %(<i class="icon ion-ios-arrow-forward"></i>)
else
# Regular arrow ("first" points to the left)
str << %(<i class="icon ion-ios-arrow-back"></i>)
str << "&nbsp;&nbsp;"
str << I18n.translate(locale, "First page")
str << translate(locale, "First page")
end
str << "</a>"
@@ -25,16 +25,16 @@ module Invidious::Frontend::Pagination
# Link
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
if I18n.locale_is_rtl?(locale)
if locale_is_rtl?(locale)
# Inverted arrow ("previous" points to the right)
str << I18n.translate(locale, "Previous page")
str << translate(locale, "Previous page")
str << "&nbsp;&nbsp;"
str << %(<i class="icon ion-ios-arrow-forward"></i>)
else
# Regular arrow ("previous" points to the left)
str << %(<i class="icon ion-ios-arrow-back"></i>)
str << "&nbsp;&nbsp;"
str << I18n.translate(locale, "Previous page")
str << translate(locale, "Previous page")
end
str << "</a>"
@@ -44,14 +44,14 @@ module Invidious::Frontend::Pagination
# Link
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
if I18n.locale_is_rtl?(locale)
if locale_is_rtl?(locale)
# Inverted arrow ("next" points to the left)
str << %(<i class="icon ion-ios-arrow-back"></i>)
str << "&nbsp;&nbsp;"
str << I18n.translate(locale, "Next page")
str << translate(locale, "Next page")
else
# Regular arrow ("next" points to the right)
str << I18n.translate(locale, "Next page")
str << translate(locale, "Next page")
str << "&nbsp;&nbsp;"
str << %(<i class="icon ion-ios-arrow-forward"></i>)
end
+8 -8
View File
@@ -6,7 +6,7 @@ module Invidious::Frontend::SearchFilters
return String.build(8000) do |str|
str << "<div id='filters'>\n"
str << "\t<details id='filters-collapse'>"
str << "\t\t<summary>" << I18n.translate(locale, "search_filters_title") << "</summary>\n"
str << "\t\t<summary>" << translate(locale, "search_filters_title") << "</summary>\n"
str << "\t\t<div id='filters-box'><form action='/search' method='get'>\n"
@@ -25,7 +25,7 @@ module Invidious::Frontend::SearchFilters
str << "\t\t\t<div id='filters-apply'>"
str << "<button type='submit' class=\"pure-button pure-button-primary\">"
str << I18n.translate(locale, "search_filters_apply_button")
str << translate(locale, "search_filters_apply_button")
str << "</button></div>\n"
str << "\t\t</form></div>\n"
@@ -41,7 +41,7 @@ module Invidious::Frontend::SearchFilters
str << "\t\t\t\t<div class=\"filter-column\"><fieldset>\n"
str << "\t\t\t\t\t<legend><div class=\"filter-name underlined\">"
str << I18n.translate(locale, "search_filters_{{name}}_label")
str << translate(locale, "search_filters_{{name}}_label")
str << "</div></legend>\n"
str << "\t\t\t\t\t<div class=\"filter-options\">\n"
@@ -62,7 +62,7 @@ module Invidious::Frontend::SearchFilters
str << '>'
str << "<label for='filter-date-{{date}}'>"
str << I18n.translate(locale, "search_filters_date_option_{{date}}")
str << translate(locale, "search_filters_date_option_{{date}}")
str << "</label></div>\n"
{% end %}
end
@@ -78,7 +78,7 @@ module Invidious::Frontend::SearchFilters
str << '>'
str << "<label for='filter-type-{{type}}'>"
str << I18n.translate(locale, "search_filters_type_option_{{type}}")
str << translate(locale, "search_filters_type_option_{{type}}")
str << "</label></div>\n"
{% end %}
end
@@ -94,7 +94,7 @@ module Invidious::Frontend::SearchFilters
str << '>'
str << "<label for='filter-duration-{{duration}}'>"
str << I18n.translate(locale, "search_filters_duration_option_{{duration}}")
str << translate(locale, "search_filters_duration_option_{{duration}}")
str << "</label></div>\n"
{% end %}
end
@@ -111,7 +111,7 @@ module Invidious::Frontend::SearchFilters
str << '>'
str << "<label for='filter-feature-{{feature}}'>"
str << I18n.translate(locale, "search_filters_features_option_{{feature}}")
str << translate(locale, "search_filters_features_option_{{feature}}")
str << "</label></div>\n"
{% end %}
{% end %}
@@ -128,7 +128,7 @@ module Invidious::Frontend::SearchFilters
str << '>'
str << "<label for='filter-sort-{{sort}}'>"
str << I18n.translate(locale, "search_filters_sort_option_{{sort}}")
str << translate(locale, "search_filters_sort_option_{{sort}}")
str << "</label></div>\n"
{% end %}
end
+6 -6
View File
@@ -20,11 +20,11 @@ module Invidious::Frontend::WatchPage
def download_widget(locale : String, video : Video, video_assets : VideoAssets) : String
if CONFIG.disabled?("downloads")
return "<p id=\"download\">#{I18n.translate(locale, "Download is disabled")}</p>"
return "<p id=\"download\">#{translate(locale, "Download is disabled")}</p>"
end
if CONFIG.dmca_content.includes?(video.id)
return "<p id=\"download\">#{I18n.translate(locale, "dmca_content")}</p>"
return "<p id=\"download\">#{translate(locale, "dmca_content")}</p>"
end
url = "/download"
@@ -36,7 +36,7 @@ module Invidious::Frontend::WatchPage
return String.build(4000) do |str|
str << "<form"
str << " class=\"pure-form pure-form-stacked\""
str << " action='" << HTML.escape(url) << "'"
str << " action='#{url}'"
str << " method='post'"
str << " rel='noopener noreferrer'"
str << " target='_blank'>"
@@ -49,7 +49,7 @@ module Invidious::Frontend::WatchPage
str << "\t<div class=\"pure-control-group\">\n"
str << "\t\t<label for='download_widget'>"
str << I18n.translate(locale, "Download as: ")
str << translate(locale, "Download as: ")
str << "</label>\n"
str << "\t\t<select name='download_widget' id='download_widget'>\n"
@@ -98,7 +98,7 @@ module Invidious::Frontend::WatchPage
value = {"label": caption.name, "ext": "#{caption.language_code}.vtt"}.to_json
str << "\t\t\t<option value='" << value << "'>"
str << I18n.translate(locale, "download_subtitles", I18n.translate(locale, caption.name))
str << translate(locale, "download_subtitles", translate(locale, caption.name))
str << "</option>\n"
end
@@ -108,7 +108,7 @@ module Invidious::Frontend::WatchPage
str << "\t</div>\n"
str << "\t<button type=\"submit\" class=\"pure-button pure-button-primary\">\n"
str << "\t\t<b>" << I18n.translate(locale, "Download") << "</b>\n"
str << "\t\t<b>" << translate(locale, "Download") << "</b>\n"
str << "\t</button>\n"
str << "</form>\n"
+13 -13
View File
@@ -63,19 +63,19 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce
error_message = <<-END_HTML
<div class="error_message">
<h2>#{I18n.translate(locale, "crash_page_you_found_a_bug")}</h2>
<h2>#{translate(locale, "crash_page_you_found_a_bug")}</h2>
<br/><br/>
<p><b>#{I18n.translate(locale, "crash_page_before_reporting")}</b></p>
<p><b>#{translate(locale, "crash_page_before_reporting")}</b></p>
<ul>
<li>#{I18n.translate(locale, "crash_page_refresh", env.request.resource)}</li>
<li>#{I18n.translate(locale, "crash_page_switch_instance", url_switch)}</li>
<li>#{I18n.translate(locale, "crash_page_read_the_faq", url_faq)}</li>
<li>#{I18n.translate(locale, "crash_page_search_issue", url_search_issues)}</li>
<li>#{translate(locale, "crash_page_refresh", env.request.resource)}</li>
<li>#{translate(locale, "crash_page_switch_instance", url_switch)}</li>
<li>#{translate(locale, "crash_page_read_the_faq", url_faq)}</li>
<li>#{translate(locale, "crash_page_search_issue", url_search_issues)}</li>
</ul>
<br/>
<p>#{I18n.translate(locale, "crash_page_report_issue", url_new_issue)}</p>
<p>#{translate(locale, "crash_page_report_issue", url_new_issue)}</p>
<!-- TODO: Add a "copy to clipboard" button -->
<pre class="error-issue-template">#{issue_template}</pre>
@@ -95,7 +95,7 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, mess
locale = env.get("preferences").as(Preferences).locale
error_message = I18n.translate(locale, message)
error_message = translate(locale, message)
next_steps = error_redirect_helper(env)
return templated "error"
@@ -186,10 +186,10 @@ def error_redirect_helper(env : HTTP::Server::Context)
if request_path.starts_with?("/search") || request_path.starts_with?("/watch") ||
request_path.starts_with?("/channel") || request_path.starts_with?("/playlist?list=PL")
next_steps_text = I18n.translate(locale, "next_steps_error_message")
refresh = I18n.translate(locale, "next_steps_error_message_refresh")
go_to_youtube = I18n.translate(locale, "next_steps_error_message_go_to_youtube")
switch_instance = I18n.translate(locale, "Switch Invidious Instance")
next_steps_text = translate(locale, "next_steps_error_message")
refresh = translate(locale, "next_steps_error_message_refresh")
go_to_youtube = translate(locale, "next_steps_error_message_go_to_youtube")
switch_instance = translate(locale, "Switch Invidious Instance")
return <<-END_HTML
<p style="margin-bottom: 4px;">#{next_steps_text}</p>
@@ -201,7 +201,7 @@ def error_redirect_helper(env : HTTP::Server::Context)
<a href="/redirect?referer=#{env.get("current_page")}">#{switch_instance}</a>
</li>
<li>
<a rel="noreferrer noopener" href="https://www.youtube.com#{env.request.resource}">#{go_to_youtube}</a>
<a rel="noreferrer noopener" href="https://youtube.com#{env.request.resource}">#{go_to_youtube}</a>
</li>
</ul>
END_HTML
+130 -136
View File
@@ -22,124 +22,60 @@ struct Annotation
property annotations : String
end
module Helpers
extend self
def html_to_content(description_html : String)
description = description_html.gsub(/(<br>)|(<br\/>)/, {
"<br>": "\n",
"<br/>": "\n",
})
private TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
def html_to_content(description_html : String)
description = description_html.gsub(/(<br>)|(<br\/>)/, {
"<br>": "\n",
"<br/>": "\n",
})
if !description.empty?
description = XML.parse_html(description).content.strip("\n ")
end
return description
if !description.empty?
description = XML.parse_html(description).content.strip("\n ")
end
def cache_annotation(id, annotations)
if !CONFIG.cache_annotations
return
end
return description
end
body = XML.parse(annotations)
nodeset = body.xpath_nodes(%q(/document/annotations/annotation))
return if nodeset == 0
has_legacy_annotations = false
nodeset.each do |node|
if !{"branding", "card", "drawer"}.includes? node["type"]?
has_legacy_annotations = true
break
end
end
Invidious::Database::Annotations.insert(id, annotations) if has_legacy_annotations
def cache_annotation(id, annotations)
if !CONFIG.cache_annotations
return
end
def create_notification_stream(env, topics, connection_channel)
connection = Channel(PQ::Notification).new(8)
connection_channel.send({true, connection})
body = XML.parse(annotations)
nodeset = body.xpath_nodes(%q(/document/annotations/annotation))
locale = env.get("preferences").as(Preferences).locale
return if nodeset == 0
since = env.params.query["since"]?.try &.to_i?
id = 0
if topics.includes? "debug"
spawn do
begin
loop do
time_span = [0, 0, 0, 0]
time_span[rand(4)] = rand(30) + 5
published = Time.utc - Time::Span.new(days: time_span[0], hours: time_span[1], minutes: time_span[2], seconds: time_span[3])
video_id = TEST_IDS[rand(TEST_IDS.size)]
video = get_video(video_id)
video.published = published
response = JSON.parse(video.to_json(locale, nil))
env.response.puts "id: #{id}"
env.response.puts "data: #{response.to_json}"
env.response.puts
env.response.flush
id += 1
sleep 1.minute
Fiber.yield
end
rescue ex
end
end
has_legacy_annotations = false
nodeset.each do |node|
if !{"branding", "card", "drawer"}.includes? node["type"]?
has_legacy_annotations = true
break
end
end
spawn do
begin
if since
since_unix = Time.unix(since.not_nil!)
Invidious::Database::Annotations.insert(id, annotations) if has_legacy_annotations
end
topics.try &.each do |topic|
case topic
when .match(/UC[A-Za-z0-9_-]{22}/)
Invidious::Database::ChannelVideos.select_notfications(topic, since_unix).each do |video|
response = JSON.parse(video.to_json(locale))
def create_notification_stream(env, topics, connection_channel)
connection = Channel(PQ::Notification).new(8)
connection_channel.send({true, connection})
env.response.puts "id: #{id}"
env.response.puts "data: #{response.to_json}"
env.response.puts
env.response.flush
locale = env.get("preferences").as(Preferences).locale
id += 1
end
else
# TODO
end
end
end
end
end
since = env.params.query["since"]?.try &.to_i?
id = 0
if topics.includes? "debug"
spawn do
begin
loop do
event = connection.receive
notification = JSON.parse(event.payload)
topic = notification["topic"].as_s
video_id = notification["videoId"].as_s
published = notification["published"].as_i64
if !topics.try &.includes? topic
next
end
time_span = [0, 0, 0, 0]
time_span[rand(4)] = rand(30) + 5
published = Time.utc - Time::Span.new(days: time_span[0], hours: time_span[1], minutes: time_span[2], seconds: time_span[3])
video_id = TEST_IDS[rand(TEST_IDS.size)]
video = get_video(video_id)
video.published = Time.unix(published)
video.published = published
response = JSON.parse(video.to_json(locale, nil))
env.response.puts "id: #{id}"
@@ -148,20 +84,65 @@ module Helpers
env.response.flush
id += 1
sleep 1.minute
Fiber.yield
end
rescue ex
ensure
connection_channel.send({false, connection})
end
end
end
spawn do
begin
if since
since_unix = Time.unix(since.not_nil!)
topics.try &.each do |topic|
case topic
when .match(/UC[A-Za-z0-9_-]{22}/)
Invidious::Database::ChannelVideos.select_notfications(topic, since_unix).each do |video|
response = JSON.parse(video.to_json(locale))
env.response.puts "id: #{id}"
env.response.puts "data: #{response.to_json}"
env.response.puts
env.response.flush
id += 1
end
else
# TODO
end
end
end
end
end
spawn do
begin
# Send heartbeat
loop do
env.response.puts ":keepalive #{Time.utc.to_unix}"
event = connection.receive
notification = JSON.parse(event.payload)
topic = notification["topic"].as_s
video_id = notification["videoId"].as_s
published = notification["published"].as_i64
if !topics.try &.includes? topic
next
end
video = get_video(video_id)
video.published = Time.unix(published)
response = JSON.parse(video.to_json(locale, nil))
env.response.puts "id: #{id}"
env.response.puts "data: #{response.to_json}"
env.response.puts
env.response.flush
sleep (20 + rand(11)).seconds
id += 1
end
rescue ex
ensure
@@ -169,38 +150,51 @@ module Helpers
end
end
def extract_initial_data(body) : Hash(String, JSON::Any)
return JSON.parse(body.match(/(window\["ytInitialData"\]|var\s*ytInitialData)\s*=\s*(?<info>{.*?});<\/script>/mx).try &.["info"] || "{}").as_h
end
def proxy_file(response, env)
if response.headers.includes_word?("Content-Encoding", "gzip")
Compress::Gzip::Writer.open(env.response) do |deflate|
IO.copy response.body_io, deflate
end
elsif response.headers.includes_word?("Content-Encoding", "deflate")
Compress::Deflate::Writer.open(env.response) do |deflate|
IO.copy response.body_io, deflate
end
else
IO.copy response.body_io, env.response
begin
# Send heartbeat
loop do
env.response.puts ":keepalive #{Time.utc.to_unix}"
env.response.puts
env.response.flush
sleep (20 + rand(11)).seconds
end
end
# Fetch the playback requests tracker from the statistics endpoint.
#
# Creates a new tracker when unavailable.
def get_playback_statistic
if (tracker = Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"]) && tracker.as(Hash).empty?
tracker = {
"totalRequests" => 0_i64,
"successfulRequests" => 0_i64,
"ratio" => 0_f64,
}
Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"] = tracker
end
return tracker.as(Hash(String, Int64 | Float64))
rescue ex
ensure
connection_channel.send({false, connection})
end
end
def extract_initial_data(body) : Hash(String, JSON::Any)
return JSON.parse(body.match(/(window\["ytInitialData"\]|var\s*ytInitialData)\s*=\s*(?<info>{.*?});<\/script>/mx).try &.["info"] || "{}").as_h
end
def proxy_file(response, env)
if response.headers.includes_word?("Content-Encoding", "gzip")
Compress::Gzip::Writer.open(env.response) do |deflate|
IO.copy response.body_io, deflate
end
elsif response.headers.includes_word?("Content-Encoding", "deflate")
Compress::Deflate::Writer.open(env.response) do |deflate|
IO.copy response.body_io, deflate
end
else
IO.copy response.body_io, env.response
end
end
# Fetch the playback requests tracker from the statistics endpoint.
#
# Creates a new tracker when unavailable.
def get_playback_statistic
if (tracker = Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"]) && tracker.as(Hash).empty?
tracker = {
"totalRequests" => 0_i64,
"successfulRequests" => 0_i64,
"ratio" => 0_f64,
}
Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"] = tracker
end
return tracker.as(Hash(String, Int64 | Float64))
end
+178 -182
View File
@@ -1,203 +1,199 @@
module I18n
extend self
# Languages requiring a better level of translation (at least 20%)
# to be added to the list below:
#
# "af" => "", # Afrikaans
# "az" => "", # Azerbaijani
# "be" => "", # Belarusian
# "bn_BD" => "", # Bengali (Bangladesh)
# "ia" => "", # Interlingua
# "or" => "", # Odia
# "tk" => "", # Turkmen
# "tok => "", # Toki Pona
#
LOCALES_LIST = {
"ar" => "العربية", # Arabic
"bg" => "български", # Bulgarian
"bn" => "বাংলা", # Bengali
"ca" => "Català", # Catalan
"cs" => "Čeština", # Czech
"cy" => "Cymraeg", # Welsh
"da" => "Dansk", # Danish
"de" => "Deutsch", # German
"el" => "Ελληνικά", # Greek
"en-US" => "English", # English
"eo" => "Esperanto", # Esperanto
"es" => "Español", # Spanish
"et" => "Eesti keel", # Estonian
"eu" => "Euskara", # Basque
"fa" => "فارسی", # Persian
"fi" => "Suomi", # Finnish
"fr" => "Français", # French
"he" => "עברית", # Hebrew
"hi" => "हिन्दी", # Hindi
"hr" => "Hrvatski", # Croatian
"hu-HU" => "Magyar Nyelv", # Hungarian
"id" => "Bahasa Indonesia", # Indonesian
"is" => "Íslenska", # Icelandic
"it" => "Italiano", # Italian
"ja" => "日本語", # Japanese
"ko" => "한국어", # Korean
"lmo" => "Lombard", # Lombard
"lt" => "Lietuvių", # Lithuanian
"nb-NO" => "Norsk bokmål", # Norwegian Bokmål
"nl" => "Nederlands", # Dutch
"pl" => "Polski", # Polish
"pt" => "Português", # Portuguese
"pt-BR" => "Português Brasileiro", # Portuguese (Brazil)
"pt-PT" => "Português de Portugal", # Portuguese (Portugal)
"ro" => "Română", # Romanian
"ru" => "Русский", # Russian
"si" => "සිංහල", # Sinhala
"sk" => "Slovenčina", # Slovak
"sl" => "Slovenščina", # Slovenian
"sq" => "Shqip", # Albanian
"sr" => "Srpski (latinica)", # Serbian (Latin)
"sr_Cyrl" => "Српски (ћирилица)", # Serbian (Cyrillic)
"sv-SE" => "Svenska", # Swedish
"ta" => "தமிழ்", # Tamil
"tr" => "Türkçe", # Turkish
"uk" => "Українська", # Ukrainian
"vi" => "Tiếng Việt", # Vietnamese
"zh-CN" => "汉语", # Chinese (Simplified)
"zh-TW" => "漢語", # Chinese (Traditional)
}
# Languages requiring a better level of translation (at least 20%)
# to be added to the list below:
#
# "af" => "", # Afrikaans
# "az" => "", # Azerbaijani
# "be" => "", # Belarusian
# "bn_BD" => "", # Bengali (Bangladesh)
# "ia" => "", # Interlingua
# "or" => "", # Odia
# "tk" => "", # Turkmen
# "tok => "", # Toki Pona
#
LOCALES_LIST = {
"ar" => "العربية", # Arabic
"bg" => "български", # Bulgarian
"bn" => "বাংলা", # Bengali
"ca" => "Català", # Catalan
"cs" => "Čeština", # Czech
"cy" => "Cymraeg", # Welsh
"da" => "Dansk", # Danish
"de" => "Deutsch", # German
"el" => "Ελληνικά", # Greek
"en-US" => "English", # English
"eo" => "Esperanto", # Esperanto
"es" => "Español", # Spanish
"et" => "Eesti keel", # Estonian
"eu" => "Euskara", # Basque
"fa" => "فارسی", # Persian
"fi" => "Suomi", # Finnish
"fr" => "Français", # French
"he" => "עברית", # Hebrew
"hi" => "हिन्दी", # Hindi
"hr" => "Hrvatski", # Croatian
"hu-HU" => "Magyar Nyelv", # Hungarian
"id" => "Bahasa Indonesia", # Indonesian
"is" => "Íslenska", # Icelandic
"it" => "Italiano", # Italian
"ja" => "日本語", # Japanese
"ko" => "한국어", # Korean
"lmo" => "Lombard", # Lombard
"lt" => "Lietuvių", # Lithuanian
"nb-NO" => "Norsk bokmål", # Norwegian Bokmål
"nl" => "Nederlands", # Dutch
"pl" => "Polski", # Polish
"pt" => "Português", # Portuguese
"pt-BR" => "Português Brasileiro", # Portuguese (Brazil)
"pt-PT" => "Português de Portugal", # Portuguese (Portugal)
"ro" => "Română", # Romanian
"ru" => "Русский", # Russian
"si" => "සිංහල", # Sinhala
"sk" => "Slovenčina", # Slovak
"sl" => "Slovenščina", # Slovenian
"sq" => "Shqip", # Albanian
"sr" => "Srpski (latinica)", # Serbian (Latin)
"sr_Cyrl" => "Српски (ћирилица)", # Serbian (Cyrillic)
"sv-SE" => "Svenska", # Swedish
"ta" => "தமிழ்", # Tamil
"tr" => "Türkçe", # Turkish
"uk" => "Українська", # Ukrainian
"vi" => "Tiếng Việt", # Vietnamese
"zh-CN" => "汉语", # Chinese (Simplified)
"zh-TW" => "漢語", # Chinese (Traditional)
}
LOCALES = load_all_locales()
LOCALES = load_all_locales()
CONTENT_REGIONS = {
"AE", "AR", "AT", "AU", "AZ", "BA", "BD", "BE", "BG", "BH", "BO", "BR", "BY",
"CA", "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "DZ", "EC", "EE",
"EG", "ES", "FI", "FR", "GB", "GE", "GH", "GR", "GT", "HK", "HN", "HR", "HU",
"ID", "IE", "IL", "IN", "IQ", "IS", "IT", "JM", "JO", "JP", "KE", "KR", "KW",
"KZ", "LB", "LI", "LK", "LT", "LU", "LV", "LY", "MA", "ME", "MK", "MT", "MX",
"MY", "NG", "NI", "NL", "NO", "NP", "NZ", "OM", "PA", "PE", "PG", "PH", "PK",
"PL", "PR", "PT", "PY", "QA", "RO", "RS", "RU", "SA", "SE", "SG", "SI", "SK",
"SN", "SV", "TH", "TN", "TR", "TW", "TZ", "UA", "UG", "US", "UY", "VE", "VN",
"YE", "ZA", "ZW",
}
CONTENT_REGIONS = {
"AE", "AR", "AT", "AU", "AZ", "BA", "BD", "BE", "BG", "BH", "BO", "BR", "BY",
"CA", "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "DZ", "EC", "EE",
"EG", "ES", "FI", "FR", "GB", "GE", "GH", "GR", "GT", "HK", "HN", "HR", "HU",
"ID", "IE", "IL", "IN", "IQ", "IS", "IT", "JM", "JO", "JP", "KE", "KR", "KW",
"KZ", "LB", "LI", "LK", "LT", "LU", "LV", "LY", "MA", "ME", "MK", "MT", "MX",
"MY", "NG", "NI", "NL", "NO", "NP", "NZ", "OM", "PA", "PE", "PG", "PH", "PK",
"PL", "PR", "PT", "PY", "QA", "RO", "RS", "RU", "SA", "SE", "SG", "SI", "SK",
"SN", "SV", "TH", "TN", "TR", "TW", "TZ", "UA", "UG", "US", "UY", "VE", "VN",
"YE", "ZA", "ZW",
}
# Enum for the different types of number formats
enum NumberFormatting
None # Print the number as-is
Separator # Use a separator for thousands
Short # Use short notation (k/M/B)
HtmlSpan # Surround with <span id="count"></span>
end
# Enum for the different types of number formats
enum NumberFormatting
None # Print the number as-is
Separator # Use a separator for thousands
Short # Use short notation (k/M/B)
HtmlSpan # Surround with <span id="count"></span>
def load_all_locales
locales = {} of String => Hash(String, JSON::Any)
LOCALES_LIST.each_key do |name|
locales[name] = JSON.parse(File.read("locales/#{name}.json")).as_h
end
def load_all_locales
locales = {} of String => Hash(String, JSON::Any)
return locales
end
LOCALES_LIST.each_key do |name|
locales[name] = JSON.parse(File.read("locales/#{name}.json")).as_h
end
return locales
def translate(locale : String?, key : String, text : String | Hash(String, String) | Nil = nil) : String
# Log a warning if "key" doesn't exist in en-US locale and return
# that key as the text, so this is more or less transparent to the user.
if !LOCALES["en-US"].has_key?(key)
LOGGER.warn("i18n: Missing translation key \"#{key}\"")
return key
end
def translate(locale : String?, key : String, text : String | Hash(String, String) | Nil = nil) : String
# Log a warning if "key" doesn't exist in en-US locale and return
# that key as the text, so this is more or less transparent to the user.
if !LOCALES["en-US"].has_key?(key)
LOGGER.warn("i18n: Missing translation key \"#{key}\"")
return key
end
# Default to english, whenever the locale doesn't exist,
# or the key requested has not been translated
if locale && LOCALES.has_key?(locale) && LOCALES[locale].has_key?(key)
raw_data = LOCALES[locale][key]
else
raw_data = LOCALES["en-US"][key]
end
# Default to english, whenever the locale doesn't exist,
# or the key requested has not been translated
if locale && LOCALES.has_key?(locale) && LOCALES[locale].has_key?(key)
raw_data = LOCALES[locale][key]
else
raw_data = LOCALES["en-US"][key]
end
case raw_data
when .as_h?
# Init
translation = ""
match_length = 0
case raw_data
when .as_h?
# Init
translation = ""
match_length = 0
raw_data.as_h.each do |hash_key, value|
if text.is_a?(String)
if md = text.try &.match(/#{hash_key}/)
if md[0].size >= match_length
translation = value.as_s
match_length = md[0].size
end
raw_data.as_h.each do |hash_key, value|
if text.is_a?(String)
if md = text.try &.match(/#{hash_key}/)
if md[0].size >= match_length
translation = value.as_s
match_length = md[0].size
end
end
end
when .as_s?
translation = raw_data.as_s
end
when .as_s?
translation = raw_data.as_s
else
raise "Invalid translation \"#{raw_data}\""
end
if text.is_a?(String)
translation = translation.gsub("`x`", text)
elsif text.is_a?(Hash(String, String))
# adds support for multi string interpolation. Based on i18next https://www.i18next.com/translation-function/interpolation#basic
text.each_key do |hash_key|
translation = translation.gsub("{{#{hash_key}}}", text[hash_key])
end
end
return translation
end
def translate_count(locale : String, key : String, count : Int, format = NumberFormatting::None) : String
# Fallback on english if locale doesn't exist
locale = "en-US" if !LOCALES.has_key?(locale)
# Retrieve suffix
suffix = I18next::Plurals::RESOLVER.get_suffix(locale, count)
plural_key = key + suffix
if LOCALES[locale].has_key?(plural_key)
translation = LOCALES[locale][plural_key].as_s
else
# Try #1: Fallback to singular in the same locale
singular_suffix = I18next::Plurals::RESOLVER.get_suffix(locale, 1)
if LOCALES[locale].has_key?(key + singular_suffix)
translation = LOCALES[locale][key + singular_suffix].as_s
elsif locale != "en-US"
# Try #2: Fallback to english
translation = translate_count("en-US", key, count)
else
raise "Invalid translation \"#{raw_data}\""
end
if text.is_a?(String)
translation = translation.gsub("`x`", text)
elsif text.is_a?(Hash(String, String))
# adds support for multi string interpolation. Based on i18next https://www.i18next.com/translation-function/interpolation#basic
text.each_key do |hash_key|
translation = translation.gsub("{{#{hash_key}}}", text[hash_key])
end
end
return translation
end
def translate_count(locale : String, key : String, count : Int, format = NumberFormatting::None) : String
# Fallback on english if locale doesn't exist
locale = "en-US" if !LOCALES.has_key?(locale)
# Retrieve suffix
suffix = I18next::Plurals::RESOLVER.get_suffix(locale, count)
plural_key = key + suffix
if LOCALES[locale].has_key?(plural_key)
translation = LOCALES[locale][plural_key].as_s
else
# Try #1: Fallback to singular in the same locale
singular_suffix = I18next::Plurals::RESOLVER.get_suffix(locale, 1)
if LOCALES[locale].has_key?(key + singular_suffix)
translation = LOCALES[locale][key + singular_suffix].as_s
elsif locale != "en-US"
# Try #2: Fallback to english
translation = self.translate_count("en-US", key, count)
else
# Return key if we're already in english, as the translation is missing
LOGGER.warn("i18n: Missing translation key \"#{key}\"")
return key
end
end
case format
when .separator? then count_txt = number_with_separator(count)
when .short? then count_txt = number_to_short_text(count)
when .html_span? then count_txt = "<span id=\"count\">" + count.to_s + "</span>"
else count_txt = count.to_s
end
return translation.gsub("{{count}}", count_txt)
end
def translate_bool(locale : String?, translation : Bool)
case translation
when true
return self.translate(locale, "Yes")
when false
return self.translate(locale, "No")
# Return key if we're already in english, as the translation is missing
LOGGER.warn("i18n: Missing translation key \"#{key}\"")
return key
end
end
def locale_is_rtl?(locale : String?)
# Fallback to en-US
return false if locale.nil?
case format
when .separator? then count_txt = number_with_separator(count)
when .short? then count_txt = number_to_short_text(count)
when .html_span? then count_txt = "<span id=\"count\">" + count.to_s + "</span>"
else count_txt = count.to_s
end
# Arabic, Persian, Hebrew
# See https://en.wikipedia.org/wiki/Right-to-left_script#List_of_RTL_scripts
return {"ar", "fa", "he"}.includes? locale
return translation.gsub("{{count}}", count_txt)
end
def translate_bool(locale : String?, translation : Bool)
case translation
when true
return translate(locale, "Yes")
when false
return translate(locale, "No")
end
end
def locale_is_rtl?(locale : String?)
# Fallback to en-US
return false if locale.nil?
# Arabic, Persian, Hebrew
# See https://en.wikipedia.org/wiki/Right-to-left_script#List_of_RTL_scripts
return {"ar", "fa", "he"}.includes? locale
end
+8 -8
View File
@@ -53,7 +53,7 @@ struct SearchVideo
xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg")
end
xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text Helpers.html_to_content(self.description_html) }
xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text html_to_content(self.description_html) }
end
end
@@ -63,7 +63,7 @@ struct SearchVideo
xml.element("media:title") { xml.text self.title }
xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg",
width: "320", height: "180")
xml.element("media:description") { xml.text Helpers.html_to_content(self.description_html) }
xml.element("media:description") { xml.text html_to_content(self.description_html) }
end
xml.element("media:community") do
@@ -111,13 +111,13 @@ struct SearchVideo
Invidious::JSONify::APIv1.thumbnails(json, self.id)
end
json.field "description", Helpers.html_to_content(self.description_html)
json.field "description", html_to_content(self.description_html)
json.field "descriptionHtml", self.description_html
json.field "viewCount", self.views
json.field "viewCountText", I18n.translate_count(locale, "generic_views_count", self.views, I18n::NumberFormatting::Short)
json.field "viewCountText", translate_count(locale, "generic_views_count", self.views, NumberFormatting::Short)
json.field "published", self.published.to_unix
json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(self.published, locale))
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
json.field "lengthSeconds", self.length_seconds
json.field "liveNow", self.badges.live_now?
json.field "premium", self.badges.premium?
@@ -255,7 +255,7 @@ struct SearchChannel
json.field "videoCount", self.video_count
json.field "channelHandle", self.channel_handle
json.field "description", Helpers.html_to_content(self.description_html)
json.field "description", html_to_content(self.description_html)
json.field "descriptionHtml", self.description_html
end
end
@@ -327,8 +327,8 @@ struct ProblematicTimelineItem
xml.element("content", type: "xhtml") do
xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do
xml.element("div") do
xml.element("h4") { I18n.translate(locale, "timeline_parse_error_placeholder_heading") }
xml.element("p") { I18n.translate(locale, "timeline_parse_error_placeholder_message") }
xml.element("h4") { translate(locale, "timeline_parse_error_placeholder_heading") }
xml.element("p") { translate(locale, "timeline_parse_error_placeholder_message") }
end
xml.element("pre") do
+7 -9
View File
@@ -1,5 +1,3 @@
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
# See http://www.evanmiller.org/how-not-to-sort-by-average-rating.html
def ci_lower_bound(pos, n)
if n == 0
@@ -146,19 +144,19 @@ def recode_date(time : Time, locale)
span = Time.utc - time
if span.total_days > 365.0
return I18n.translate_count(locale, "generic_count_years", span.total_days.to_i // 365)
return translate_count(locale, "generic_count_years", span.total_days.to_i // 365)
elsif span.total_days > 30.0
return I18n.translate_count(locale, "generic_count_months", span.total_days.to_i // 30)
return translate_count(locale, "generic_count_months", span.total_days.to_i // 30)
elsif span.total_days > 7.0
return I18n.translate_count(locale, "generic_count_weeks", span.total_days.to_i // 7)
return translate_count(locale, "generic_count_weeks", span.total_days.to_i // 7)
elsif span.total_hours > 24.0
return I18n.translate_count(locale, "generic_count_days", span.total_days.to_i)
return translate_count(locale, "generic_count_days", span.total_days.to_i)
elsif span.total_minutes > 60.0
return I18n.translate_count(locale, "generic_count_hours", span.total_hours.to_i)
return translate_count(locale, "generic_count_hours", span.total_hours.to_i)
elsif span.total_seconds > 60.0
return I18n.translate_count(locale, "generic_count_minutes", span.total_minutes.to_i)
return translate_count(locale, "generic_count_minutes", span.total_minutes.to_i)
else
return I18n.translate_count(locale, "generic_count_seconds", span.total_seconds.to_i)
return translate_count(locale, "generic_count_seconds", span.total_seconds.to_i)
end
end
+2 -2
View File
@@ -22,7 +22,7 @@ module Invidious::JSONify::APIv1
json.field "description", video.description
json.field "descriptionHtml", video.description_html
json.field "published", video.published.to_unix
json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(video.published, locale))
json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale))
json.field "keywords", video.keywords
json.field "viewCount", video.views
@@ -268,7 +268,7 @@ module Invidious::JSONify::APIv1
json.field "viewCountText", rv["short_view_count"]?
json.field "published", rv["published"]?
if rv["published"]?.try &.presence
json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].to_s), locale))
json.field "publishedText", translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].to_s), locale))
else
json.field "publishedText", ""
end
+1 -1
View File
@@ -27,7 +27,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
video_id = "CvFH_6DNRCY" if rdid.starts_with? "OLAK5uy_"
response = YT_POOL.client &.get("/watch?v=#{video_id}&list=#{rdid}&gl=US&hl=en", headers)
initial_data = Helpers.extract_initial_data(response.body)
initial_data = extract_initial_data(response.body)
if !initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]?
raise InfoException.new("Could not create mix.")
+8 -15
View File
@@ -107,11 +107,7 @@ struct Playlist
json.field "author", self.author
json.field "authorId", self.ucid
if !self.ucid.empty?
json.field "authorUrl", "/channel/#{self.ucid}"
else
json.field "authorUrl", ""
end
json.field "authorUrl", "/channel/#{self.ucid}"
json.field "subtitle", self.subtitle
json.field "authorThumbnails" do
@@ -199,7 +195,7 @@ struct InvidiousPlaylist
json.field "authorUrl", nil
json.field "authorThumbnails", [] of String
json.field "description", Helpers.html_to_content(self.description_html)
json.field "description", html_to_content(self.description_html)
json.field "descriptionHtml", self.description_html
json.field "videoCount", self.video_count
@@ -363,9 +359,6 @@ def fetch_playlist(plid : String)
thumbnail = playlist_info.dig?(
"thumbnailRenderer", "playlistVideoThumbnailRenderer",
"thumbnail", "thumbnails", 0, "url"
).try &.as_s || playlist_info.dig?(
"thumbnailRenderer", "playlistCustomThumbnailRenderer",
"thumbnail", "thumbnails", 0, "url"
).try &.as_s
views = 0_i64
@@ -384,7 +377,7 @@ def fetch_playlist(plid : String)
video_count = text.gsub(/\D/, "").to_i? || 0
elsif text.includes? "view"
views = text.gsub(/\D/, "").to_i64? || 0_i64
elsif !text.includes? "Pay to watch"
else
updated = decode_date(text.lchop("Last updated on ").lchop("Updated "))
end
end
@@ -445,7 +438,7 @@ def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32,
# 100 videos per request
ctoken = produce_playlist_continuation(playlist.id, offset)
initial_data = YoutubeAPI.browse(ctoken)
videos += extract_playlist_videos(playlist.id, initial_data)
videos += extract_playlist_videos(initial_data)
offset += 100
end
@@ -454,7 +447,7 @@ def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32,
end
end
def extract_playlist_videos(playlist_id : String, initial_data : Hash(String, JSON::Any))
def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
videos = [] of PlaylistVideo | ProblematicTimelineItem
if initial_data["contents"]?
@@ -480,9 +473,9 @@ def extract_playlist_videos(playlist_id : String, initial_data : Hash(String, JS
contents.try &.each do |item|
if i = item["playlistVideoRenderer"]?
video_id = i.dig?("navigationEndpoint", "watchEndpoint", "videoId").try &.as_s || i.dig("videoId").as_s
plid = i.dig?("navigationEndpoint", "watchEndpoint", "playlistId").try &.as_s || playlist_id
index = i.dig?("navigationEndpoint", "watchEndpoint", "index").try &.as_i64 || i.dig("index", "simpleText").as_s.to_i64
video_id = i["navigationEndpoint"]["watchEndpoint"]["videoId"].as_s
plid = i["navigationEndpoint"]["watchEndpoint"]["playlistId"].as_s
index = i["navigationEndpoint"]["watchEndpoint"]["index"].as_i64
title = i["title"].try { |t| t["simpleText"]? || t["runs"]?.try &.[0]["text"]? }.try &.as_s || ""
author = i["shortBylineText"]?.try &.["runs"][0]["text"].as_s || ""
+2 -2
View File
@@ -8,7 +8,7 @@ module Invidious::Routes::API::V1::Authenticated
# topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000)
# topics ||= [] of String
# Helpers.create_notification_stream(env, topics, connection_channel)
# create_notification_stream(env, topics, connection_channel)
# end
def self.get_preferences(env)
@@ -485,6 +485,6 @@ module Invidious::Routes::API::V1::Authenticated
topics = raw_topics.try &.split(",").uniq.first(1000)
topics ||= [] of String
Helpers.create_notification_stream(env, topics, CONNECTION_CHANNEL)
create_notification_stream(env, topics, CONNECTION_CHANNEL)
end
end
+2 -2
View File
@@ -97,14 +97,13 @@ module Invidious::Routes::API::V1::Channels
json.field "autoGenerated", channel.auto_generated
json.field "ageGated", channel.is_age_gated
json.field "isFamilyFriendly", channel.is_family_friendly
json.field "description", Helpers.html_to_content(channel.description_html)
json.field "description", html_to_content(channel.description_html)
json.field "descriptionHtml", channel.description_html
json.field "allowedRegions", channel.allowed_regions
json.field "tabs", channel.tabs
json.field "tags", channel.tags
json.field "authorVerified", channel.verified
json.field "pronouns", channel.pronouns
json.field "latestVideos" do
json.array do
@@ -128,6 +127,7 @@ module Invidious::Routes::API::V1::Channels
end
end
end # relatedChannels
end
end
end
+3 -6
View File
@@ -1,9 +1,6 @@
require "html"
module Invidious::Routes::API::V1::Videos
private INTERNET_ARCHIVE_URL = URI.parse("https://archive.org")
private CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
def self.videos(env)
locale = env.get("preferences").as(Preferences).locale
@@ -282,7 +279,7 @@ module Invidious::Routes::API::V1::Videos
file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml")
location = make_client(INTERNET_ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}"))
location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}"))
if !location.headers["Location"]?
env.response.status_code = location.status_code
@@ -300,7 +297,7 @@ module Invidious::Routes::API::V1::Videos
annotations = response.body
Helpers.cache_annotation(id, annotations)
cache_annotation(id, annotations)
end
else # "youtube"
response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}")
@@ -410,7 +407,7 @@ module Invidious::Routes::API::V1::Videos
clip_title = nil
if params = response.dig?("endpoint", "watchEndpoint", "params").try &.as_s
start_time, end_time, clip_title = Invidious::Videos::Clip.parse_clip_parameters(params)
start_time, end_time, clip_title = parse_clip_parameters(params)
end
begin
+5 -19
View File
@@ -1,16 +1,4 @@
module Invidious::Routes::BeforeAll
struct CompanionCSP
property companion_urls : String = ""
def initialize
self.companion_urls = CONFIG.invidious_companion.reject(&.builtin_proxy).map do |companion|
"#{companion.public_url.scheme}://#{companion.public_url.host}#{companion.public_url.port ? ":#{companion.public_url.port}" : ""}"
end.join(" ")
end
end
private COMPANION_CSP = CompanionCSP.new
def self.handle(env)
preferences = Preferences.from_json("{}")
@@ -19,7 +7,7 @@ module Invidious::Routes::BeforeAll
preferences = Preferences.from_json(URI.decode_www_form(prefs_cookie.value))
else
if language_header = env.request.headers["Accept-Language"]?
if language = ANG.language_negotiator.best(language_header, I18n::LOCALES.keys)
if language = ANG.language_negotiator.best(language_header, LOCALES.keys)
preferences.locale = language.header
end
end
@@ -32,8 +20,6 @@ module Invidious::Routes::BeforeAll
env.response.headers["X-XSS-Protection"] = "1; mode=block"
env.response.headers["X-Content-Type-Options"] = "nosniff"
env.set "header_x-forwarded-host", env.request.headers["X-Forwarded-Host"]?
# Only allow the pages at /embed/* to be embedded
if env.request.resource.starts_with?("/embed")
frame_ancestors = "'self' file: http: https:"
@@ -49,9 +35,9 @@ module Invidious::Routes::BeforeAll
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'self' " + COMPANION_CSP.companion_urls,
"connect-src 'self'",
"manifest-src 'self'",
"media-src 'self' blob: " + COMPANION_CSP.companion_urls,
"media-src 'self' blob:",
"child-src 'self' blob:",
"frame-src 'self'",
"frame-ancestors " + frame_ancestors,
@@ -108,8 +94,8 @@ module Invidious::Routes::BeforeAll
end
dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s
thin_mode = env.params.query["thin_mode"]?
thin_mode = (thin_mode == "true") || preferences.thin_mode
thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode.to_s
thin_mode = thin_mode == "true"
locale = env.params.query["hl"]? || preferences.locale
preferences.dark_mode = dark_mode
+4 -6
View File
@@ -231,10 +231,8 @@ module Invidious::Routes::Channels
env.redirect "/post/#{URI.encode_www_form(lb)}?ucid=#{URI.encode_www_form(ucid)}"
end
preferences = env.get("preferences").as(Preferences)
thin_mode = env.params.query["thin_mode"]?
thin_mode = (thin_mode == "true") || preferences.thin_mode
thin_mode = env.params.query["thin_mode"]? || env.get("preferences").as(Preferences).thin_mode
thin_mode = thin_mode == "true"
continuation = env.params.query["continuation"]?
@@ -351,10 +349,10 @@ module Invidious::Routes::Channels
invidious_url_params.delete_all("user")
begin
resolved_url = YoutubeAPI.resolve_url("https://www.youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}")
resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}")
ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"]
rescue ex : InfoException | KeyError
return error_template(404, I18n.translate(locale, "This channel does not exist."))
return error_template(404, translate(locale, "This channel does not exist."))
end
selected_tab = env.params.url["tab"]?
+14 -3
View File
@@ -10,7 +10,7 @@ module Invidious::Routes::Embed
videos = get_playlist_videos(playlist, offset: offset)
if videos.empty?
url = "/playlist?list=#{plid}"
raise NotFoundException.new(I18n.translate(locale, "error_video_not_in_playlist", url))
raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url))
end
first_playlist_video = videos[0].as(PlaylistVideo)
@@ -71,7 +71,7 @@ module Invidious::Routes::Embed
videos = get_playlist_videos(playlist, offset: offset)
if videos.empty?
url = "/playlist?list=#{plid}"
raise NotFoundException.new(I18n.translate(locale, "error_video_not_in_playlist", url))
raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url))
end
first_playlist_video = videos[0].as(PlaylistVideo)
@@ -122,7 +122,7 @@ module Invidious::Routes::Embed
else nil # Continue
end
params = Invidious::Videos.process_video_params(env.params.query, preferences)
params = process_video_params(env.params.query, preferences)
user = env.get?("user").try &.as(User)
if user
@@ -208,6 +208,17 @@ module Invidious::Routes::Embed
if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
invidious_companion_urls = CONFIG.invidious_companion.reject(&.builtin_proxy).map do |companion|
uri =
"#{companion.public_url.scheme}://#{companion.public_url.host}#{companion.public_url.port ? ":#{companion.public_url.port}" : ""}"
end.join(" ")
if !invidious_companion_urls.empty?
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src #{invidious_companion_urls}")
.gsub("connect-src", "connect-src #{invidious_companion_urls}")
end
end
rendered "embed"
+1 -1
View File
@@ -8,7 +8,7 @@ module Invidious::Routes::ErrorRoutes
if md = env.request.path.match(/^\/(?<id>([a-zA-Z0-9_-]{11})|(\w+))$/)
item = md["id"]
# Check if item is branding URL e.g. https://www.youtube.com/gaming
# Check if item is branding URL e.g. https://youtube.com/gaming
response = YT_POOL.client &.get("/#{item}")
if response.status_code == 301
+3 -3
View File
@@ -37,7 +37,7 @@ module Invidious::Routes::Feeds
if CONFIG.popular_enabled
templated "feeds/popular"
else
message = I18n.translate(locale, "The Popular feed has been disabled by the administrator.")
message = translate(locale, "The Popular feed has been disabled by the administrator.")
templated "message"
end
end
@@ -259,7 +259,7 @@ module Invidious::Routes::Feeds
xml.element("link", "type": "text/html", rel: "alternate", href: "#{HOST_URL}/feed/subscriptions")
xml.element("link", "type": "application/atom+xml", rel: "self",
href: "#{HOST_URL}#{env.request.resource}")
xml.element("title") { xml.text I18n.translate(locale, "Invidious Private Feed for `x`", user.email) }
xml.element("title") { xml.text translate(locale, "Invidious Private Feed for `x`", user.email) }
(notifications + videos).each do |video|
video.to_xml(locale, params, xml)
@@ -320,7 +320,7 @@ module Invidious::Routes::Feeds
case attribute.name
when "url", "href"
request_target = URI.parse(node[attribute.name]).request_target
query_string_opt = request_target.starts_with?("/watch?v=") ? ("&#{params}" if !params.empty?) : ""
query_string_opt = request_target.starts_with?("/watch?v=") ? "&#{params}" : ""
node[attribute.name] = "#{HOST_URL}#{request_target}#{query_string_opt}"
else nil # Skip
end
+4 -14
View File
@@ -51,7 +51,7 @@ module Invidious::Routes::Images
end
# ??? maybe also for storyboards?
def self.s_p_image(env, authority = "i9")
def self.s_p_image(env)
id = env.params.url["id"]
name = env.params.url["name"]
url = env.request.resource
@@ -65,23 +65,13 @@ module Invidious::Routes::Images
end
begin
get_ytimg_pool(authority).client &.get(url, headers) do |resp|
get_ytimg_pool("i9").client &.get(url, headers) do |resp|
return self.proxy_image(env, resp)
end
rescue ex
end
end
# Both pl_c and tvfilm_banner use the same logic used in s_p_image(env)
# just with a different authority ("i").
def self.pl_c_image(env)
self.s_p_image(env, "i")
end
def self.tvfilm_banner_image(env)
self.s_p_image(env, "i")
end
def self.yts_image(env)
headers = HTTP::Headers.new
REQUEST_HEADERS_WHITELIST.each do |header|
@@ -106,7 +96,7 @@ module Invidious::Routes::Images
break
end
Helpers.proxy_file(response, env)
proxy_file(response, env)
end
rescue ex
end
@@ -158,6 +148,6 @@ module Invidious::Routes::Images
return env.response.headers.delete("Transfer-Encoding")
end
return Helpers.proxy_file(response, env)
return proxy_file(response, env)
end
end
+3 -12
View File
@@ -26,7 +26,6 @@ module Invidious::Routes::Login
def self.login(env)
locale = env.get("preferences").as(Preferences).locale
host = env.get("header_x-forwarded-host")
referer = get_referer(env, "/feed/subscriptions")
@@ -58,11 +57,7 @@ module Invidious::Routes::Login
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
Invidious::Database::SessionIDs.insert(sid, email)
if alt = CONFIG.alternative_domains.index(host)
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
else
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
end
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
else
return error_template(401, "Wrong username or password")
end
@@ -117,7 +112,7 @@ module Invidious::Routes::Login
user, sid = create_user(sid, email, password)
if language_header = env.request.headers["Accept-Language"]?
if language = ANG.language_negotiator.best(language_header, I18n::LOCALES.keys)
if language = ANG.language_negotiator.best(language_header, LOCALES.keys)
user.preferences.locale = language.header
end
end
@@ -128,11 +123,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 alt = CONFIG.alternative_domains.index(host)
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
else
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
end
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
if env.request.cookies["PREFS"]?
user.preferences = env.get("preferences").as(Preferences)
+2 -17
View File
@@ -145,10 +145,6 @@ module Invidious::Routes::PreferencesRoute
default_playlist = env.params.body["default_playlist"]?.try &.as(String)
search_privacy = env.params.body["search_privacy"]?.try &.as(String)
search_privacy ||= "off"
search_privacy = search_privacy == "on"
# Convert to JSON and back again to take advantage of converters used for compatibility
preferences = Preferences.from_json({
annotations: annotations,
@@ -186,7 +182,6 @@ module Invidious::Routes::PreferencesRoute
show_nick: show_nick,
save_player_pos: save_player_pos,
default_playlist: default_playlist,
search_privacy: search_privacy,
}.to_json)
if user = env.get? "user"
@@ -231,12 +226,7 @@ module Invidious::Routes::PreferencesRoute
File.write("config/config.yml", CONFIG.to_yaml)
end
else
host = env.get("header_x-forwarded-host")
if alt = CONFIG.alternative_domains.index(host)
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences)
else
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
end
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
end
env.redirect referer
@@ -271,12 +261,7 @@ module Invidious::Routes::PreferencesRoute
preferences.dark_mode = "dark"
end
host = env.get("header_x-forwarded-host")
if alt = CONFIG.alternative_domains.index(host)
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences)
else
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
end
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
end
if redirect
-20
View File
@@ -1,20 +0,0 @@
module Invidious::Routes
private REQUEST_HEADERS_WHITELIST = {
"accept",
"accept-encoding",
"cache-control",
"content-length",
"if-none-match",
"range",
}
private RESPONSE_HEADERS_BLACKLIST = {
"access-control-allow-origin",
"alt-svc",
"server",
"cross-origin-opener-policy-report-only",
"report-to",
"cross-origin",
"timing-allow-origin",
"cross-origin-resource-policy",
}
end
+2 -9
View File
@@ -40,16 +40,9 @@ module Invidious::Routes::Search
preferences = env.get("preferences").as(Preferences)
locale = preferences.locale
uri_params = URI::Params.new
if env.request.method == "GET"
uri_params = env.params.query
else
uri_params = env.params.body
end
region = env.params.query["region"]? || preferences.region
region = uri_params["region"]? || preferences.region
query = Invidious::Search::Query.new(uri_params, :regular, region)
query = Invidious::Search::Query.new(env.params.query, :regular, region)
if query.empty?
# Display the full page search box implemented in #1977
+2 -4
View File
@@ -1,6 +1,4 @@
module Invidious::Routes::VideoPlayback
private HTTP_CHUNK_SIZE = 10485760 # ~10MB
# /videoplayback
def self.get_video_playback(env)
locale = env.get("preferences").as(Preferences).locale
@@ -83,7 +81,7 @@ module Invidious::Routes::VideoPlayback
# Remove the Range header added previously.
headers.delete("Range") if range_header.nil?
playback_statistics = Helpers.get_playback_statistic
playback_statistics = get_playback_statistic()
playback_statistics["totalRequests"] += 1
if response.status_code >= 400
@@ -195,7 +193,7 @@ module Invidious::Routes::VideoPlayback
end
end
Helpers.proxy_file(resp, env)
proxy_file(resp, env)
end
rescue ex
if ex.message != "Error reading socket: Connection reset by peer"
+24 -16
View File
@@ -47,7 +47,7 @@ module Invidious::Routes::Watch
end
subscriptions ||= [] of String
params = Invidious::Videos.process_video_params(env.params.query, preferences)
params = process_video_params(env.params.query, preferences)
env.params.query.delete_all("listen")
begin
@@ -129,20 +129,17 @@ module Invidious::Routes::Watch
video_streams = video.video_streams
audio_streams = video.audio_streams
# Videos that are a premiere do not have audio streams.
if video.premiere_timestamp.nil?
# Older videos may not have audio sources available.
# We redirect here so they're not unplayable
if audio_streams.empty? && !video.live_now
if params.quality == "dash"
env.params.query.delete_all("quality")
env.params.query["quality"] = "medium"
return env.redirect "/watch?#{env.params.query}"
elsif params.listen
env.params.query.delete_all("listen")
env.params.query["listen"] = "0"
return env.redirect "/watch?#{env.params.query}"
end
# Older videos may not have audio sources available.
# We redirect here so they're not unplayable
if audio_streams.empty? && !video.live_now
if params.quality == "dash"
env.params.query.delete_all("quality")
env.params.query["quality"] = "medium"
return env.redirect "/watch?#{env.params.query}"
elsif params.listen
env.params.query.delete_all("listen")
env.params.query["listen"] = "0"
return env.redirect "/watch?#{env.params.query}"
end
end
@@ -196,6 +193,17 @@ module Invidious::Routes::Watch
if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
invidious_companion_urls = CONFIG.invidious_companion.reject(&.builtin_proxy).map do |companion|
uri =
"#{companion.public_url.scheme}://#{companion.public_url.host}#{companion.public_url.port ? ":#{companion.public_url.port}" : ""}"
end.join(" ")
if !invidious_companion_urls.empty?
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src #{invidious_companion_urls}")
.gsub("connect-src", "connect-src #{invidious_companion_urls}")
end
end
templated "watch"
@@ -276,7 +284,7 @@ module Invidious::Routes::Watch
if video_id = response.dig?("endpoint", "watchEndpoint", "videoId")
if params = response.dig?("endpoint", "watchEndpoint", "params").try &.as_s
start_time, end_time, _ = Invidious::Videos::Clip.parse_clip_parameters(params)
start_time, end_time, _ = parse_clip_parameters(params)
env.params.query["start"] = start_time.to_s if start_time != nil
env.params.query["end"] = end_time.to_s if end_time != nil
end
-3
View File
@@ -185,7 +185,6 @@ module Invidious::Routing
get "/opensearch.xml", Routes::Search, :opensearch
get "/results", Routes::Search, :results
get "/search", Routes::Search, :search
post "/search", Routes::Search, :search
get "/hashtag/:hashtag", Routes::Search, :hashtag
end
@@ -223,8 +222,6 @@ module Invidious::Routing
get "/s_p/:id/:name", Routes::Images, :s_p_image
get "/yts/img/:name", Routes::Images, :yts_image
get "/vi/:id/:name", Routes::Images, :thumbnails
get "/pl_c/:id/:name", Routes::Images, :pl_c_image
get "/tvfilm_banner/:id/:name", Routes::Images, :tvfilm_banner_image
end
def register_companion_routes
+2
View File
@@ -57,6 +57,8 @@ module Invidious::Search
# Values correspond to { "1:varint": <X> }
enum Sort
Relevance = 0
Rating = 1
Date = 2
Views = 3
end
+1 -1
View File
@@ -21,7 +21,7 @@ module Invidious::Search
if response.status_code == 404
response = YT_POOL.client &.get("/user/#{query.channel}")
response = YT_POOL.client &.get("/c/#{query.channel}") if response.status_code == 404
initial_data = Helpers.extract_initial_data(response.body)
initial_data = extract_initial_data(response.body)
ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?)
raise ChannelSearchException.new(query.channel) if !ucid
else
+3 -17
View File
@@ -6,24 +6,17 @@ struct Invidious::User
# Note: we use ternary operator because the two variables
# used in here are not booleans.
@@secure = (Kemal.config.ssl || CONFIG.https_only) ? true : false
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
# Not secure if it's being accessed from I2P
# Browsers expect the domain to include https. On I2P there is no HTTPS
# Tor browser works fine with secure being true
if (domain.try &.split(".").last == "i2p") && @@secure
@@secure = false
end
return HTTP::Cookie.new(
name: "SID",
domain: domain,
value: sid,
expires: Time.utc + 2.years,
secure: @@secure,
secure: SECURE,
http_only: true,
samesite: HTTP::Cookie::SameSite::Lax
)
@@ -32,19 +25,12 @@ struct Invidious::User
# Preferences (PREFS) cookie
# Parameter "domain" comes from the global config
def prefs(domain : String?, preferences : Preferences) : HTTP::Cookie
# Not secure if it's being accessed from I2P
# Browsers expect the domain to include https. On I2P there is no HTTPS
# Tor browser works fine with secure being true
if (domain.try &.split(".").last == "i2p") && @@secure
@@secure = false
end
return HTTP::Cookie.new(
name: "PREFS",
domain: domain,
value: URI.encode_www_form(preferences.to_json),
expires: Time.utc + 2.years,
secure: @@secure,
secure: SECURE,
http_only: false,
samesite: HTTP::Cookie::SameSite::Lax
)
+1 -1
View File
@@ -15,7 +15,7 @@ struct Invidious::User
playlists.each do |playlist|
json.object do
json.field "title", playlist.title
json.field "description", Helpers.html_to_content(playlist.description_html)
json.field "description", html_to_content(playlist.description_html)
json.field "privacy", playlist.privacy.to_s
json.field "videos" do
json.array do
+13 -11
View File
@@ -30,24 +30,28 @@ struct Invidious::User
return subscriptions
end
# Parse a CSV Google Takeout - Youtube Playlist file
def parse_playlist_export_csv(user : User, playlist_name : String, raw_input : String)
def parse_playlist_export_csv(user : User, raw_input : String)
# Split the input into head and body content
raw_head, raw_body = raw_input.split("\n", limit: 2, remove_empty: true)
raw_head, raw_body = raw_input.strip('\n').split("\n\n", limit: 2, remove_empty: true)
# Create the playlist from the head content
csv_head = CSV.new(raw_head.strip('\n'), headers: true)
csv_head.next
title = playlist_name
title = csv_head[4]
description = csv_head[5]
visibility = csv_head[6]
description = "This is the default description of an imported playlist. Feel Free to change it as you see fit."
privacy = PlaylistPrivacy::Private
if visibility.compare("Public", case_insensitive: true) == 0
privacy = PlaylistPrivacy::Public
else
privacy = PlaylistPrivacy::Private
end
playlist = create_playlist(title, privacy, user)
Invidious::Database::Playlists.update_description(playlist.id, description)
# Add each video to the playlist from the body content
csv_body = CSV.new(raw_body.strip('\n'), headers: false)
csv_body = CSV.new(raw_body.strip('\n'), headers: true)
csv_body.each do |row|
video_id = row[0]
if playlist
@@ -200,12 +204,10 @@ struct Invidious::User
end
def from_youtube_pl(user : User, body : String, filename : String, type : String) : Bool
filename_array = filename.split(".")
playlist_name = filename_array.first
extension = filename_array.last
extension = filename.split(".").last
if extension == "csv" || type == "text/csv"
playlist = parse_playlist_export_csv(user, playlist_name, body)
playlist = parse_playlist_export_csv(user, body)
if playlist
return true
else
-1
View File
@@ -57,7 +57,6 @@ struct Preferences
property volume : Int32 = CONFIG.default_user_preferences.volume
property save_player_pos : Bool = CONFIG.default_user_preferences.save_player_pos
property default_playlist : String? = nil
property search_privacy : Bool = CONFIG.default_user_preferences.search_privacy
module BoolToString
def self.to_json(value : String, json : JSON::Builder)
+4 -5
View File
@@ -81,10 +81,9 @@ struct Video
end
def premiere_timestamp : Time?
if self.video_type == VideoType::Scheduled
return info["published"]?
.try { |t| Time.parse_rfc3339(t.as_s) }
end
info
.dig?("microformat", "playerMicroformatRenderer", "liveBroadcastDetails", "startTimestamp")
.try { |t| Time.parse_rfc3339(t.as_s) }
end
def related_videos
@@ -325,7 +324,7 @@ rescue DB::Error
end
def fetch_video(id, region)
info = Invidious::Videos::Parser.extract_video_info(video_id: id)
info = extract_video_info(video_id: id)
if info.nil?
raise InfoException.new("Invidious companion is not available. \
+15 -19
View File
@@ -1,26 +1,22 @@
require "json"
module Invidious::Videos::Clip
extend self
# returns start_time, end_time and clip_title
def parse_clip_parameters(params) : {Float64?, Float64?, String?}
decoded_protobuf = params.try { |i| URI.decode_www_form(i) }
.try { |i| Base64.decode(i) }
.try { |i| IO::Memory.new(i) }
.try { |i| Protodec::Any.parse(i) }
# returns start_time, end_time and clip_title
def parse_clip_parameters(params) : {Float64?, Float64?, String?}
decoded_protobuf = params.try { |i| URI.decode_www_form(i) }
.try { |i| Base64.decode(i) }
.try { |i| IO::Memory.new(i) }
.try { |i| Protodec::Any.parse(i) }
start_time = decoded_protobuf
.try(&.["50:0:embedded"]["2:1:varint"].as_i64)
.try { |i| i/1000 }
start_time = decoded_protobuf
.try(&.["50:0:embedded"]["2:1:varint"].as_i64)
.try { |i| i/1000 }
end_time = decoded_protobuf
.try(&.["50:0:embedded"]["3:2:varint"].as_i64)
.try { |i| i/1000 }
end_time = decoded_protobuf
.try(&.["50:0:embedded"]["3:2:varint"].as_i64)
.try { |i| i/1000 }
clip_title = decoded_protobuf
.try(&.["50:0:embedded"]["4:3:string"].as_s)
clip_title = decoded_protobuf
.try(&.["50:0:embedded"]["4:3:string"].as_s)
return start_time, end_time, clip_title
end
return start_time, end_time, clip_title
end
+6
View File
@@ -21,6 +21,8 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
str << cp.chr
end
# A codepoint from the SMP counts twice
copied += 1 if cp > 0xFFFF
copied += 1
end
@@ -42,6 +44,10 @@ def parse_description(desc, video_id : String) : String?
end
end
# Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
# (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are
# automatically decoded by the JSON parser. It means that we need to count
# copied byte in a special manner, preventing the use of regular string copy.
iter = content.each_codepoint
index = 0
+417 -421
View File
@@ -1,469 +1,465 @@
require "json"
module Invidious::Videos::Parser
extend self
# Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer".
# The former is preferred as it has more videos in it. The second has
# the same 11 first entries as the compact rendered.
#
# TODO: "compactRadioRenderer" (Mix) and
# TODO: Use a proper struct/class instead of a hacky JSON object
def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
return nil if !related["videoId"]?
# Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer".
# The former is preferred as it has more videos in it. The second has
# the same 11 first entries as the compact rendered.
#
# TODO: "compactRadioRenderer" (Mix) and
# TODO: Use a proper struct/class instead of a hacky JSON object
def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
return nil if !related["videoId"]?
# The compact renderer has video length in seconds, where the end
# screen rendered has a full text version ("42:40")
length = related["lengthInSeconds"]?.try &.as_i.to_s
length ||= related.dig?("lengthText", "simpleText").try do |box|
decode_length_seconds(box.as_s).to_s
end
# Both have "short", so the "long" option shouldn't be required
channel_info = (related["shortBylineText"]? || related["longBylineText"]?)
.try &.dig?("runs", 0)
author = channel_info.try &.dig?("text")
author_verified = has_verified_badge?(related["ownerBadges"]?).to_s
ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) }
short_view_count = related.try do |r|
HelperExtractors.get_short_view_count(r).to_s
end
LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container")
if published_time_text = related["publishedTimeText"]?
decoded_time = decode_date(published_time_text["simpleText"].to_s)
published = decoded_time.to_rfc3339.to_s
else
published = nil
end
# TODO: when refactoring video types, make a struct for related videos
# or reuse an existing type, if that fits.
return {
"id" => related["videoId"],
"title" => related["title"]["simpleText"],
"author" => author || JSON::Any.new(""),
"ucid" => JSON::Any.new(ucid || ""),
"length_seconds" => JSON::Any.new(length || "0"),
"short_view_count" => JSON::Any.new(short_view_count || "0"),
"author_verified" => JSON::Any.new(author_verified),
"published" => JSON::Any.new(published || ""),
}
# The compact renderer has video length in seconds, where the end
# screen rendered has a full text version ("42:40")
length = related["lengthInSeconds"]?.try &.as_i.to_s
length ||= related.dig?("lengthText", "simpleText").try do |box|
decode_length_seconds(box.as_s).to_s
end
def extract_video_info(video_id : String)
# Fetch data from the player endpoint
player_response = YoutubeAPI.player(video_id: video_id)
# Both have "short", so the "long" option shouldn't be required
channel_info = (related["shortBylineText"]? || related["longBylineText"]?)
.try &.dig?("runs", 0)
if player_response.nil?
return nil
end
author = channel_info.try &.dig?("text")
author_verified = has_verified_badge?(related["ownerBadges"]?).to_s
playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) }
if playability_status != "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
short_view_count = related.try do |r|
HelperExtractors.get_short_view_count(r).to_s
end
# Stop here if video is not a scheduled livestream or
# for LOGIN_REQUIRED when videoDetails element is not found because retrying won't help
if !{"LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) ||
playability_status == "LOGIN_REQUIRED" && !player_response.dig?("videoDetails")
return {
"version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64),
"reason" => JSON::Any.new(reason),
}
end
elsif video_id != player_response.dig?("videoDetails", "videoId")
# YouTube may return a different video player response than expected.
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
# Line to be reverted if one day we solve the video not available issue.
LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container")
# Although technically not a call to /videoplayback the fact that YouTube is returning the
# wrong video means that we should count it as a failure.
Helpers.get_playback_statistic["totalRequests"] += 1
if published_time_text = related["publishedTimeText"]?
decoded_time = decode_date(published_time_text["simpleText"].to_s)
published = decoded_time.to_rfc3339.to_s
else
published = nil
end
# TODO: when refactoring video types, make a struct for related videos
# or reuse an existing type, if that fits.
return {
"id" => related["videoId"],
"title" => related["title"]["simpleText"],
"author" => author || JSON::Any.new(""),
"ucid" => JSON::Any.new(ucid || ""),
"length_seconds" => JSON::Any.new(length || "0"),
"short_view_count" => JSON::Any.new(short_view_count || "0"),
"author_verified" => JSON::Any.new(author_verified),
"published" => JSON::Any.new(published || ""),
}
end
def extract_video_info(video_id : String)
# Fetch data from the player endpoint
player_response = YoutubeAPI.player(video_id: video_id)
if player_response.nil?
return nil
end
playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
if playability_status != "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
# Stop here if video is not a scheduled livestream or
# for LOGIN_REQUIRED when videoDetails element is not found because retrying won't help
if !{"LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) ||
playability_status == "LOGIN_REQUIRED" && !player_response.dig?("videoDetails")
return {
"version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64),
"reason" => JSON::Any.new("Can't load the video on this Invidious instance. YouTube is currently trying to block Invidious instances. <a href=\"https://github.com/iv-org/invidious/issues/3822\">Click here for more info about the issue.</a>"),
"reason" => JSON::Any.new(reason),
}
else
reason = nil
end
elsif video_id != player_response.dig?("videoDetails", "videoId")
# YouTube may return a different video player response than expected.
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
# Line to be reverted if one day we solve the video not available issue.
# Don't fetch the next endpoint if the video is unavailable.
if {"OK", "LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status)
next_response = YoutubeAPI.next({"videoId": video_id, "params": ""})
# Remove the microformat returned by the /next endpoint on some videos
# to prevent player_response microformat from being overwritten.
next_response.delete("microformat")
player_response = player_response.merge(next_response)
end
# Although technically not a call to /videoplayback the fact that YouTube is returning the
# wrong video means that we should count it as a failure.
get_playback_statistic()["totalRequests"] += 1
params = self.parse_video_info(video_id, player_response)
params["reason"] = JSON::Any.new(reason) if reason
return {
"version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64),
"reason" => JSON::Any.new("Can't load the video on this Invidious instance. YouTube is currently trying to block Invidious instances. <a href=\"https://github.com/iv-org/invidious/issues/3822\">Click here for more info about the issue.</a>"),
}
else
reason = nil
end
{"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f|
params[f] = player_response[f] if player_response[f]?
end
# Don't fetch the next endpoint if the video is unavailable.
if {"OK", "LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status)
next_response = YoutubeAPI.next({"videoId": video_id, "params": ""})
# Remove the microformat returned by the /next endpoint on some videos
# to prevent player_response microformat from being overwritten.
next_response.delete("microformat")
player_response = player_response.merge(next_response)
end
# Convert URLs, if those are present
if streaming_data = player_response["streamingData"]?
%w[formats adaptiveFormats].each do |key|
streaming_data.as_h[key]?.try &.as_a.each do |format|
format = format.as_h
if format["url"]?.nil?
format["url"] = format["signatureCipher"]
end
format["url"] = JSON::Any.new(convert_url(format))
params = parse_video_info(video_id, player_response)
params["reason"] = JSON::Any.new(reason) if reason
{"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f|
params[f] = player_response[f] if player_response[f]?
end
# Convert URLs, if those are present
if streaming_data = player_response["streamingData"]?
%w[formats adaptiveFormats].each do |key|
streaming_data.as_h[key]?.try &.as_a.each do |format|
format = format.as_h
if format["url"]?.nil?
format["url"] = format["signatureCipher"]
end
format["url"] = JSON::Any.new(convert_url(format))
end
params["streamingData"] = streaming_data
end
# Data structure version, for cache control
params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64)
return params
params["streamingData"] = streaming_data
end
def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)?
LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.")
response = YoutubeAPI.player(video_id: id)
# Data structure version, for cache control
params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64)
playability_status = response["playabilityStatus"]["status"]
LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.")
return params
end
if id != response.dig?("videoDetails", "videoId")
# YouTube may return a different video player response than expected.
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
raise InfoException.new(
"The video returned by YouTube isn't the requested one. (#{client_config.client_type} client)"
)
elsif playability_status == "OK"
return response
else
return nil
def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)?
LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.")
response = YoutubeAPI.player(video_id: id)
playability_status = response["playabilityStatus"]["status"]
LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.")
if id != response.dig?("videoDetails", "videoId")
# YouTube may return a different video player response than expected.
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
raise InfoException.new(
"The video returned by YouTube isn't the requested one. (#{client_config.client_type} client)"
)
elsif playability_status == "OK"
return response
else
return nil
end
end
def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any)
# Top level elements
main_results = player_response.dig?("contents", "twoColumnWatchNextResults")
raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results
# Primary results are not available on Music videos
# See: https://github.com/iv-org/invidious/pull/3238#issuecomment-1207193725
if primary_results = main_results.dig?("results", "results", "contents")
video_primary_renderer = primary_results
.as_a.find(&.["videoPrimaryInfoRenderer"]?)
.try &.["videoPrimaryInfoRenderer"]
video_secondary_renderer = primary_results
.as_a.find(&.["videoSecondaryInfoRenderer"]?)
.try &.["videoSecondaryInfoRenderer"]
raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer
raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer
end
video_details = player_response.dig?("videoDetails")
if !(microformat = player_response.dig?("microformat", "playerMicroformatRenderer"))
microformat = {} of String => JSON::Any
end
raise BrokenTubeException.new("videoDetails") if !video_details
# Basic video infos
title = video_details["title"]?.try &.as_s
# We have to try to extract viewCount from videoPrimaryInfoRenderer first,
# then from videoDetails, as the latter is "0" for livestreams (we want
# to get the amount of viewers watching).
views_txt = extract_text(
video_primary_renderer
.try &.dig?("viewCount", "videoViewCountRenderer", "viewCount")
)
views_txt ||= video_details["viewCount"]?.try &.as_s || ""
views = views_txt.gsub(/\D/, "").to_i64?
length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"])
.try &.as_s.to_i64
published = microformat["publishDate"]?
.try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc
premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp")
.try { |t| Time.parse_rfc3339(t.as_s) }
premiere_timestamp ||= player_response.dig?(
"playabilityStatus", "liveStreamability",
"liveStreamabilityRenderer", "offlineSlate",
"liveStreamOfflineSlateRenderer", "scheduledStartTime"
)
.try &.as_s.to_i64
.try { |t| Time.unix(t) }
live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow")
.try &.as_bool
live_now ||= video_details.dig?("isLive").try &.as_bool || false
post_live_dvr = video_details.dig?("isPostLiveDvr")
.try &.as_bool || false
# Extra video infos
allowed_regions = microformat["availableCountries"]?
.try &.as_a.map &.as_s || [] of String
allow_ratings = video_details["allowRatings"]?.try &.as_bool
family_friendly = microformat["isFamilySafe"]?.try &.as_bool
is_listed = video_details["isCrawlable"]?.try &.as_bool
is_upcoming = video_details["isUpcoming"]?.try &.as_bool
keywords = video_details["keywords"]?
.try &.as_a.map &.as_s || [] of String
# Related videos
LOGGER.debug("extract_video_info: parsing related videos...")
related = [] of JSON::Any
# Parse "compactVideoRenderer" items (under secondary results)
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
end
end
def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any)
# Top level elements
main_results = player_response.dig?("contents", "twoColumnWatchNextResults")
raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results
# Primary results are not available on Music videos
# See: https://github.com/iv-org/invidious/pull/3238#issuecomment-1207193725
if primary_results = main_results.dig?("results", "results", "contents")
video_primary_renderer = primary_results
.as_a.find(&.["videoPrimaryInfoRenderer"]?)
.try &.["videoPrimaryInfoRenderer"]
video_secondary_renderer = primary_results
.as_a.find(&.["videoSecondaryInfoRenderer"]?)
.try &.["videoSecondaryInfoRenderer"]
raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer
raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer
end
video_details = player_response.dig?("videoDetails")
if !(microformat = player_response.dig?("microformat", "playerMicroformatRenderer"))
microformat = {} of String => JSON::Any
end
raise BrokenTubeException.new("videoDetails") if !video_details
# Basic video infos
title = video_details["title"]?.try &.as_s
# We have to try to extract viewCount from videoPrimaryInfoRenderer first,
# then from videoDetails, as the latter is "0" for livestreams (we want
# to get the amount of viewers watching).
views_txt = extract_text(
video_primary_renderer
.try &.dig?("viewCount", "videoViewCountRenderer", "viewCount")
# If nothing was found previously, fall back to end screen renderer
if related.empty?
# Container for "endScreenVideoRenderer" items
player_overlays = player_response.dig?(
"playerOverlays", "playerOverlayRenderer",
"endScreen", "watchNextEndScreenRenderer", "results"
)
views_txt ||= video_details["viewCount"]?.try &.as_s || ""
views = views_txt.gsub(/\D/, "").to_i64?
length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"])
.try &.as_s.to_i64
published = microformat["publishDate"]?
.try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc
premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp")
.try { |t| Time.parse_rfc3339(t.as_s) }
premiere_timestamp ||= player_response.dig?(
"playabilityStatus", "liveStreamability",
"liveStreamabilityRenderer", "offlineSlate",
"liveStreamOfflineSlateRenderer", "scheduledStartTime"
)
.try &.as_s.to_i64
.try { |t| Time.unix(t) }
live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow")
.try &.as_bool
live_now ||= video_details.dig?("isLive").try &.as_bool || false
post_live_dvr = video_details.dig?("isPostLiveDvr")
.try &.as_bool || false
# Extra video infos
allowed_regions = microformat["availableCountries"]?
.try &.as_a.map &.as_s || [] of String
allow_ratings = video_details["allowRatings"]?.try &.as_bool
family_friendly = microformat["isFamilySafe"]?.try &.as_bool
is_listed = video_details["isCrawlable"]?.try &.as_bool
is_upcoming = video_details["isUpcoming"]?.try &.as_bool
keywords = video_details["keywords"]?
.try &.as_a.map &.as_s || [] of String
# Related videos
LOGGER.debug("extract_video_info: parsing related videos...")
related = [] of JSON::Any
# Parse "compactVideoRenderer" items (under secondary results)
secondary_results = main_results
.dig?("secondaryResults", "secondaryResults", "results")
secondary_results.try &.as_a.each do |element|
if item = element["compactVideoRenderer"]?
related_video = self.parse_related_video(item)
player_overlays.try &.as_a.each do |element|
if item = element["endScreenVideoRenderer"]?
related_video = parse_related_video(item)
related << JSON::Any.new(related_video) if related_video
end
end
end
# If nothing was found previously, fall back to end screen renderer
if related.empty?
# Container for "endScreenVideoRenderer" items
player_overlays = player_response.dig?(
"playerOverlays", "playerOverlayRenderer",
"endScreen", "watchNextEndScreenRenderer", "results"
)
# Likes
player_overlays.try &.as_a.each do |element|
if item = element["endScreenVideoRenderer"]?
related_video = self.parse_related_video(item)
related << JSON::Any.new(related_video) if related_video
end
end
end
toplevel_buttons = video_primary_renderer
.try &.dig?("videoActions", "menuRenderer", "topLevelButtons")
# Likes
toplevel_buttons = video_primary_renderer
.try &.dig?("videoActions", "menuRenderer", "topLevelButtons")
if toplevel_buttons
# New Format as of december 2023
likes_button = toplevel_buttons.dig?(0,
"segmentedLikeDislikeButtonViewModel",
"likeButtonViewModel",
"likeButtonViewModel",
"toggleButtonViewModel",
"toggleButtonViewModel",
"defaultButtonViewModel",
"buttonViewModel"
)
likes_button ||= toplevel_buttons.try &.as_a
.find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE")
.try &.["toggleButtonRenderer"]
# New format as of september 2022
likes_button ||= toplevel_buttons.try &.as_a
.find(&.["segmentedLikeDislikeButtonRenderer"]?)
.try &.dig?(
"segmentedLikeDislikeButtonRenderer",
"likeButton", "toggleButtonRenderer"
)
if likes_button
likes_txt = likes_button.dig?("accessibilityText")
# Note: The like count from `toggledText` is off by one, as it would
# represent the new like count in the event where the user clicks on "like".
likes_txt ||= (likes_button["defaultText"]? || likes_button["toggledText"]?)
.try &.dig?("accessibility", "accessibilityData", "label")
likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt
LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"")
LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes
end
end
# Description
description = microformat.dig?("description", "simpleText").try &.as_s || ""
short_description = player_response.dig?("videoDetails", "shortDescription")
# description_html = video_secondary_renderer.try &.dig?("description", "runs")
# .try &.as_a.try { |t| content_to_comment_html(t, video_id) }
description_html = parse_description(video_secondary_renderer.try &.dig?("attributedDescription"), video_id)
# Video metadata
metadata = video_secondary_renderer
.try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows")
.try &.as_a
genre = microformat["category"]?
genre_ucid = nil
license = nil
metadata.try &.each do |row|
metadata_title = extract_text(row.dig?("metadataRowRenderer", "title"))
contents = row.dig?("metadataRowRenderer", "contents", 0)
if metadata_title == "Category"
contents = contents.try &.dig?("runs", 0)
genre = contents.try &.["text"]?
genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId")
elsif metadata_title == "License"
license = contents.try &.dig?("runs", 0, "text")
elsif metadata_title == "Licensed to YouTube by"
license = contents.try &.["simpleText"]?
end
end
# Music section
music_list = [] of VideoMusic
music_desclist = player_response.dig?(
"engagementPanels", 1, "engagementPanelSectionListRenderer",
"content", "structuredDescriptionContentRenderer", "items", 2,
"videoDescriptionMusicSectionRenderer", "carouselLockups"
if toplevel_buttons
# New Format as of december 2023
likes_button = toplevel_buttons.dig?(0,
"segmentedLikeDislikeButtonViewModel",
"likeButtonViewModel",
"likeButtonViewModel",
"toggleButtonViewModel",
"toggleButtonViewModel",
"defaultButtonViewModel",
"buttonViewModel"
)
music_desclist.try &.as_a.each do |music_desc|
artist = nil
album = nil
music_license = nil
likes_button ||= toplevel_buttons.try &.as_a
.find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE")
.try &.["toggleButtonRenderer"]
# Used when the video has multiple songs
if song_title = music_desc.dig?("carouselLockupRenderer", "videoLockup", "compactVideoRenderer", "title")
# "simpleText" for plain text / "runs" when song has a link
song = song_title["simpleText"]? || song_title.dig?("runs", 0, "text")
# New format as of september 2022
likes_button ||= toplevel_buttons.try &.as_a
.find(&.["segmentedLikeDislikeButtonRenderer"]?)
.try &.dig?(
"segmentedLikeDislikeButtonRenderer",
"likeButton", "toggleButtonRenderer"
)
# some videos can have empty tracks. See: https://www.youtube.com/watch?v=eBGIQ7ZuuiU
next if !song
end
if likes_button
likes_txt = likes_button.dig?("accessibilityText")
# Note: The like count from `toggledText` is off by one, as it would
# represent the new like count in the event where the user clicks on "like".
likes_txt ||= (likes_button["defaultText"]? || likes_button["toggledText"]?)
.try &.dig?("accessibility", "accessibilityData", "label")
likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt
music_desc.dig?("carouselLockupRenderer", "infoRows").try &.as_a.each do |desc|
desc_title = extract_text(desc.dig?("infoRowRenderer", "title"))
if desc_title == "ARTIST"
artist = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata"))
elsif desc_title == "SONG"
song = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata"))
elsif desc_title == "ALBUM"
album = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata"))
elsif desc_title == "LICENSES"
music_license = extract_text(desc.dig?("infoRowRenderer", "expandedMetadata"))
end
end
music_list << VideoMusic.new(song.to_s, album.to_s, artist.to_s, music_license.to_s)
LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"")
LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes
end
end
# Description
description = microformat.dig?("description", "simpleText").try &.as_s || ""
short_description = player_response.dig?("videoDetails", "shortDescription")
# description_html = video_secondary_renderer.try &.dig?("description", "runs")
# .try &.as_a.try { |t| content_to_comment_html(t, video_id) }
description_html = parse_description(video_secondary_renderer.try &.dig?("attributedDescription"), video_id)
# Video metadata
metadata = video_secondary_renderer
.try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows")
.try &.as_a
genre = microformat["category"]?
genre_ucid = nil
license = nil
metadata.try &.each do |row|
metadata_title = extract_text(row.dig?("metadataRowRenderer", "title"))
contents = row.dig?("metadataRowRenderer", "contents", 0)
if metadata_title == "Category"
contents = contents.try &.dig?("runs", 0)
genre = contents.try &.["text"]?
genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId")
elsif metadata_title == "License"
license = contents.try &.dig?("runs", 0, "text")
elsif metadata_title == "Licensed to YouTube by"
license = contents.try &.["simpleText"]?
end
end
# Music section
music_list = [] of VideoMusic
music_desclist = player_response.dig?(
"engagementPanels", 1, "engagementPanelSectionListRenderer",
"content", "structuredDescriptionContentRenderer", "items", 2,
"videoDescriptionMusicSectionRenderer", "carouselLockups"
)
music_desclist.try &.as_a.each do |music_desc|
artist = nil
album = nil
music_license = nil
# Used when the video has multiple songs
if song_title = music_desc.dig?("carouselLockupRenderer", "videoLockup", "compactVideoRenderer", "title")
# "simpleText" for plain text / "runs" when song has a link
song = song_title["simpleText"]? || song_title.dig?("runs", 0, "text")
# some videos can have empty tracks. See: https://www.youtube.com/watch?v=eBGIQ7ZuuiU
next if !song
end
music_desc.dig?("carouselLockupRenderer", "infoRows").try &.as_a.each do |desc|
desc_title = extract_text(desc.dig?("infoRowRenderer", "title"))
if desc_title == "ARTIST"
artist = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata"))
elsif desc_title == "SONG"
song = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata"))
elsif desc_title == "ALBUM"
album = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata"))
elsif desc_title == "LICENSES"
music_license = extract_text(desc.dig?("infoRowRenderer", "expandedMetadata"))
end
end
music_list << VideoMusic.new(song.to_s, album.to_s, artist.to_s, music_license.to_s)
end
# Author infos
author = video_details["author"]?.try &.as_s
ucid = video_details["channelId"]?.try &.as_s
if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer")
author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url")
author_verified = has_verified_badge?(author_info["badges"]?)
subs_text = author_info["subscriberCountText"]?
.try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }
.try &.as_s.split(" ", 2)[0]
end
# Return data
if live_now
video_type = VideoType::Livestream
elsif !premiere_timestamp.nil?
video_type = VideoType::Scheduled
published = premiere_timestamp || Time.utc
else
video_type = VideoType::Video
end
params = {
"videoType" => JSON::Any.new(video_type.to_s),
# Basic video infos
"title" => JSON::Any.new(title || ""),
"views" => JSON::Any.new(views || 0_i64),
"likes" => JSON::Any.new(likes || 0_i64),
"lengthSeconds" => JSON::Any.new(length_txt || 0_i64),
"published" => JSON::Any.new(published.to_rfc3339),
# Extra video infos
"allowedRegions" => JSON::Any.new(allowed_regions.map { |v| JSON::Any.new(v) }),
"allowRatings" => JSON::Any.new(allow_ratings || false),
"isFamilyFriendly" => JSON::Any.new(family_friendly || false),
"isListed" => JSON::Any.new(is_listed || false),
"isUpcoming" => JSON::Any.new(is_upcoming || false),
"keywords" => JSON::Any.new(keywords.map { |v| JSON::Any.new(v) }),
"isPostLiveDvr" => JSON::Any.new(post_live_dvr),
# Related videos
"relatedVideos" => JSON::Any.new(related),
# Description
"description" => JSON::Any.new(description || ""),
"descriptionHtml" => JSON::Any.new(description_html || "<p></p>"),
"shortDescription" => JSON::Any.new(short_description.try &.as_s || nil),
# Video metadata
"genre" => JSON::Any.new(genre.try &.as_s || ""),
"genreUcid" => JSON::Any.new(genre_ucid.try &.as_s?),
"license" => JSON::Any.new(license.try &.as_s || ""),
# Music section
"music" => JSON.parse(music_list.to_json),
# Author infos
"author" => JSON::Any.new(author || ""),
"ucid" => JSON::Any.new(ucid || ""),
"authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""),
"authorVerified" => JSON::Any.new(author_verified || false),
"subCountText" => JSON::Any.new(subs_text || "-"),
}
author = video_details["author"]?.try &.as_s
ucid = video_details["channelId"]?.try &.as_s
if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer")
author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url")
author_verified = has_verified_badge?(author_info["badges"]?)
subs_text = author_info["subscriberCountText"]?
.try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }
.try &.as_s.split(" ", 2)[0]
end
# Return data
if live_now
video_type = VideoType::Livestream
elsif !premiere_timestamp.nil?
video_type = VideoType::Scheduled
published = premiere_timestamp || Time.utc
else
video_type = VideoType::Video
end
params = {
"videoType" => JSON::Any.new(video_type.to_s),
# Basic video infos
"title" => JSON::Any.new(title || ""),
"views" => JSON::Any.new(views || 0_i64),
"likes" => JSON::Any.new(likes || 0_i64),
"lengthSeconds" => JSON::Any.new(length_txt || 0_i64),
"published" => JSON::Any.new(published.to_rfc3339),
# Extra video infos
"allowedRegions" => JSON::Any.new(allowed_regions.map { |v| JSON::Any.new(v) }),
"allowRatings" => JSON::Any.new(allow_ratings || false),
"isFamilyFriendly" => JSON::Any.new(family_friendly || false),
"isListed" => JSON::Any.new(is_listed || false),
"isUpcoming" => JSON::Any.new(is_upcoming || false),
"keywords" => JSON::Any.new(keywords.map { |v| JSON::Any.new(v) }),
"isPostLiveDvr" => JSON::Any.new(post_live_dvr),
# Related videos
"relatedVideos" => JSON::Any.new(related),
# Description
"description" => JSON::Any.new(description || ""),
"descriptionHtml" => JSON::Any.new(description_html || "<p></p>"),
"shortDescription" => JSON::Any.new(short_description.try &.as_s || nil),
# Video metadata
"genre" => JSON::Any.new(genre.try &.as_s || ""),
"genreUcid" => JSON::Any.new(genre_ucid.try &.as_s?),
"license" => JSON::Any.new(license.try &.as_s || ""),
# Music section
"music" => JSON.parse(music_list.to_json),
# Author infos
"author" => JSON::Any.new(author || ""),
"ucid" => JSON::Any.new(ucid || ""),
"authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""),
"authorVerified" => JSON::Any.new(author_verified || false),
"subCountText" => JSON::Any.new(subs_text || "-"),
}
return params
end
private def convert_url(fmt)
if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) }
url = URI.parse(cfr["url"])
params = url.query_params
LOGGER.debug("convert_url: Decoding '#{cfr}'")
else
url = URI.parse(fmt["url"].as_s)
params = url.query_params
end
url.query_params = params
LOGGER.trace("convert_url: new url is '#{url}'")
return url.to_s
rescue ex
LOGGER.debug("convert_url: Error when parsing video URL")
LOGGER.trace(ex.inspect_with_backtrace)
return ""
end
return params
end
private def convert_url(fmt)
if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) }
url = URI.parse(cfr["url"])
params = url.query_params
LOGGER.debug("convert_url: Decoding '#{cfr}'")
else
url = URI.parse(fmt["url"].as_s)
params = url.query_params
end
url.query_params = params
LOGGER.trace("convert_url: new url is '#{url}'")
return url.to_s
rescue ex
LOGGER.debug("convert_url: Error when parsing video URL")
LOGGER.trace(ex.inspect_with_backtrace)
return ""
end
+160 -164
View File
@@ -1,166 +1,162 @@
module Invidious::Videos
extend self
struct VideoPreferences
include JSON::Serializable
struct VideoPreferences
include JSON::Serializable
property annotations : Bool
property preload : Bool
property autoplay : Bool
property comments : Array(String)
property continue : Bool
property continue_autoplay : Bool
property controls : Bool
property listen : Bool
property local : Bool
property preferred_captions : Array(String)
property player_style : String
property quality : String
property quality_dash : String
property raw : Bool
property region : String?
property related_videos : Bool
property speed : Float32 | Float64
property video_end : Float64 | Int32
property video_loop : Bool
property extend_desc : Bool
property video_start : Float64 | Int32
property volume : Int32
property vr_mode : Bool
property save_player_pos : Bool
end
def process_video_params(query, preferences)
annotations = query["iv_load_policy"]?.try &.to_i?
preload = query["preload"]?.try { |q| (q == "true" || q == "1").to_unsafe }
autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe }
comments = query["comments"]?.try &.split(",").map(&.downcase)
continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe }
continue_autoplay = query["continue_autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe }
listen = query["listen"]?.try { |q| (q == "true" || q == "1").to_unsafe }
local = query["local"]?.try { |q| (q == "true" || q == "1").to_unsafe }
player_style = query["player_style"]?
preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase)
quality = query["quality"]?
quality_dash = query["quality_dash"]?
region = query["region"]?
related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe }
speed = query["speed"]?.try &.rchop("x").to_f?
video_loop = query["loop"]?.try { |q| (q == "true" || q == "1").to_unsafe }
extend_desc = query["extend_desc"]?.try { |q| (q == "true" || q == "1").to_unsafe }
volume = query["volume"]?.try &.to_i?
vr_mode = query["vr_mode"]?.try { |q| (q == "true" || q == "1").to_unsafe }
save_player_pos = query["save_player_pos"]?.try { |q| (q == "true" || q == "1").to_unsafe }
if preferences
# region ||= preferences.region
annotations ||= preferences.annotations.to_unsafe
preload ||= preferences.preload.to_unsafe
autoplay ||= preferences.autoplay.to_unsafe
comments ||= preferences.comments
continue ||= preferences.continue.to_unsafe
continue_autoplay ||= preferences.continue_autoplay.to_unsafe
listen ||= preferences.listen.to_unsafe
local ||= preferences.local.to_unsafe
player_style ||= preferences.player_style
preferred_captions ||= preferences.captions
quality ||= preferences.quality
quality_dash ||= preferences.quality_dash
related_videos ||= preferences.related_videos.to_unsafe
speed ||= preferences.speed
video_loop ||= preferences.video_loop.to_unsafe
extend_desc ||= preferences.extend_desc.to_unsafe
volume ||= preferences.volume
vr_mode ||= preferences.vr_mode.to_unsafe
save_player_pos ||= preferences.save_player_pos.to_unsafe
end
annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe
preload ||= CONFIG.default_user_preferences.preload.to_unsafe
autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe
comments ||= CONFIG.default_user_preferences.comments
continue ||= CONFIG.default_user_preferences.continue.to_unsafe
continue_autoplay ||= CONFIG.default_user_preferences.continue_autoplay.to_unsafe
listen ||= CONFIG.default_user_preferences.listen.to_unsafe
local ||= CONFIG.default_user_preferences.local.to_unsafe
player_style ||= CONFIG.default_user_preferences.player_style
preferred_captions ||= CONFIG.default_user_preferences.captions
quality ||= CONFIG.default_user_preferences.quality
quality_dash ||= CONFIG.default_user_preferences.quality_dash
related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe
speed ||= CONFIG.default_user_preferences.speed
video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe
extend_desc ||= CONFIG.default_user_preferences.extend_desc.to_unsafe
volume ||= CONFIG.default_user_preferences.volume
vr_mode ||= CONFIG.default_user_preferences.vr_mode.to_unsafe
save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe
annotations = annotations == 1
preload = preload == 1
autoplay = autoplay == 1
continue = continue == 1
continue_autoplay = continue_autoplay == 1
listen = listen == 1
local = local == 1
related_videos = related_videos == 1
video_loop = video_loop == 1
extend_desc = extend_desc == 1
vr_mode = vr_mode == 1
save_player_pos = save_player_pos == 1
if CONFIG.disabled?("dash") && quality == "dash"
quality = "high"
end
if CONFIG.disabled?("local") && local
local = false
end
if start = query["t"]? || query["time_continue"]? || query["start"]?
video_start = decode_time(start)
end
video_start ||= 0
if query["end"]?
video_end = decode_time(query["end"])
end
video_end ||= -1
raw = query["raw"]?.try &.to_i?
raw ||= 0
raw = raw == 1
controls = query["controls"]?.try &.to_i?
controls ||= 1
controls = controls >= 1
params = VideoPreferences.new({
annotations: annotations,
preload: preload,
autoplay: autoplay,
comments: comments,
continue: continue,
continue_autoplay: continue_autoplay,
controls: controls,
listen: listen,
local: local,
player_style: player_style,
preferred_captions: preferred_captions,
quality: quality,
quality_dash: quality_dash,
raw: raw,
region: region,
related_videos: related_videos,
speed: speed,
video_end: video_end,
video_loop: video_loop,
extend_desc: extend_desc,
video_start: video_start,
volume: volume,
vr_mode: vr_mode,
save_player_pos: save_player_pos,
})
return params
end
property annotations : Bool
property preload : Bool
property autoplay : Bool
property comments : Array(String)
property continue : Bool
property continue_autoplay : Bool
property controls : Bool
property listen : Bool
property local : Bool
property preferred_captions : Array(String)
property player_style : String
property quality : String
property quality_dash : String
property raw : Bool
property region : String?
property related_videos : Bool
property speed : Float32 | Float64
property video_end : Float64 | Int32
property video_loop : Bool
property extend_desc : Bool
property video_start : Float64 | Int32
property volume : Int32
property vr_mode : Bool
property save_player_pos : Bool
end
def process_video_params(query, preferences)
annotations = query["iv_load_policy"]?.try &.to_i?
preload = query["preload"]?.try { |q| (q == "true" || q == "1").to_unsafe }
autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe }
comments = query["comments"]?.try &.split(",").map(&.downcase)
continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe }
continue_autoplay = query["continue_autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe }
listen = query["listen"]?.try { |q| (q == "true" || q == "1").to_unsafe }
local = query["local"]?.try { |q| (q == "true" || q == "1").to_unsafe }
player_style = query["player_style"]?
preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase)
quality = query["quality"]?
quality_dash = query["quality_dash"]?
region = query["region"]?
related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe }
speed = query["speed"]?.try &.rchop("x").to_f?
video_loop = query["loop"]?.try { |q| (q == "true" || q == "1").to_unsafe }
extend_desc = query["extend_desc"]?.try { |q| (q == "true" || q == "1").to_unsafe }
volume = query["volume"]?.try &.to_i?
vr_mode = query["vr_mode"]?.try { |q| (q == "true" || q == "1").to_unsafe }
save_player_pos = query["save_player_pos"]?.try { |q| (q == "true" || q == "1").to_unsafe }
if preferences
# region ||= preferences.region
annotations ||= preferences.annotations.to_unsafe
preload ||= preferences.preload.to_unsafe
autoplay ||= preferences.autoplay.to_unsafe
comments ||= preferences.comments
continue ||= preferences.continue.to_unsafe
continue_autoplay ||= preferences.continue_autoplay.to_unsafe
listen ||= preferences.listen.to_unsafe
local ||= preferences.local.to_unsafe
player_style ||= preferences.player_style
preferred_captions ||= preferences.captions
quality ||= preferences.quality
quality_dash ||= preferences.quality_dash
related_videos ||= preferences.related_videos.to_unsafe
speed ||= preferences.speed
video_loop ||= preferences.video_loop.to_unsafe
extend_desc ||= preferences.extend_desc.to_unsafe
volume ||= preferences.volume
vr_mode ||= preferences.vr_mode.to_unsafe
save_player_pos ||= preferences.save_player_pos.to_unsafe
end
annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe
preload ||= CONFIG.default_user_preferences.preload.to_unsafe
autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe
comments ||= CONFIG.default_user_preferences.comments
continue ||= CONFIG.default_user_preferences.continue.to_unsafe
continue_autoplay ||= CONFIG.default_user_preferences.continue_autoplay.to_unsafe
listen ||= CONFIG.default_user_preferences.listen.to_unsafe
local ||= CONFIG.default_user_preferences.local.to_unsafe
player_style ||= CONFIG.default_user_preferences.player_style
preferred_captions ||= CONFIG.default_user_preferences.captions
quality ||= CONFIG.default_user_preferences.quality
quality_dash ||= CONFIG.default_user_preferences.quality_dash
related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe
speed ||= CONFIG.default_user_preferences.speed
video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe
extend_desc ||= CONFIG.default_user_preferences.extend_desc.to_unsafe
volume ||= CONFIG.default_user_preferences.volume
vr_mode ||= CONFIG.default_user_preferences.vr_mode.to_unsafe
save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe
annotations = annotations == 1
preload = preload == 1
autoplay = autoplay == 1
continue = continue == 1
continue_autoplay = continue_autoplay == 1
listen = listen == 1
local = local == 1
related_videos = related_videos == 1
video_loop = video_loop == 1
extend_desc = extend_desc == 1
vr_mode = vr_mode == 1
save_player_pos = save_player_pos == 1
if CONFIG.disabled?("dash") && quality == "dash"
quality = "high"
end
if CONFIG.disabled?("local") && local
local = false
end
if start = query["t"]? || query["time_continue"]? || query["start"]?
video_start = decode_time(start)
end
video_start ||= 0
if query["end"]?
video_end = decode_time(query["end"])
end
video_end ||= -1
raw = query["raw"]?.try &.to_i?
raw ||= 0
raw = raw == 1
controls = query["controls"]?.try &.to_i?
controls ||= 1
controls = controls >= 1
params = VideoPreferences.new({
annotations: annotations,
preload: preload,
autoplay: autoplay,
comments: comments,
continue: continue,
continue_autoplay: continue_autoplay,
controls: controls,
listen: listen,
local: local,
player_style: player_style,
preferred_captions: preferred_captions,
quality: quality,
quality_dash: quality_dash,
raw: raw,
region: region,
related_videos: related_videos,
speed: speed,
video_end: video_end,
video_loop: video_loop,
extend_desc: extend_desc,
video_start: video_start,
volume: volume,
vr_mode: vr_mode,
save_player_pos: save_player_pos,
})
return params
end
+2 -2
View File
@@ -8,12 +8,12 @@
<div class="pure-u-1 pure-u-lg-3-5">
<div class="h-box">
<form class="pure-form pure-form-aligned" action="/add_playlist_items" method="get">
<legend><a href="/playlist?list=<%= playlist.id %>"><%= I18n.translate(locale, "Editing playlist `x`", %|"#{HTML.escape(playlist.title)}"|) %></a></legend>
<legend><a href="/playlist?list=<%= playlist.id %>"><%= translate(locale, "Editing playlist `x`", %|"#{HTML.escape(playlist.title)}"|) %></a></legend>
<fieldset>
<input class="pure-input-1" type="search" name="q"
<% if query %>value="<%= HTML.escape(query.text) %>"<% end %>
placeholder="<%= I18n.translate(locale, "Search for videos") %>">
placeholder="<%= translate(locale, "Search for videos") %>">
<input type="hidden" name="list" value="<%= plid %>">
</fieldset>
</form>
+4 -4
View File
@@ -35,10 +35,10 @@
<%=
{
"ucid" => ucid,
"youtube_comments_text" => HTML.escape(I18n.translate(locale, "View YouTube comments")),
"comments_text" => HTML.escape(I18n.translate(locale, "View `x` comments", "{commentCount}")),
"hide_replies_text" => HTML.escape(I18n.translate(locale, "Hide replies")),
"show_replies_text" => HTML.escape(I18n.translate(locale, "Show replies")),
"youtube_comments_text" => HTML.escape(translate(locale, "View YouTube comments")),
"comments_text" => HTML.escape(translate(locale, "View `x` comments", "{commentCount}")),
"hide_replies_text" => HTML.escape(translate(locale, "Hide replies")),
"show_replies_text" => HTML.escape(translate(locale, "Show replies")),
"preferences" => env.get("preferences").as(Preferences)
}.to_pretty_json
%>
@@ -12,10 +12,7 @@
<div class="pure-u-1-2 flex-left flexible">
<div class="channel-profile">
<img src="/ggpht<%= channel_profile_pic %>" alt="" />
<div class="channel-name-pronouns">
<span class="channel-name"><%= author %></span><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
<% if !channel.pronouns.nil? %><br /><span class="channel-pronouns"><%= channel.pronouns %></span><% end %>
</div>
<span class="channel-name"><%= author %></span><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
</div>
</div>
@@ -27,7 +24,7 @@
<div class="pure-u">
<a class="pure-button pure-button-secondary" dir="auto" href="/feed/channel/<%= ucid %>">
<i class="icon ion-logo-rss"></i>&nbsp;<%= I18n.translate(locale, "generic_button_rss") %>
<i class="icon ion-logo-rss"></i>&nbsp;<%= translate(locale, "generic_button_rss") %>
</a>
</div>
</div>
@@ -40,10 +37,10 @@
<div class="pure-g h-box">
<div class="pure-u-1-2">
<div class="pure-u-1 pure-md-1-3">
<a href="<%= youtube_url %>"><%= I18n.translate(locale, "View channel on YouTube") %></a>
<a href="<%= youtube_url %>"><%= translate(locale, "View channel on YouTube") %></a>
</div>
<div class="pure-u-1 pure-md-1-3">
<a href="<%= redirect_url %>"><%= I18n.translate(locale, "Switch Invidious Instance") %></a>
<a href="<%= redirect_url %>"><%= translate(locale, "Switch Invidious Instance") %></a>
</div>
<%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %>
@@ -53,9 +50,9 @@
<% sort_options.each do |sort| %>
<div class="pure-u-1 pure-md-1-3">
<% if sort_by == sort %>
<b><%= I18n.translate(locale, sort) %></b>
<b><%= translate(locale, sort) %></b>
<% else %>
<a href="<%= relative_url %>?sort_by=<%= sort %>"><%= I18n.translate(locale, sort) %></a>
<a href="<%= relative_url %>?sort_by=<%= sort %>"><%= translate(locale, sort) %></a>
<% end %>
</div>
<% end %>
+1 -1
View File
@@ -5,7 +5,7 @@
<% end %>
<% feed_menu.each do |feed| %>
<a href="/feed/<%= feed.downcase %>" class="feed-menu-item pure-menu-heading">
<%= I18n.translate(locale, feed) %>
<%= translate(locale, feed) %>
</a>
<% end %>
</div>
+12 -12
View File
@@ -27,8 +27,8 @@
</div>
<% if !item.channel_handle.nil? %><p class="channel-name" dir="auto"><%= item.channel_handle %></p><% end %>
<p><%= I18n.translate_count(locale, "generic_subscribers_count", item.subscriber_count, I18n::NumberFormatting::Separator) %></p>
<% if !item.auto_generated && item.channel_handle.nil? %><p><%= I18n.translate_count(locale, "generic_videos_count", item.video_count, I18n::NumberFormatting::Separator) %></p><% end %>
<p><%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %></p>
<% if !item.auto_generated && item.channel_handle.nil? %><p><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p><% end %>
<h5><%= item.description_html %></h5>
<% when SearchHashtag %>
<% if !thin_mode %>
@@ -45,13 +45,13 @@
<div class="video-card-row">
<%- if item.video_count != 0 -%>
<p><%= I18n.translate_count(locale, "generic_videos_count", item.video_count, I18n::NumberFormatting::Separator) %></p>
<p><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
<%- end -%>
</div>
<div class="video-card-row">
<%- if item.channel_count != 0 -%>
<p><%= I18n.translate_count(locale, "generic_channels_count", item.channel_count, I18n::NumberFormatting::Separator) %></p>
<p><%= translate_count(locale, "generic_channels_count", item.channel_count, NumberFormatting::Separator) %></p>
<%- end -%>
</div>
<% when SearchPlaylist, InvidiousPlaylist %>
@@ -73,7 +73,7 @@
<%- end -%>
<div class="bottom-right-overlay">
<p class="length"><%= I18n.translate_count(locale, "generic_videos_count", item.video_count, I18n::NumberFormatting::Separator) %></p>
<p class="length"><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
</div>
</div>
@@ -101,11 +101,11 @@
<div class="error-card">
<div class="explanation">
<i class="icon ion-ios-alert"></i>
<h4><%=I18n.translate(locale, "timeline_parse_error_placeholder_heading")%></h4>
<p><%=I18n.translate(locale, "timeline_parse_error_placeholder_message")%></p>
<h4><%=translate(locale, "timeline_parse_error_placeholder_heading")%></h4>
<p><%=translate(locale, "timeline_parse_error_placeholder_message")%></p>
</div>
<details>
<summary class="pure-button pure-button-secondary"><%=I18n.translate(locale, "timeline_parse_error_show_technical_details")%></summary>
<summary class="pure-button pure-button-secondary"><%=translate(locale, "timeline_parse_error_show_technical_details")%></summary>
<pre class="error-issue-template"><%=get_issue_template(env, item.parse_exception)[1]%></pre>
</details>
</div>
@@ -168,7 +168,7 @@
<div class="bottom-right-overlay">
<%- if item.responds_to?(:live_now) && item.live_now -%>
<p class="length" dir="auto"><i class="icon ion-ios-play-circle"></i>&nbsp;<%= I18n.translate(locale, "LIVE") %></p>
<p class="length" dir="auto"><i class="icon ion-ios-play-circle"></i>&nbsp;<%= translate(locale, "LIVE") %></p>
<%- elsif item.length_seconds != 0 -%>
<p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
<%- end -%>
@@ -200,15 +200,15 @@
<div class="video-card-row flexible">
<div class="flex-left">
<% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>
<p class="video-data" dir="auto"><%= I18n.translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></p>
<p class="video-data" dir="auto"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></p>
<% elsif item.responds_to?(:published) && (Time.utc - item.published) > 1.minute %>
<p class="video-data" dir="auto"><%= I18n.translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></p>
<p class="video-data" dir="auto"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></p>
<% end %>
</div>
<% if item.responds_to?(:views) && item.views %>
<div class="flex-right">
<p class="video-data" dir="auto"><%= I18n.translate_count(locale, "generic_views_count", item.views || 0, I18n::NumberFormatting::Short) %></p>
<p class="video-data" dir="auto"><%= translate_count(locale, "generic_views_count", item.views || 0, NumberFormatting::Short) %></p>
</div>
<% end %>
</div>
@@ -11,9 +11,9 @@
<script id="pagination-data" type="application/json">
<%=
{
"next_page" => I18n.translate(locale, "Next page"),
"prev_page" => I18n.translate(locale, "Previous page"),
"is_rtl" => I18n.locale_is_rtl?(locale)
"next_page" => translate(locale, "Next page"),
"prev_page" => translate(locale, "Previous page"),
"is_rtl" => locale_is_rtl?(locale)
}.to_pretty_json
%>
</script>
+1 -1
View File
@@ -25,7 +25,7 @@
audio_streams.each_with_index do |fmt, i|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
src_url += "&local=true" if params.local
src_url = invidious_companion.public_url.to_s + src_url +
src_url = invidious_companion.public_url.to_s + src_url +
"&check=#{invidious_companion_check_id}" if (invidious_companion)
bitrate = fmt["bitrate"]
+3 -11
View File
@@ -1,20 +1,12 @@
<%
search_privacy = preferences.search_privacy
%>
<% if search_privacy %>
<form class="pure-form" action="/search" method="post">
<% else %>
<form class="pure-form" action="/search" method="get">
<% end %>
<fieldset>
<input type="search" id="searchbox" autocorrect="off"
autocapitalize="none" spellcheck="false" <% if autofocus %>autofocus<% end %>
name="q" placeholder="<%= I18n.translate(locale, "search") %>"
title="<%= I18n.translate(locale, "search") %>"
name="q" placeholder="<%= translate(locale, "search") %>"
title="<%= translate(locale, "search") %>"
value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
</fieldset>
<button type="submit" id="searchbutton" aria-label="<%= I18n.translate(locale, "search") %>">
<button type="submit" id="searchbutton" aria-label="<%= translate(locale, "search") %>">
<i class="icon ion-ios-search"></i>
</button>
</form>
@@ -3,14 +3,14 @@
<form action="/subscription_ajax?action=remove_subscriptions&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<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="<%= I18n.translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b>
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b>
</button>
</form>
<% else %>
<form action="/subscription_ajax?action=create_subscription_to_channel&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<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="<%= I18n.translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b>
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b>
</button>
</form>
<% end %>
@@ -22,8 +22,8 @@
"author" => HTML.escape(author),
"sub_count_text" => HTML.escape(sub_count_text),
"csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || ""),
"subscribe_text" => HTML.escape(I18n.translate(locale, "Subscribe")),
"unsubscribe_text" => HTML.escape(I18n.translate(locale, "Unsubscribe"))
"subscribe_text" => HTML.escape(translate(locale, "Subscribe")),
"unsubscribe_text" => HTML.escape(translate(locale, "Unsubscribe"))
}.to_pretty_json
%>
</script>
@@ -31,6 +31,6 @@
<% else %>
<a id="subscribe" class="pure-button pure-button-primary"
href="/login?referer=<%= env.get("current_page") %>">
<b><%= I18n.translate(locale, "Subscribe") %> | <%= sub_count_text %></b>
<b><%= translate(locale, "Subscribe") %> | <%= sub_count_text %></b>
</a>
<% end %>
@@ -1,21 +1,21 @@
<div class="flex-right flexible">
<div class="icon-buttons">
<a title="<%=I18n.translate(locale, "videoinfo_watch_on_youTube")%>" rel="noreferrer noopener" href="https://www.youtube.com/watch<%=endpoint_params%>">
<a title="<%=translate(locale, "videoinfo_watch_on_youTube")%>" rel="noreferrer noopener" href="https://www.youtube.com/watch<%=endpoint_params%>">
<i class="icon ion-logo-youtube"></i>
</a>
<a title="<%=I18n.translate(locale, "Audio mode")%>" href="/watch<%=endpoint_params%>&listen=1">
<a title="<%=translate(locale, "Audio mode")%>" href="/watch<%=endpoint_params%>&listen=1">
<i class="icon ion-md-headset"></i>
</a>
<% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
<a title="<%=I18n.translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=%2Fwatch<%=URI.encode_www_form(endpoint_params)%>">
<a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=%2Fwatch<%=URI.encode_www_form(endpoint_params)%>">
<i class="icon ion-md-jet"></i>
</a>
<% else %>
<a title="<%=I18n.translate(locale, "Switch Invidious Instance")%>" href="https://redirect.invidious.io/watch<%=endpoint_params%>">
<a title="<%=translate(locale, "Switch Invidious Instance")%>" href="https://redirect.invidious.io/watch<%=endpoint_params%>">
<i class="icon ion-md-jet"></i>
</a>
<% end %>
</div>
</div>
</div>
+7 -7
View File
@@ -1,5 +1,5 @@
<% content_for "header" do %>
<title><%= I18n.translate(locale, "Create playlist") %> - Invidious</title>
<title><%= translate(locale, "Create playlist") %> - Invidious</title>
<% end %>
<div class="pure-g">
@@ -8,25 +8,25 @@
<div class="h-box">
<form class="pure-form pure-form-aligned" action="/create_playlist?referer=<%= URI.encode_www_form(referer) %>" method="post">
<fieldset>
<legend><%= I18n.translate(locale, "Create playlist") %></legend>
<legend><%= translate(locale, "Create playlist") %></legend>
<div class="pure-control-group">
<label for="title"><%= I18n.translate(locale, "Title") %> :</label>
<input required name="title" type="text" placeholder="<%= I18n.translate(locale, "Title") %>">
<label for="title"><%= translate(locale, "Title") %> :</label>
<input required name="title" type="text" placeholder="<%= translate(locale, "Title") %>">
</div>
<div class="pure-control-group">
<label for="privacy"><%= I18n.translate(locale, "Playlist privacy") %> :</label>
<label for="privacy"><%= translate(locale, "Playlist privacy") %> :</label>
<select name="privacy" id="privacy">
<% PlaylistPrivacy.names.each do |option| %>
<option value="<%= option %>" <% if option == "Public" %> selected <% end %>><%= I18n.translate(locale, option) %></option>
<option value="<%= option %>" <% if option == "Public" %> selected <% end %>><%= translate(locale, option) %></option>
<% end %>
</select>
</div>
<div class="pure-controls">
<button type="submit" name="action" value="create_playlist" class="pure-button pure-button-primary">
<%= I18n.translate(locale, "Create playlist") %>
<%= translate(locale, "Create playlist") %>
</button>
</div>
+4 -4
View File
@@ -1,20 +1,20 @@
<% content_for "header" do %>
<title><%= I18n.translate(locale, "Delete playlist") %> - Invidious</title>
<title><%= translate(locale, "Delete playlist") %> - Invidious</title>
<% end %>
<div class="h-box">
<form class="pure-form pure-form-aligned" action="/delete_playlist?list=<%= plid %>&referer=<%= URI.encode_www_form(referer) %>" method="post">
<legend><%= I18n.translate(locale, "Delete playlist `x`?", %|"#{HTML.escape(playlist.title)}"|) %></legend>
<legend><%= translate(locale, "Delete playlist `x`?", %|"#{HTML.escape(playlist.title)}"|) %></legend>
<div class="pure-g">
<div class="pure-u-1-2">
<button type="submit" name="submit" value="delete_playlist" class="pure-button pure-button-primary">
<%= I18n.translate(locale, "Yes") %>
<%= translate(locale, "Yes") %>
</button>
</div>
<div class="pure-u-1-2">
<a class="pure-button" href="/playlist?list=<%= plid %>">
<%= I18n.translate(locale, "No") %>
<%= translate(locale, "No") %>
</a>
</div>
</div>
+5 -5
View File
@@ -10,17 +10,17 @@
<div class="flex-right button-container">
<div class="pure-u">
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/playlist?list=<%= plid %>">
<i class="icon ion-md-close"></i>&nbsp;<%= I18n.translate(locale, "generic_button_cancel") %>
<i class="icon ion-md-close"></i>&nbsp;<%= translate(locale, "generic_button_cancel") %>
</a>
</div>
<div class="pure-u">
<button class="pure-button pure-button-secondary low-profile" dir="auto" type="submit">
<i class="icon ion-md-save"></i>&nbsp;<%= I18n.translate(locale, "generic_button_save") %>
<i class="icon ion-md-save"></i>&nbsp;<%= translate(locale, "generic_button_save") %>
</button>
</div>
<div class="pure-u">
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/delete_playlist?list=<%= plid %>">
<i class="icon ion-md-trash"></i>&nbsp;<%= I18n.translate(locale, "generic_button_delete") %>
<i class="icon ion-md-trash"></i>&nbsp;<%= translate(locale, "generic_button_delete") %>
</a>
</div>
</div>
@@ -36,11 +36,11 @@
<div class="pure-u-1-1">
<b>
<%= HTML.escape(playlist.author) %> |
<%= I18n.translate_count(locale, "generic_videos_count", playlist.video_count) %> |
<%= translate_count(locale, "generic_videos_count", playlist.video_count) %> |
</b>
<select name="privacy">
<%- {"Public", "Unlisted", "Private"}.each do |option| -%>
<option value="<%= option %>" <% if option == playlist.privacy.to_s %>selected<% end %>><%= I18n.translate(locale, option) %></option>
<option value="<%= option %>" <% if option == playlist.privacy.to_s %>selected<% end %>><%= translate(locale, option) %></option>
<%- end -%>
</select>
</div>
+4 -4
View File
@@ -1,19 +1,19 @@
<% content_for "header" do %>
<title><%= I18n.translate(locale, "History") %> - Invidious</title>
<title><%= translate(locale, "History") %> - Invidious</title>
<% end %>
<div class="pure-g h-box">
<div class="pure-u-1-3">
<h3><%= I18n.translate_count(locale, "generic_videos_count", user.watched.size, I18n::NumberFormatting::HtmlSpan) %></h3>
<h3><%= translate_count(locale, "generic_videos_count", user.watched.size, NumberFormatting::HtmlSpan) %></h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:center">
<a href="/feed/subscriptions"><%= I18n.translate_count(locale, "generic_subscriptions_count", user.subscriptions.size, I18n::NumberFormatting::HtmlSpan) %></a>
<a href="/feed/subscriptions"><%= translate_count(locale, "generic_subscriptions_count", user.subscriptions.size, NumberFormatting::HtmlSpan) %></a>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:right">
<a href="/clear_watch_history"><%= I18n.translate(locale, "Clear watch history") %></a>
<a href="/clear_watch_history"><%= translate(locale, "Clear watch history") %></a>
</h3>
</div>
</div>
+5 -5
View File
@@ -1,22 +1,22 @@
<% content_for "header" do %>
<title><%= I18n.translate(locale, "Playlists") %> - Invidious</title>
<title><%= translate(locale, "Playlists") %> - Invidious</title>
<% end %>
<%= rendered "components/feed_menu" %>
<div class="pure-g h-box">
<div class="pure-u-1-3">
<h3><%= I18n.translate(locale, "user_created_playlists", %(<span id="count">#{items_created.size}</span>)) %></h3>
<h3><%= translate(locale, "user_created_playlists", %(<span id="count">#{items_created.size}</span>)) %></h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:center">
<a href="/create_playlist?referer=<%= URI.encode_www_form("/feed/playlists") %>"><%= I18n.translate(locale, "Create playlist") %></a>
<a href="/create_playlist?referer=<%= URI.encode_www_form("/feed/playlists") %>"><%= translate(locale, "Create playlist") %></a>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:right">
<a href="/data_control?referer=<%= URI.encode_www_form("/feed/playlists") %>">
<%= I18n.translate(locale, "Import/export") %>
<%= translate(locale, "Import/export") %>
</a>
</h3>
</div>
@@ -30,7 +30,7 @@
<div class="pure-g h-box">
<div class="pure-u-1">
<h3><%= I18n.translate(locale, "user_saved_playlists", %(<span id="count">#{items_saved.size}</span>)) %></h3>
<h3><%= translate(locale, "user_saved_playlists", %(<span id="count">#{items_saved.size}</span>)) %></h3>
</div>
</div>
+2 -2
View File
@@ -1,8 +1,8 @@
<% content_for "header" do %>
<meta name="description" content="<%= I18n.translate(locale, "An alternative front-end to YouTube") %>">
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
<title>
<% if env.get("preferences").as(Preferences).default_home != "Popular" %>
<%= I18n.translate(locale, "Popular") %> - Invidious
<%= translate(locale, "Popular") %> - Invidious
<% else %>
Invidious
<% end %>
+4 -4
View File
@@ -1,5 +1,5 @@
<% content_for "header" do %>
<title><%= I18n.translate(locale, "Subscriptions") %> - Invidious</title>
<title><%= translate(locale, "Subscriptions") %> - Invidious</title>
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/private?token=<%= token %>" />
<% end %>
@@ -8,12 +8,12 @@
<div class="pure-g h-box">
<div class="pure-u-1-3">
<h3>
<a href="/subscription_manager"><%= I18n.translate(locale, "Manage subscriptions") %></a>
<a href="/subscription_manager"><%= translate(locale, "Manage subscriptions") %></a>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:center">
<a href="/feed/history"><%= I18n.translate(locale, "Watch history") %></a>
<a href="/feed/history"><%= translate(locale, "Watch history") %></a>
</h3>
</div>
<div class="pure-u-1-3">
@@ -26,7 +26,7 @@
<% if CONFIG.enable_user_notifications %>
<center>
<%= I18n.translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %>
<%= translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %>
</center>
<% if !notifications.empty? %>
+5 -5
View File
@@ -1,8 +1,8 @@
<% content_for "header" do %>
<meta name="description" content="<%= I18n.translate(locale, "An alternative front-end to YouTube") %>">
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
<title>
<% if env.get("preferences").as(Preferences).default_home != "Trending" %>
<%= I18n.translate(locale, "Trending") %> - Invidious
<%= translate(locale, "Trending") %> - Invidious
<% else %>
Invidious
<% end %>
@@ -15,7 +15,7 @@
<div style="align-self:flex-end" class="pure-u-2-3">
<% if plid %>
<a href="/playlist?list=<%= plid %>">
<%= I18n.translate(locale, "View as playlist") %>
<%= translate(locale, "View as playlist") %>
</a>
<% end %>
</div>
@@ -24,10 +24,10 @@
<% {"Livestreams", "Gaming"}.each do |option| %>
<div class="pure-u-1 pure-md-1-3">
<% if trending_type == option %>
<b><%= I18n.translate(locale, option) %></b>
<b><%= translate(locale, option) %></b>
<% else %>
<a href="/feed/trending?type=<%= option %>&region=<%= region %>">
<%= I18n.translate(locale, option) %>
<%= translate(locale, option) %>
</a>
<% end %>
</div>
+13 -13
View File
@@ -7,7 +7,7 @@
</head>
<body>
<h1><%= I18n.translate(locale, "JavaScript license information") %></h1>
<h1><%= translate(locale, "JavaScript license information") %></h1>
<table id="jslicense-labels1">
<tr>
<td>
@@ -19,7 +19,7 @@
</td>
<td>
<a href="https://github.com/iv-org/videojs-quality-selector"><%= I18n.translate(locale, "source") %></a>
<a href="https://github.com/iv-org/videojs-quality-selector"><%= translate(locale, "source") %></a>
</td>
</tr>
@@ -33,7 +33,7 @@
</td>
<td>
<a href="https://github.com/mpetazzoni/sse.js"><%= I18n.translate(locale, "source") %></a>
<a href="https://github.com/mpetazzoni/sse.js"><%= translate(locale, "source") %></a>
</td>
</tr>
@@ -47,7 +47,7 @@
</td>
<td>
<a href="https://github.com/videojs/videojs-contrib-quality-levels"><%= I18n.translate(locale, "source") %></a>
<a href="https://github.com/videojs/videojs-contrib-quality-levels"><%= translate(locale, "source") %></a>
</td>
</tr>
@@ -61,7 +61,7 @@
</td>
<td>
<a href="https://github.com/jfujita/videojs-http-source-selector"><%= I18n.translate(locale, "source") %></a>
<a href="https://github.com/jfujita/videojs-http-source-selector"><%= translate(locale, "source") %></a>
</td>
</tr>
@@ -75,7 +75,7 @@
</td>
<td>
<a href="https://github.com/mister-ben/videojs-mobile-ui"><%= I18n.translate(locale, "source") %></a>
<a href="https://github.com/mister-ben/videojs-mobile-ui"><%= translate(locale, "source") %></a>
</td>
</tr>
@@ -89,7 +89,7 @@
</td>
<td>
<a href="https://github.com/spchuang/videojs-markers"><%= I18n.translate(locale, "source") %></a>
<a href="https://github.com/spchuang/videojs-markers"><%= translate(locale, "source") %></a>
</td>
</tr>
@@ -103,7 +103,7 @@
</td>
<td>
<a href="https://github.com/brightcove/videojs-overlay"><%= I18n.translate(locale, "source") %></a>
<a href="https://github.com/brightcove/videojs-overlay"><%= translate(locale, "source") %></a>
</td>
</tr>
@@ -117,7 +117,7 @@
</td>
<td>
<a href="https://github.com/mkhazov/videojs-share"><%= I18n.translate(locale, "source") %></a>
<a href="https://github.com/mkhazov/videojs-share"><%= translate(locale, "source") %></a>
</td>
</tr>
@@ -131,7 +131,7 @@
</td>
<td>
<a href="https://github.com/chrisboustead/videojs-vtt-thumbnails"><%= I18n.translate(locale, "source") %></a>
<a href="https://github.com/chrisboustead/videojs-vtt-thumbnails"><%= translate(locale, "source") %></a>
</td>
</tr>
@@ -145,7 +145,7 @@
</td>
<td>
<a href="https://github.com/afrmtbl/videojs-youtube-annotations"><%= I18n.translate(locale, "source") %></a>
<a href="https://github.com/afrmtbl/videojs-youtube-annotations"><%= translate(locale, "source") %></a>
</td>
</tr>
@@ -159,7 +159,7 @@
</td>
<td>
<a href="https://github.com/videojs/videojs-vr"><%= I18n.translate(locale, "source") %></a>
<a href="https://github.com/videojs/videojs-vr"><%= translate(locale, "source") %></a>
</td>
</tr>
@@ -173,7 +173,7 @@
</td>
<td>
<a href="https://github.com/videojs/video.js"><%= I18n.translate(locale, "source") %></a>
<a href="https://github.com/videojs/video.js"><%= translate(locale, "source") %></a>
</td>
</tr>
+1 -1
View File
@@ -1,5 +1,5 @@
<% content_for "header" do %>
<meta name="description" content="<%= I18n.translate(locale, "An alternative front-end to YouTube") %>">
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
<title>
Invidious
</title>
+16 -16
View File
@@ -13,28 +13,28 @@
<%- if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email -%>
<div class="pure-u">
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/add_playlist_items?list=<%= plid %>">
<i class="icon ion-md-add"></i>&nbsp;<%= I18n.translate(locale, "playlist_button_add_items") %>
<i class="icon ion-md-add"></i>&nbsp;<%= translate(locale, "playlist_button_add_items") %>
</a>
</div>
<div class="pure-u">
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/edit_playlist?list=<%= plid %>">
<i class="icon ion-md-create"></i>&nbsp;<%= I18n.translate(locale, "generic_button_edit") %>
<i class="icon ion-md-create"></i>&nbsp;<%= translate(locale, "generic_button_edit") %>
</a>
</div>
<div class="pure-u">
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/delete_playlist?list=<%= plid %>">
<i class="icon ion-md-trash"></i>&nbsp;<%= I18n.translate(locale, "generic_button_delete") %>
<i class="icon ion-md-trash"></i>&nbsp;<%= translate(locale, "generic_button_delete") %>
</a>
</div>
<%- else -%>
<div class="pure-u">
<%- if IV::Database::Playlists.exists?(playlist.id) -%>
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/subscribe_playlist?list=<%= plid %>">
<i class="icon ion-md-add"></i>&nbsp;<%= I18n.translate(locale, "Subscribe") %>
<i class="icon ion-md-add"></i>&nbsp;<%= translate(locale, "Subscribe") %>
</a>
<%- else -%>
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/delete_playlist?list=<%= plid %>">
<i class="icon ion-md-trash"></i>&nbsp;<%= I18n.translate(locale, "Unsubscribe") %>
<i class="icon ion-md-trash"></i>&nbsp;<%= translate(locale, "Unsubscribe") %>
</a>
<%- end -%>
</div>
@@ -42,7 +42,7 @@
<div class="pure-u">
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/feed/playlist/<%= plid %>">
<i class="icon ion-logo-rss"></i>&nbsp;<%= I18n.translate(locale, "generic_button_rss") %>
<i class="icon ion-logo-rss"></i>&nbsp;<%= translate(locale, "generic_button_rss") %>
</a>
</div>
</div>
@@ -57,15 +57,15 @@
<% else %>
<%= author %> |
<% end %>
<%= I18n.translate_count(locale, "generic_videos_count", playlist.video_count) %> |
<%= I18n.translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> |
<%= translate_count(locale, "generic_videos_count", playlist.video_count) %> |
<%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> |
<% case playlist.as(InvidiousPlaylist).privacy when %>
<% when PlaylistPrivacy::Public %>
<i class="icon ion-md-globe"></i> <%= I18n.translate(locale, "Public") %>
<i class="icon ion-md-globe"></i> <%= translate(locale, "Public") %>
<% when PlaylistPrivacy::Unlisted %>
<i class="icon ion-ios-unlock"></i> <%= I18n.translate(locale, "Unlisted") %>
<i class="icon ion-ios-unlock"></i> <%= translate(locale, "Unlisted") %>
<% when PlaylistPrivacy::Private %>
<i class="icon ion-ios-lock"></i> <%= I18n.translate(locale, "Private") %>
<i class="icon ion-ios-lock"></i> <%= translate(locale, "Private") %>
<% end %>
</b>
<% else %>
@@ -76,25 +76,25 @@
<% subtitle = playlist.subtitle || "" %>
<span><%= HTML.escape(subtitle[0..subtitle.rindex(" • ") || subtitle.size]) %></span> |
<% end %>
<%= I18n.translate_count(locale, "generic_videos_count", playlist.video_count) %> |
<%= I18n.translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %>
<%= translate_count(locale, "generic_videos_count", playlist.video_count) %> |
<%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %>
</b>
<% end %>
<% if !playlist.is_a? InvidiousPlaylist %>
<div class="pure-u-2-3">
<a rel="noreferrer noopener" href="https://www.youtube.com/playlist?list=<%= playlist.id %>">
<%= I18n.translate(locale, "View playlist on YouTube") %>
<%= translate(locale, "View playlist on YouTube") %>
</a>
<span> | </span>
<% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
<a href="/redirect?referer=<%= env.get?("current_page") %>">
<%= I18n.translate(locale, "Switch Invidious Instance") %>
<%= translate(locale, "Switch Invidious Instance") %>
</a>
<% else %>
<a href="https://redirect.invidious.io/playlist?list=<%= playlist.id %>">
<%= I18n.translate(locale, "Switch Invidious Instance") %>
<%= translate(locale, "Switch Invidious Instance") %>
</a>
<% end %>
</div>
+6 -6
View File
@@ -18,7 +18,7 @@
<% else %>
<noscript>
<a href="/post/<%= id %>?ucid=<%= ucid %>&nojs=1">
<%= I18n.translate(locale, "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.") %>
<%= translate(locale, "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.") %>
</a>
</noscript>
<% end %>
@@ -29,12 +29,12 @@
<%=
{
"id" => id,
"youtube_comments_text" => HTML.escape(I18n.translate(locale, "View YouTube comments")),
"youtube_comments_text" => HTML.escape(translate(locale, "View YouTube comments")),
"reddit_comments_text" => "",
"reddit_permalink_text" => "",
"comments_text" => HTML.escape(I18n.translate(locale, "View `x` comments", "{commentCount}")),
"hide_replies_text" => HTML.escape(I18n.translate(locale, "Hide replies")),
"show_replies_text" => HTML.escape(I18n.translate(locale, "Show replies")),
"comments_text" => HTML.escape(translate(locale, "View `x` comments", "{commentCount}")),
"hide_replies_text" => HTML.escape(translate(locale, "Hide replies")),
"show_replies_text" => HTML.escape(translate(locale, "Show replies")),
"params" => {
"comments": ["youtube"]
},
@@ -45,4 +45,4 @@
%>
</script>
<script src="/js/comments.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/post.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/post.js?v=<%= ASSET_COMMIT %>"></script>
+4 -9
View File
@@ -1,10 +1,5 @@
<%
search_privacy = preferences.search_privacy
search_query = query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "&hellip;" : HTML.escape(query.text)
%>
<% content_for "header" do %>
<title><%= search_privacy ? "Search" : search_query %> - Invidious</title>
<title><%= query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "&hellip;" : HTML.escape(query.text) %> - Invidious</title>
<link rel="stylesheet" href="/css/search.css?v=<%= ASSET_COMMIT %>">
<% end %>
@@ -16,9 +11,9 @@
<%- if items.empty? -%>
<div class="h-box no-results-error">
<div>
<%= I18n.translate(locale, "search_message_no_results") %><br/><br/>
<%= I18n.translate(locale, "search_message_change_filters_or_query") %><br/><br/>
<%= I18n.translate(locale, "search_message_use_another_instance", redirect_url) %>
<%= translate(locale, "search_message_no_results") %><br/><br/>
<%= translate(locale, "search_message_change_filters_or_query") %><br/><br/>
<%= translate(locale, "search_message_use_another_instance", redirect_url) %>
</div>
</div>
<%- else -%>
+2 -2
View File
@@ -1,7 +1,7 @@
<% content_for "header" do %>
<meta name="description" content="<%= I18n.translate(locale, "An alternative front-end to YouTube") %>">
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
<title>
Invidious - <%= I18n.translate(locale, "search") %>
Invidious - <%= translate(locale, "search") %>
</title>
<link rel="stylesheet" href="/css/empty.css?v=<%= ASSET_COMMIT %>">
<% end %>
+19 -19
View File
@@ -43,7 +43,7 @@
<div class="pure-u-1 pure-u-md-8-24 user-field">
<% if env.get? "user" %>
<div class="pure-u-1-4">
<a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading" title="<%= I18n.translate(locale, "toggle_theme") %>">
<a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading" title="<%= translate(locale, "toggle_theme") %>">
<% if dark_mode == "dark" %>
<i class="icon ion-ios-sunny"></i>
<% else %>
@@ -52,7 +52,7 @@
</a>
</div>
<div class="pure-u-1-4">
<a id="notification_ticker" title="<%= I18n.translate(locale, "Subscriptions") %>" href="/feed/subscriptions" class="pure-menu-heading">
<a id="notification_ticker" title="<%= translate(locale, "Subscriptions") %>" href="/feed/subscriptions" class="pure-menu-heading">
<% notification_count = env.get("user").as(Invidious::User).notifications.size %>
<% if CONFIG.enable_user_notifications && notification_count > 0 %>
<span id="notification_count"><%= notification_count %></span> <i class="icon ion-ios-notifications"></i>
@@ -62,7 +62,7 @@
</a>
</div>
<div class="pure-u-1-4">
<a title="<%= I18n.translate(locale, "Preferences") %>" href="/preferences?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
<a title="<%= translate(locale, "Preferences") %>" href="/preferences?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
<i class="icon ion-ios-cog"></i>
</a>
</div>
@@ -75,13 +75,13 @@
<form action="/signout?referer=<%= env.get?("current_page") %>" method="post">
<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="<%= I18n.translate(locale, "Log out") %>">
<input style="all:unset" type="submit" value="<%= translate(locale, "Log out") %>">
</a>
</form>
</div>
<% else %>
<div class="pure-u-1-3">
<a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading" title="<%= I18n.translate(locale, "toggle_theme") %>">
<a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading" title="<%= translate(locale, "toggle_theme") %>">
<% if dark_mode == "dark" %>
<i class="icon ion-ios-sunny"></i>
<% else %>
@@ -90,14 +90,14 @@
</a>
</div>
<div class="pure-u-1-3">
<a title="<%= I18n.translate(locale, "Preferences") %>" href="/preferences?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
<a title="<%= translate(locale, "Preferences") %>" href="/preferences?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
<i class="icon ion-ios-cog"></i>
</a>
</div>
<% if CONFIG.login_enabled %>
<div class="pure-u-1-3">
<a href="/login?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
<%= I18n.translate(locale, "Log in") %>
<%= translate(locale, "Log in") %>
</a>
</div>
<% end %>
@@ -119,39 +119,39 @@
<span>
<i class="icon ion-logo-github"></i>
<% if CONFIG.modified_source_code_url %>
<a href="https://github.com/iv-org/invidious"><%= I18n.translate(locale, "footer_original_source_code") %></a>&nbsp;/
<a href="<%= CONFIG.modified_source_code_url %>"><%= I18n.translate(locale, "footer_modfied_source_code") %></a>
<a href="https://github.com/iv-org/invidious"><%= translate(locale, "footer_original_source_code") %></a>&nbsp;/
<a href="<%= CONFIG.modified_source_code_url %>"><%= translate(locale, "footer_modfied_source_code") %></a>
<% else %>
<a href="https://github.com/iv-org/invidious"><%= I18n.translate(locale, "footer_source_code") %></a>
<a href="https://github.com/iv-org/invidious"><%= translate(locale, "footer_source_code") %></a>
<% end %>
</span>
<span>
<i class="icon ion-ios-paper"></i>
<a href="https://github.com/iv-org/documentation"><%= I18n.translate(locale, "footer_documentation") %></a>
<a href="https://github.com/iv-org/documentation"><%= translate(locale, "footer_documentation") %></a>
</span>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<span>
<a href="https://github.com/iv-org/invidious/blob/master/LICENSE"><%= I18n.translate(locale, "Released under the AGPLv3 on Github.") %></a>
<a href="https://github.com/iv-org/invidious/blob/master/LICENSE"><%= translate(locale, "Released under the AGPLv3 on Github.") %></a>
</span>
<span>
<i class="icon ion-logo-javascript"></i>
<a rel="jslicense" href="/licenses"><%= I18n.translate(locale, "View JavaScript license information.") %></a>
<a rel="jslicense" href="/licenses"><%= translate(locale, "View JavaScript license information.") %></a>
</span>
<span>
<i class="icon ion-ios-paper"></i>
<a href="/privacy"><%= I18n.translate(locale, "View privacy policy.") %></a>
<a href="/privacy"><%= translate(locale, "View privacy policy.") %></a>
</span>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<span>
<i class="icon ion-ios-wallet"></i>
<a href="https://invidious.io/donate/"><%= I18n.translate(locale, "footer_donate_page") %></a>
<a href="https://invidious.io/donate/"><%= translate(locale, "footer_donate_page") %></a>
</span>
<span>
<%= I18n.translate(locale, "Current version: ") %>
<%= translate(locale, "Current version: ") %>
<% if CONFIG.modified_source_code_url %>
<a href="<%= CONFIG.modified_source_code_url %>/commit/<%= CURRENT_COMMIT %>"><%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %></a>
<% else %>
@@ -159,7 +159,7 @@
<% end %>
@ <%= CURRENT_BRANCH %>
<% if CURRENT_TAG != "" %>
(
(
<% if CONFIG.modified_source_code_url %>
<a href="<%= CONFIG.modified_source_code_url %>/releases/tag/<%= CURRENT_TAG %>"><%= CURRENT_TAG %></a>
<% else %>
@@ -181,8 +181,8 @@
<script id="notification_data" type="application/json">
<%=
{
"upload_text" => HTML.escape(I18n.translate(locale, "`x` uploaded a video")),
"live_upload_text" => HTML.escape(I18n.translate(locale, "`x` is live"))
"upload_text" => HTML.escape(translate(locale, "`x` uploaded a video")),
"live_upload_text" => HTML.escape(translate(locale, "`x` is live"))
}.to_pretty_json
%>
</script>
+8 -8
View File
@@ -1,22 +1,22 @@
<% content_for "header" do %>
<title><%= I18n.translate(locale, "Token") %> - Invidious</title>
<title><%= translate(locale, "Token") %> - Invidious</title>
<% end %>
<% if env.get? "access_token" %>
<div class="pure-g h-box">
<div class="pure-u-1-3">
<h3>
<%= I18n.translate(locale, "Token") %>
<%= translate(locale, "Token") %>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:center">
<a href="/token_manager"><%= I18n.translate(locale, "Token manager") %></a>
<a href="/token_manager"><%= translate(locale, "Token manager") %></a>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:right">
<a href="/preferences"><%= I18n.translate(locale, "Preferences") %></a>
<a href="/preferences"><%= translate(locale, "Preferences") %></a>
</h3>
</div>
</div>
@@ -30,9 +30,9 @@
<div class="h-box">
<form class="pure-form pure-form-aligned" action="/authorize_token" method="post">
<% if callback_url %>
<legend><%= I18n.translate(locale, "Authorize token for `x`?", "#{callback_url.scheme}://#{callback_url.host}") %></legend>
<legend><%= translate(locale, "Authorize token for `x`?", "#{callback_url.scheme}://#{callback_url.host}") %></legend>
<% else %>
<legend><%= I18n.translate(locale, "Authorize token?") %></legend>
<legend><%= translate(locale, "Authorize token?") %></legend>
<% end %>
<div class="pure-g">
@@ -48,7 +48,7 @@
<div class="pure-g">
<div class="pure-u-1-2">
<button type="submit" name="submit" value="clear_watch_history" class="pure-button pure-button-primary">
<%= I18n.translate(locale, "Yes") %>
<%= translate(locale, "Yes") %>
</button>
</div>
<div class="pure-u-1-2">
@@ -57,7 +57,7 @@
<% else %>
<a class="pure-button" href="/">
<% end %>
<%= I18n.translate(locale, "No") %>
<%= translate(locale, "No") %>
</a>
</div>
</div>
+9 -9
View File
@@ -1,5 +1,5 @@
<% content_for "header" do %>
<title><%= I18n.translate(locale, "Change password") %> - Invidious</title>
<title><%= translate(locale, "Change password") %> - Invidious</title>
<% end %>
<div class="pure-g">
@@ -7,20 +7,20 @@
<div class="pure-u-1 pure-u-lg-3-5">
<div class="h-box">
<form class="pure-form pure-form-aligned" action="/change_password?referer=<%= URI.encode_www_form(referer) %>" method="post">
<legend><%= I18n.translate(locale, "Change password") %></legend>
<legend><%= translate(locale, "Change password") %></legend>
<fieldset>
<label for="password"><%= I18n.translate(locale, "Password") %> :</label>
<input required class="pure-input-1" name="password" type="password" placeholder="<%= I18n.translate(locale, "Password") %>">
<label for="password"><%= translate(locale, "Password") %> :</label>
<input required class="pure-input-1" name="password" type="password" placeholder="<%= translate(locale, "Password") %>">
<label for="new_password[0]"><%= I18n.translate(locale, "New password") %> :</label>
<input required class="pure-input-1" name="new_password[0]" type="password" placeholder="<%= I18n.translate(locale, "New password") %>">
<label for="new_password[0]"><%= translate(locale, "New password") %> :</label>
<input required class="pure-input-1" name="new_password[0]" type="password" placeholder="<%= translate(locale, "New password") %>">
<label for="new_password[1]"><%= I18n.translate(locale, "New password") %> :</label>
<input required class="pure-input-1" name="new_password[1]" type="password" placeholder="<%= I18n.translate(locale, "New password") %>">
<label for="new_password[1]"><%= translate(locale, "New password") %> :</label>
<input required class="pure-input-1" name="new_password[1]" type="password" placeholder="<%= translate(locale, "New password") %>">
<button type="submit" name="action" value="change_password" class="pure-button pure-button-primary">
<%= I18n.translate(locale, "Change password") %>
<%= translate(locale, "Change password") %>
</button>
<input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>">
@@ -1,20 +1,20 @@
<% content_for "header" do %>
<title><%= I18n.translate(locale, "Clear watch history") %> - Invidious</title>
<title><%= translate(locale, "Clear watch history") %> - Invidious</title>
<% end %>
<div class="h-box">
<form class="pure-form pure-form-aligned" action="/clear_watch_history?referer=<%= URI.encode_www_form(referer) %>" method="post">
<legend><%= I18n.translate(locale, "Clear watch history?") %></legend>
<legend><%= translate(locale, "Clear watch history?") %></legend>
<div class="pure-g">
<div class="pure-u-1-2">
<button type="submit" name="submit" value="clear_watch_history" class="pure-button pure-button-primary">
<%= I18n.translate(locale, "Yes") %>
<%= translate(locale, "Yes") %>
</button>
</div>
<div class="pure-u-1-2">
<a class="pure-button" href="<%= URI.encode_www_form(referer) %>">
<%= I18n.translate(locale, "No") %>
<%= translate(locale, "No") %>
</a>
</div>
</div>
+14 -14
View File
@@ -1,67 +1,67 @@
<% content_for "header" do %>
<title><%= I18n.translate(locale, "Import and Export Data") %> - Invidious</title>
<title><%= translate(locale, "Import and Export Data") %> - Invidious</title>
<% end %>
<div class="h-box">
<form class="pure-form pure-form-aligned" enctype="multipart/form-data" action="/data_control?referer=<%= URI.encode_www_form(referer) %>" method="post">
<fieldset>
<legend><%= I18n.translate(locale, "Import") %></legend>
<legend><%= translate(locale, "Import") %></legend>
<div class="pure-control-group">
<label for="import_invidious"><%= I18n.translate(locale, "Import Invidious data") %></label>
<label for="import_invidious"><%= translate(locale, "Import Invidious data") %></label>
<input type="file" id="import_invidious" name="import_invidious">
</div>
<div class="pure-control-group">
<label for="import_youtube">
<a rel="noopener noreferrer" target="_blank" href="https://github.com/iv-org/documentation/blob/master/docs/export-youtube-subscriptions.md">
<%= I18n.translate(locale, "Import YouTube subscriptions") %>
<%= translate(locale, "Import YouTube subscriptions") %>
</a>
</label>
<input type="file" id="import_youtube" name="import_youtube">
</div>
<div class="pure-control-group">
<label for="import_youtube_pl"><%= I18n.translate(locale, "Import YouTube playlist (.csv)") %></label>
<label for="import_youtube_pl"><%= translate(locale, "Import YouTube playlist (.csv)") %></label>
<input type="file" id="import_youtube_pl" name="import_youtube_pl">
</div>
<div class="pure-control-group">
<label for="import_youtube_wh"><%= I18n.translate(locale, "Import YouTube watch history (.json)") %></label>
<label for="import_youtube_wh"><%= translate(locale, "Import YouTube watch history (.json)") %></label>
<input type="file" id="import_youtube_wh" name="import_youtube_wh">
</div>
<div class="pure-control-group">
<label for="import_freetube"><%= I18n.translate(locale, "Import FreeTube subscriptions (.db)") %></label>
<label for="import_freetube"><%= translate(locale, "Import FreeTube subscriptions (.db)") %></label>
<input type="file" id="import_freetube" name="import_freetube">
</div>
<div class="pure-control-group">
<label for="import_newpipe_subscriptions"><%= I18n.translate(locale, "Import NewPipe subscriptions (.json)") %></label>
<label for="import_newpipe_subscriptions"><%= translate(locale, "Import NewPipe subscriptions (.json)") %></label>
<input type="file" id="import_newpipe_subscriptions" name="import_newpipe_subscriptions">
</div>
<div class="pure-control-group">
<label for="import_newpipe"><%= I18n.translate(locale, "Import NewPipe data (.zip)") %></label>
<label for="import_newpipe"><%= translate(locale, "Import NewPipe data (.zip)") %></label>
<input type="file" id="import_newpipe" name="import_newpipe">
</div>
<div class="pure-controls">
<button type="submit" class="pure-button pure-button-primary"><%= I18n.translate(locale, "Import") %></button>
<button type="submit" class="pure-button pure-button-primary"><%= translate(locale, "Import") %></button>
</div>
<legend><%= I18n.translate(locale, "Export") %></legend>
<legend><%= translate(locale, "Export") %></legend>
<div class="pure-control-group">
<a href="/subscription_manager?action_takeout=1"><%= I18n.translate(locale, "Export subscriptions as OPML") %></a>
<a href="/subscription_manager?action_takeout=1"><%= translate(locale, "Export subscriptions as OPML") %></a>
</div>
<div class="pure-control-group">
<a href="/subscription_manager?action_takeout=1&format=newpipe"><%= I18n.translate(locale, "Export subscriptions as OPML (for NewPipe & FreeTube)") %></a>
<a href="/subscription_manager?action_takeout=1&format=newpipe"><%= translate(locale, "Export subscriptions as OPML (for NewPipe & FreeTube)") %></a>
</div>
<div class="pure-control-group">
<a href="/subscription_manager?action_takeout=1&format=json"><%= I18n.translate(locale, "Export data as JSON") %></a>
<a href="/subscription_manager?action_takeout=1&format=json"><%= translate(locale, "Export data as JSON") %></a>
</div>
</fieldset>
</form>
+4 -4
View File
@@ -1,20 +1,20 @@
<% content_for "header" do %>
<title><%= I18n.translate(locale, "Delete account") %> - Invidious</title>
<title><%= translate(locale, "Delete account") %> - Invidious</title>
<% end %>
<div class="h-box">
<form class="pure-form pure-form-aligned" action="/delete_account?referer=<%= URI.encode_www_form(referer) %>" method="post">
<legend><%= I18n.translate(locale, "Delete account?") %></legend>
<legend><%= translate(locale, "Delete account?") %></legend>
<div class="pure-g">
<div class="pure-u-1-2">
<button type="submit" name="submit" value="delete_account" class="pure-button pure-button-primary">
<%= I18n.translate(locale, "Yes") %>
<%= translate(locale, "Yes") %>
</button>
</div>
<div class="pure-u-1-2">
<a class="pure-button" href="<%= URI.encode_www_form(referer) %>">
<%= I18n.translate(locale, "No") %>
<%= translate(locale, "No") %>
</a>
</div>
</div>
+8 -8
View File
@@ -1,5 +1,5 @@
<% content_for "header" do %>
<title><%= I18n.translate(locale, "Log in") %> - Invidious</title>
<title><%= translate(locale, "Log in") %> - Invidious</title>
<% end %>
<div class="pure-g">
@@ -13,15 +13,15 @@
<% if email %>
<input name="email" type="hidden" value="<%= HTML.escape(email) %>">
<% else %>
<label for="email"><%= I18n.translate(locale, "User ID") %> :</label>
<input required class="pure-input-1" name="email" type="text" placeholder="<%= I18n.translate(locale, "User ID") %>">
<label for="email"><%= translate(locale, "User ID") %> :</label>
<input required class="pure-input-1" name="email" type="text" placeholder="<%= translate(locale, "User ID") %>">
<% end %>
<% if password %>
<input name="password" type="hidden" value="<%= HTML.escape(password) %>">
<% else %>
<label for="password"><%= I18n.translate(locale, "Password") %> :</label>
<input required class="pure-input-1" name="password" type="password" placeholder="<%= I18n.translate(locale, "Password") %>">
<label for="password"><%= translate(locale, "Password") %> :</label>
<input required class="pure-input-1" name="password" type="password" placeholder="<%= translate(locale, "Password") %>">
<% end %>
<% if captcha %>
@@ -30,15 +30,15 @@
<% captcha[:tokens].each_with_index do |token, i| %>
<input type="hidden" name="token[<%= i %>]" value="<%= HTML.escape(token) %>">
<% end %>
<label for="answer"><%= I18n.translate(locale, "Time (h:mm:ss):") %></label>
<label for="answer"><%= translate(locale, "Time (h:mm:ss):") %></label>
<input type="text" name="answer" type="text" placeholder="h:mm:ss">
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">
<%= I18n.translate(locale, "Register") %>
<%= translate(locale, "Register") %>
</button>
<% else %>
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">
<%= I18n.translate(locale, "Sign In") %>/<%= I18n.translate(locale, "Register") %>
<%= translate(locale, "Sign In") %>/<%= translate(locale, "Register") %>
</button>
<% end %>
</fieldset>
+75 -81
View File
@@ -1,49 +1,49 @@
<% content_for "header" do %>
<title><%= I18n.translate(locale, "Preferences") %> - Invidious</title>
<title><%= translate(locale, "Preferences") %> - Invidious</title>
<% end %>
<div class="h-box">
<form class="pure-form pure-form-aligned" action="/preferences?referer=<%= URI.encode_www_form(referer) %>" method="post">
<fieldset>
<legend><%= I18n.translate(locale, "preferences_category_player") %></legend>
<legend><%= translate(locale, "preferences_category_player") %></legend>
<div class="pure-control-group">
<label for="video_loop"><%= I18n.translate(locale, "preferences_video_loop_label") %></label>
<label for="video_loop"><%= translate(locale, "preferences_video_loop_label") %></label>
<input name="video_loop" id="video_loop" type="checkbox" <% if preferences.video_loop %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="preload"><%= I18n.translate(locale, "preferences_preload_label") %></label>
<label for="preload"><%= translate(locale, "preferences_preload_label") %></label>
<input name="preload" id="preload" type="checkbox" <% if preferences.preload %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="autoplay"><%= I18n.translate(locale, "preferences_autoplay_label") %></label>
<label for="autoplay"><%= translate(locale, "preferences_autoplay_label") %></label>
<input name="autoplay" id="autoplay" type="checkbox" <% if preferences.autoplay %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="continue"><%= I18n.translate(locale, "preferences_continue_label") %></label>
<label for="continue"><%= translate(locale, "preferences_continue_label") %></label>
<input name="continue" id="continue" type="checkbox" <% if preferences.continue %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="continue_autoplay"><%= I18n.translate(locale, "preferences_continue_autoplay_label") %></label>
<label for="continue_autoplay"><%= translate(locale, "preferences_continue_autoplay_label") %></label>
<input name="continue_autoplay" id="continue_autoplay" type="checkbox" <% if preferences.continue_autoplay %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="local"><%= I18n.translate(locale, "preferences_local_label") %></label>
<label for="local"><%= translate(locale, "preferences_local_label") %></label>
<input name="local" id="local" type="checkbox" <% if preferences.local && !CONFIG.disabled?("local") %>checked<% end %> <% if CONFIG.disabled?("local") %>disabled<% end %>>
</div>
<div class="pure-control-group">
<label for="listen"><%= I18n.translate(locale, "preferences_listen_label") %></label>
<label for="listen"><%= translate(locale, "preferences_listen_label") %></label>
<input name="listen" id="listen" type="checkbox" <% if preferences.listen %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="speed"><%= I18n.translate(locale, "preferences_speed_label") %></label>
<label for="speed"><%= translate(locale, "preferences_speed_label") %></label>
<select name="speed" id="speed">
<% {2.0, 1.75, 1.5, 1.25, 1.0, 0.75, 0.5, 0.25}.each do |option| %>
<option <% if preferences.speed == option %> selected <% end %>><%= option %></option>
@@ -52,11 +52,11 @@
</div>
<div class="pure-control-group">
<label for="quality"><%= I18n.translate(locale, "preferences_quality_label") %></label>
<label for="quality"><%= translate(locale, "preferences_quality_label") %></label>
<select name="quality" id="quality">
<% {"dash", "hd720", "medium", "small"}.each do |option| %>
<% if !(option == "dash" && CONFIG.disabled?("dash")) %>
<option value="<%= option %>" <% if preferences.quality == option %> selected <% end %>><%= I18n.translate(locale, "preferences_quality_option_" + option) %></option>
<option value="<%= option %>" <% if preferences.quality == option %> selected <% end %>><%= translate(locale, "preferences_quality_option_" + option) %></option>
<% end %>
<% end %>
</select>
@@ -64,74 +64,74 @@
<% if !CONFIG.disabled?("dash") %>
<div class="pure-control-group">
<label for="quality_dash"><%= I18n.translate(locale, "preferences_quality_dash_label") %></label>
<label for="quality_dash"><%= translate(locale, "preferences_quality_dash_label") %></label>
<select name="quality_dash" id="quality_dash">
<% {"auto", "best", "4320p", "2160p", "1440p", "1080p", "720p", "480p", "360p", "240p", "144p", "worst"}.each do |option| %>
<option value="<%= option %>" <% if preferences.quality_dash == option %> selected <% end %>><%= I18n.translate(locale, "preferences_quality_dash_option_" + option) %></option>
<option value="<%= option %>" <% if preferences.quality_dash == option %> selected <% end %>><%= translate(locale, "preferences_quality_dash_option_" + option) %></option>
<% end %>
</select>
</div>
<% end %>
<div class="pure-control-group">
<label for="volume"><%= I18n.translate(locale, "preferences_volume_label") %></label>
<label for="volume"><%= translate(locale, "preferences_volume_label") %></label>
<input name="volume" id="volume" data-onrange="update_volume_value" type="range" min="0" max="100" step="5" value="<%= preferences.volume %>">
<span class="pure-form-message-inline" id="volume-value"><%= preferences.volume %></span>
</div>
<div class="pure-control-group">
<label for="comments[0]"><%= I18n.translate(locale, "preferences_comments_label") %></label>
<label for="comments[0]"><%= translate(locale, "preferences_comments_label") %></label>
<% preferences.comments.each_with_index do |comments, index| %>
<select name="comments[<%= index %>]" id="comments[<%= index %>]">
<% {"", "youtube", "reddit"}.each do |option| %>
<option value="<%= option %>" <% if preferences.comments[index] == option %> selected <% end %>><%= I18n.translate(locale, option.blank? ? "none" : option) %></option>
<option value="<%= option %>" <% if preferences.comments[index] == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
<% end %>
</div>
<div class="pure-control-group">
<label for="captions[0]"><%= I18n.translate(locale, "preferences_captions_label") %></label>
<label for="captions[0]"><%= translate(locale, "preferences_captions_label") %></label>
<% preferences.captions.each_with_index do |caption, index| %>
<select class="pure-u-1-6" name="captions[<%= index %>]" id="captions[<%= index %>]">
<% Invidious::Videos::Captions::LANGUAGES.each do |option| %>
<option value="<%= option %>" <% if preferences.captions[index] == option %> selected <% end %>><%= I18n.translate(locale, option.blank? ? "none" : option) %></option>
<option value="<%= option %>" <% if preferences.captions[index] == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
<% end %>
</div>
<div class="pure-control-group">
<label for="related_videos"><%= I18n.translate(locale, "preferences_related_videos_label") %></label>
<label for="related_videos"><%= translate(locale, "preferences_related_videos_label") %></label>
<input name="related_videos" id="related_videos" type="checkbox" <% if preferences.related_videos %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="annotations"><%= I18n.translate(locale, "preferences_annotations_label") %></label>
<label for="annotations"><%= translate(locale, "preferences_annotations_label") %></label>
<input name="annotations" id="annotations" type="checkbox" <% if preferences.annotations %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="extend_desc"><%= I18n.translate(locale, "preferences_extend_desc_label") %></label>
<label for="extend_desc"><%= translate(locale, "preferences_extend_desc_label") %></label>
<input name="extend_desc" id="extend_desc" type="checkbox" <% if preferences.extend_desc %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="vr_mode"><%= I18n.translate(locale, "preferences_vr_mode_label") %></label>
<label for="vr_mode"><%= translate(locale, "preferences_vr_mode_label") %></label>
<input name="vr_mode" id="vr_mode" type="checkbox" <% if preferences.vr_mode %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="save_player_pos"><%= I18n.translate(locale, "preferences_save_player_pos_label") %></label>
<label for="save_player_pos"><%= translate(locale, "preferences_save_player_pos_label") %></label>
<input name="save_player_pos" id="save_player_pos" type="checkbox" <% if preferences.save_player_pos %>checked<% end %>>
</div>
<% if user = env.get?("user").try &.as(User) %>
<% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %>
<div class="pure-control-group">
<label for="default_playlist"><%= I18n.translate(locale, "preferences_default_playlist") %></label>
<label for="default_playlist"><%= translate(locale, "preferences_default_playlist") %></label>
<select name="default_playlist" id="default_playlist">
<option value=""><%= I18n.translate(locale, "preferences_default_playlist_none") %></option>
<option value=""><%= translate(locale, "preferences_default_playlist_none") %></option>
<% playlists.each do |plid, playlist_title| %>
<option value="<%= plid %>" <%= "selected" if user.preferences.default_playlist == plid %>><%= HTML.escape(playlist_title) %></option>
<% end %>
@@ -139,46 +139,46 @@
</div>
<% end %>
<legend><%= I18n.translate(locale, "preferences_category_visual") %></legend>
<legend><%= translate(locale, "preferences_category_visual") %></legend>
<div class="pure-control-group">
<label for="locale"><%= I18n.translate(locale, "preferences_locale_label") %></label>
<label for="locale"><%= translate(locale, "preferences_locale_label") %></label>
<select name="locale" id="locale">
<% I18n::LOCALES_LIST.each do |iso_name, full_name| %>
<% LOCALES_LIST.each do |iso_name, full_name| %>
<option value="<%= iso_name %>" <% if preferences.locale == iso_name %> selected <% end %>><%= HTML.escape(full_name) %></option>
<% end %>
</select>
</div>
<div class="pure-control-group">
<label for="region"><%= I18n.translate(locale, "preferences_region_label") %></label>
<label for="region"><%= translate(locale, "preferences_region_label") %></label>
<select name="region" id="region">
<% I18n::CONTENT_REGIONS.each do |option| %>
<% CONTENT_REGIONS.each do |option| %>
<option value="<%= option %>" <% if preferences.region == option %> selected <% end %>><%= option %></option>
<% end %>
</select>
</div>
<div class="pure-control-group">
<label for="player_style"><%= I18n.translate(locale, "preferences_player_style_label") %></label>
<label for="player_style"><%= translate(locale, "preferences_player_style_label") %></label>
<select name="player_style" id="player_style">
<% {"invidious", "youtube"}.each do |option| %>
<option value="<%= option %>" <% if preferences.player_style == option %> selected <% end %>><%= I18n.translate(locale, option) %></option>
<option value="<%= option %>" <% if preferences.player_style == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %>
</select>
</div>
<div class="pure-control-group">
<label for="dark_mode"><%= I18n.translate(locale, "preferences_dark_mode_label") %></label>
<label for="dark_mode"><%= translate(locale, "preferences_dark_mode_label") %></label>
<select name="dark_mode" id="dark_mode">
<% {"", "light", "dark"}.each do |option| %>
<option value="<%= option %>" <% if preferences.dark_mode == option %> selected <% end %>><%= I18n.translate(locale, option.blank? ? "auto" : option) %></option>
<option value="<%= option %>" <% if preferences.dark_mode == option %> selected <% end %>><%= translate(locale, option.blank? ? "auto" : option) %></option>
<% end %>
</select>
</div>
<div class="pure-control-group">
<label for="thin_mode"><%= I18n.translate(locale, "preferences_thin_mode_label") %></label>
<label for="thin_mode"><%= translate(locale, "preferences_thin_mode_label") %></label>
<input name="thin_mode" id="thin_mode" type="checkbox" <% if preferences.thin_mode %>checked<% end %>>
</div>
@@ -189,193 +189,187 @@
<% end %>
<div class="pure-control-group">
<label for="default_home"><%= I18n.translate(locale, "preferences_default_home_label") %></label>
<label for="default_home"><%= translate(locale, "preferences_default_home_label") %></label>
<select name="default_home" id="default_home">
<% feed_options.each do |option| %>
<option value="<%= option %>" <% if preferences.default_home == option %> selected <% end %>><%= I18n.translate(locale, option.blank? ? "Search" : option) %></option>
<option value="<%= option %>" <% if preferences.default_home == option %> selected <% end %>><%= translate(locale, option.blank? ? "Search" : option) %></option>
<% end %>
</select>
</div>
<div class="pure-control-group">
<label for="feed_menu"><%= I18n.translate(locale, "preferences_feed_menu_label") %></label>
<label for="feed_menu"><%= translate(locale, "preferences_feed_menu_label") %></label>
<% (feed_options.size - 1).times do |index| %>
<select name="feed_menu[<%= index %>]" id="feed_menu[<%= index %>]">
<% feed_options.each do |option| %>
<option value="<%= option %>" <% if preferences.feed_menu[index]? == option %> selected <% end %>><%= I18n.translate(locale, option.blank? ? "Search" : option) %></option>
<option value="<%= option %>" <% if preferences.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option.blank? ? "Search" : option) %></option>
<% end %>
</select>
<% end %>
</div>
<% if env.get? "user" %>
<div class="pure-control-group">
<label for="show_nick"><%= I18n.translate(locale, "preferences_show_nick_label") %></label>
<label for="show_nick"><%= translate(locale, "preferences_show_nick_label") %></label>
<input name="show_nick" id="show_nick" type="checkbox" <% if preferences.show_nick %>checked<% end %>>
</div>
<% end %>
<legend><%= I18n.translate(locale, "preferences_category_misc") %></legend>
<legend><%= translate(locale, "preferences_category_misc") %></legend>
<div class="pure-control-group">
<label for="automatic_instance_redirect"><%= I18n.translate(locale, "preferences_automatic_instance_redirect_label") %></label>
<label for="automatic_instance_redirect"><%= translate(locale, "preferences_automatic_instance_redirect_label") %></label>
<input name="automatic_instance_redirect" id="automatic_instance_redirect" type="checkbox" <% if preferences.automatic_instance_redirect %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="search_privacy"><%= I18n.translate(locale, "preferences_search_privacy_label") %></label>
<input name="search_privacy" id="search_privacy" type="checkbox" <% if preferences.search_privacy %>checked<% end %>>
<span class="preference-description"><%= I18n.translate(locale, "preferences_search_privacy_description") %></span>
</div>
<% if env.get? "user" %>
<legend><%= I18n.translate(locale, "preferences_category_subscription") %></legend>
<legend><%= translate(locale, "preferences_category_subscription") %></legend>
<div class="pure-control-group">
<label for="watch_history"><%= I18n.translate(locale, "preferences_watch_history_label") %></label>
<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"><%= I18n.translate(locale, "preferences_annotations_subscribed_label") %></label>
<label for="annotations_subscribed"><%= translate(locale, "preferences_annotations_subscribed_label") %></label>
<input name="annotations_subscribed" id="annotations_subscribed" type="checkbox" <% if preferences.annotations_subscribed %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="max_results"><%= I18n.translate(locale, "preferences_max_results_label") %></label>
<label for="max_results"><%= translate(locale, "preferences_max_results_label") %></label>
<input name="max_results" id="max_results" type="number" value="<%= preferences.max_results %>">
</div>
<div class="pure-control-group">
<label for="sort"><%= I18n.translate(locale, "preferences_sort_label") %></label>
<label for="sort"><%= translate(locale, "preferences_sort_label") %></label>
<select name="sort" id="sort">
<% {"published", "published - reverse", "alphabetically", "alphabetically - reverse", "channel name", "channel name - reverse"}.each do |option| %>
<option value="<%= option %>" <% if preferences.sort == option %> selected <% end %>><%= I18n.translate(locale, option) %></option>
<option value="<%= option %>" <% if preferences.sort == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %>
</select>
</div>
<div class="pure-control-group">
<% if preferences.unseen_only %>
<label for="latest_only"><%= I18n.translate(locale, "Only show latest unwatched video from channel: ") %></label>
<label for="latest_only"><%= translate(locale, "Only show latest unwatched video from channel: ") %></label>
<% else %>
<label for="latest_only"><%= I18n.translate(locale, "Only show latest video from channel: ") %></label>
<label for="latest_only"><%= translate(locale, "Only show latest video from channel: ") %></label>
<% end %>
<input name="latest_only" id="latest_only" type="checkbox" <% if preferences.latest_only %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="unseen_only"><%= I18n.translate(locale, "preferences_unseen_only_label") %></label>
<label for="unseen_only"><%= translate(locale, "preferences_unseen_only_label") %></label>
<input name="unseen_only" id="unseen_only" type="checkbox" <% if preferences.unseen_only %>checked<% end %>>
</div>
<% if CONFIG.enable_user_notifications %>
<div class="pure-control-group">
<label for="notifications_only"><%= I18n.translate(locale, "preferences_notifications_only_label") %></label>
<label for="notifications_only"><%= translate(locale, "preferences_notifications_only_label") %></label>
<input name="notifications_only" id="notifications_only" type="checkbox" <% if preferences.notifications_only %>checked<% end %>>
</div>
<% # Web notifications are only supported over HTTPS %>
<% if Kemal.config.ssl || CONFIG.https_only %>
<div class="pure-control-group">
<a href="#" data-onclick="notification_requestPermission"><%= I18n.translate(locale, "Enable web notifications") %></a>
<a href="#" data-onclick="notification_requestPermission"><%= translate(locale, "Enable web notifications") %></a>
</div>
<% end %>
<% end %>
<% end %>
<% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(Invidious::User).email %>
<legend><%= I18n.translate(locale, "preferences_category_admin") %></legend>
<legend><%= translate(locale, "preferences_category_admin") %></legend>
<div class="pure-control-group">
<label for="admin_default_home"><%= I18n.translate(locale, "preferences_default_home_label") %></label>
<label for="admin_default_home"><%= translate(locale, "preferences_default_home_label") %></label>
<select name="admin_default_home" id="admin_default_home">
<% feed_options.each do |option| %>
<option value="<%= option %>" <% if CONFIG.default_user_preferences.default_home == option %> selected <% end %>><%= I18n.translate(locale, option.blank? ? "none" : option) %></option>
<option value="<%= option %>" <% if CONFIG.default_user_preferences.default_home == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
</div>
<div class="pure-control-group">
<label for="admin_feed_menu"><%= I18n.translate(locale, "preferences_feed_menu_label") %></label>
<label for="admin_feed_menu"><%= translate(locale, "preferences_feed_menu_label") %></label>
<% (feed_options.size - 1).times do |index| %>
<select name="admin_feed_menu[<%= index %>]" id="admin_feed_menu[<%= index %>]">
<% feed_options.each do |option| %>
<option value="<%= option %>" <% if CONFIG.default_user_preferences.feed_menu[index]? == option %> selected <% end %>><%= I18n.translate(locale, option.blank? ? "none" : option) %></option>
<option value="<%= option %>" <% if CONFIG.default_user_preferences.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
<% end %>
</div>
<div class="pure-control-group">
<label for="popular_enabled"><%= I18n.translate(locale, "Popular enabled: ") %></label>
<label for="popular_enabled"><%= translate(locale, "Popular enabled: ") %></label>
<input name="popular_enabled" id="popular_enabled" type="checkbox" <% if CONFIG.popular_enabled %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="captcha_enabled"><%= I18n.translate(locale, "CAPTCHA enabled: ") %></label>
<label for="captcha_enabled"><%= translate(locale, "CAPTCHA enabled: ") %></label>
<input name="captcha_enabled" id="captcha_enabled" type="checkbox" <% if CONFIG.captcha_enabled %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="login_enabled"><%= I18n.translate(locale, "Login enabled: ") %></label>
<label for="login_enabled"><%= translate(locale, "Login enabled: ") %></label>
<input name="login_enabled" id="login_enabled" type="checkbox" <% if CONFIG.login_enabled %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="registration_enabled"><%= I18n.translate(locale, "Registration enabled: ") %></label>
<label for="registration_enabled"><%= translate(locale, "Registration enabled: ") %></label>
<input name="registration_enabled" id="registration_enabled" type="checkbox" <% if CONFIG.registration_enabled %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="statistics_enabled"><%= I18n.translate(locale, "Report statistics: ") %></label>
<label for="statistics_enabled"><%= translate(locale, "Report statistics: ") %></label>
<input name="statistics_enabled" id="statistics_enabled" type="checkbox" <% if CONFIG.statistics_enabled %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="modified_source_code_url"><%= I18n.translate(locale, "adminprefs_modified_source_code_url_label") %></label>
<label for="modified_source_code_url"><%= translate(locale, "adminprefs_modified_source_code_url_label") %></label>
<input name="modified_source_code_url" id="modified_source_code_url" type="url" value="<%= CONFIG.modified_source_code_url %>">
</div>
<% end %>
<% if env.get? "user" %>
<legend><%= I18n.translate(locale, "preferences_category_data") %></legend>
<legend><%= translate(locale, "preferences_category_data") %></legend>
<div class="pure-control-group">
<a href="/clear_watch_history?referer=<%= URI.encode_www_form(referer) %>"><%= I18n.translate(locale, "Clear watch history") %></a>
<a href="/clear_watch_history?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Clear watch history") %></a>
</div>
<div class="pure-control-group">
<a href="/change_password?referer=<%= URI.encode_www_form(referer) %>"><%= I18n.translate(locale, "Change password") %></a>
<a href="/change_password?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Change password") %></a>
</div>
<div class="pure-control-group">
<a href="/data_control?referer=<%= URI.encode_www_form(referer) %>"><%= I18n.translate(locale, "Import/export data") %></a>
<a href="/data_control?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Import/export data") %></a>
</div>
<div class="pure-control-group">
<a href="/subscription_manager"><%= I18n.translate(locale, "Manage subscriptions") %></a>
<a href="/subscription_manager"><%= translate(locale, "Manage subscriptions") %></a>
</div>
<div class="pure-control-group">
<a href="/token_manager"><%= I18n.translate(locale, "Manage tokens") %></a>
<a href="/token_manager"><%= translate(locale, "Manage tokens") %></a>
</div>
<div class="pure-control-group">
<a href="/feed/playlists"><%= I18n.translate(locale, "View all playlists") %></a>
<a href="/feed/playlists"><%= translate(locale, "View all playlists") %></a>
</div>
<div class="pure-control-group">
<a href="/feed/history"><%= I18n.translate(locale, "Watch history") %></a>
<a href="/feed/history"><%= translate(locale, "Watch history") %></a>
</div>
<div class="pure-control-group">
<a href="/delete_account?referer=<%= URI.encode_www_form(referer) %>"><%= I18n.translate(locale, "Delete account") %></a>
<a href="/delete_account?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Delete account") %></a>
</div>
<% end %>
<div class="pure-controls">
<button type="submit" class="pure-button pure-button-primary"><%= I18n.translate(locale, "Save preferences") %></button>
<button type="submit" class="pure-button pure-button-primary"><%= translate(locale, "Save preferences") %></button>
</div>
</fieldset>
</form>
@@ -1,26 +1,26 @@
<% content_for "header" do %>
<title><%= I18n.translate(locale, "Subscription manager") %> - Invidious</title>
<title><%= translate(locale, "Subscription manager") %> - Invidious</title>
<% end %>
<div class="pure-g h-box">
<div class="pure-u-1-3">
<h3>
<a href="/feed/subscriptions">
<%= I18n.translate_count(locale, "generic_subscriptions_count", subscriptions.size, I18n::NumberFormatting::HtmlSpan) %>
<%= translate_count(locale, "generic_subscriptions_count", subscriptions.size, NumberFormatting::HtmlSpan) %>
</a>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:center">
<a href="/feed/history">
<%= I18n.translate(locale, "Watch history") %>
<%= translate(locale, "Watch history") %>
</a>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:right">
<a href="/data_control?referer=<%= URI.encode_www_form(referer) %>">
<%= I18n.translate(locale, "Import/export") %>
<%= translate(locale, "Import/export") %>
</a>
</h3>
</div>
@@ -39,7 +39,7 @@
<h3 style="padding-right:0.5em">
<form data-onsubmit="return_false" action="/subscription_ajax?action=remove_subscriptions&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<input style="all:unset" type="submit" data-onclick="remove_subscription" data-ucid="<%= channel.id %>" value="<%= I18n.translate(locale, "unsubscribe") %>">
<input style="all:unset" type="submit" data-onclick="remove_subscription" data-ucid="<%= channel.id %>" value="<%= translate(locale, "unsubscribe") %>">
</form>
</h3>
</div>

Some files were not shown because too many files have changed in this diff Show More