mirror of
https://github.com/iv-org/invidious.git
synced 2026-06-27 10:54:06 +05:30
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4b9abbfcf | |||
| e689588fab | |||
| 08f862292a | |||
| 73a1bacea8 | |||
| c435dc1204 | |||
| 6dec63a3e5 | |||
| 067260a4ab | |||
| 6b21daab56 | |||
| 8f279745db | |||
| 98f4f118b2 | |||
| 85534a988d | |||
| 0e0ee40cb6 | |||
| e96ad036ca | |||
| 1a5a71b086 | |||
| 3a35552a66 | |||
| 8ef5ea03d4 | |||
| 86c425b43f | |||
| 4ae227ce91 | |||
| 8b183caa2a | |||
| edb3a0fc36 | |||
| 6659cbbbd8 | |||
| 99390d065d | |||
| e82ac674ae | |||
| 103f80e617 | |||
| 9ee39bc437 | |||
| 529fc8a8a3 | |||
| b4728b81dc | |||
| f914ce8040 | |||
| 57ba257233 | |||
| e012334975 | |||
| 85a078a580 | |||
| afea61bb8f | |||
| fd313e0107 | |||
| 0c600988ca | |||
| 264e7c24e9 | |||
| 9eda6e5bc4 | |||
| 73c749f13f | |||
| bc64cd9b67 | |||
| 54365c0e2a | |||
| 606467c693 | |||
| 749791cdf1 | |||
| d7361cbb9a | |||
| f07c9a7209 | |||
| cf9b6c4fcb | |||
| 21d0d1041a | |||
| fda8d1b528 | |||
| e7f8b15b21 | |||
| 60c31e3069 | |||
| 11db343cfb |
@@ -29,7 +29,7 @@ jobs:
|
||||
- os: ubuntu-24.04-arm
|
||||
platform: linux/arm64/v8
|
||||
name: "ARM64"
|
||||
dockerfile: "docker/Dockerfile.arm64"
|
||||
dockerfile: "docker/Dockerfile"
|
||||
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@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: quay.io/invidious/invidious
|
||||
flavor: |
|
||||
@@ -62,13 +62,34 @@ jobs:
|
||||
quay.expires-after=12w
|
||||
|
||||
- name: Build and push Docker ${{ matrix.name }} image for Push Event
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
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
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- os: ubuntu-24.04-arm
|
||||
platform: linux/arm64/v8
|
||||
name: "ARM64"
|
||||
dockerfile: "docker/Dockerfile.arm64"
|
||||
dockerfile: "docker/Dockerfile"
|
||||
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@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: quay.io/invidious/invidious
|
||||
flavor: |
|
||||
@@ -54,13 +54,34 @@ jobs:
|
||||
quay.expires-after=12w
|
||||
|
||||
- name: Build and push Docker ${{ matrix.name }} image for Push Event
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
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
|
||||
|
||||
@@ -43,6 +43,8 @@ jobs:
|
||||
- 1.16.3
|
||||
- 1.17.1
|
||||
- 1.18.2
|
||||
- 1.19.2
|
||||
- 1.20.2
|
||||
include:
|
||||
- crystal: nightly
|
||||
stable: false
|
||||
@@ -58,7 +60,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Crystal
|
||||
uses: crystal-lang/install-crystal@v1.9.1
|
||||
uses: crystal-lang/install-crystal@v1.9.2
|
||||
with:
|
||||
crystal: ${{ matrix.crystal }}
|
||||
|
||||
@@ -80,7 +82,7 @@ jobs:
|
||||
run: crystal spec
|
||||
|
||||
- name: Build
|
||||
run: crystal build --warnings all --error-on-warnings --error-trace src/invidious.cr
|
||||
run: crystal build --warnings all --error-on-warnings --stats --time --progress --error-trace src/invidious.cr
|
||||
|
||||
build-docker:
|
||||
strategy:
|
||||
@@ -98,10 +100,6 @@ 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
|
||||
|
||||
@@ -134,7 +132,7 @@ jobs:
|
||||
|
||||
- name: Install Crystal
|
||||
id: lint_step_install_crystal
|
||||
uses: crystal-lang/install-crystal@v1.9.1
|
||||
uses: crystal-lang/install-crystal@v1.9.2
|
||||
with:
|
||||
crystal: latest
|
||||
|
||||
|
||||
@@ -1,5 +1,94 @@
|
||||
# CHANGELOG
|
||||
|
||||
## vX.Y.0 (future)
|
||||
|
||||
## v2.20260626.0
|
||||
|
||||
### Wrap-up
|
||||
|
||||
This release hardens playlists, channels, and search, adds a privacy option for searches, and modernizes the packaging and CI pipeline.
|
||||
|
||||
Searches can now be submitted via `POST` so queries do not leak into server logs or browser history, Invidious cookies work across alternative domains, and "Watch on YouTube" / embed redirects use the correct timestamp and host. Playlist and channel parsing issues got fixed: outdated playlist parsing that hid all videos, paid course videos breaking imports, RSS feeds exposing private playlists without auth, broken author verification badges, and channel videos/playlists not loading from search. Thumbnail paths `/pl_c` / `/tvfilm_banner` are now supported, YouTube comments that were written in Japanese, Chinese, Korean and probably other languages do not longer swallows the last character when an emoji is present in the comment, and the search filters dropped the deprecated "sort by rating/date" options.
|
||||
|
||||
Packaging moves Docker builds to the 84codes Crystal compiler image, updates OpenSSL to 3.6.2 and Crystal to 1.20.x in OCI, bumps Alpine to 3.24, and unifies the ARM64 and AMD64 Dockerfiles. Developers benefit from continued encapsulation of constants/helpers/translation/video-parser logic into dedicated modules, an `api/v1/channels.cr` lint pass, trailing-whitespace cleanup, and a sweep of dependency and GitHub Actions bumps.
|
||||
|
||||
### New features & important changes
|
||||
#### For Users
|
||||
- Searches can be submitted through `POST` requests so queries stay out of URLs, server logs and browser history (#5551)
|
||||
- Invidious cookies are honoured across alternative configured domains (#5647)
|
||||
- Embed and "Watch on YouTube" redirects use the correct `t`/`start` parameter and the `www.youtube.com` host consistently (#5660, #5768)
|
||||
- The `referrerpolicy`/`noreferrer` handling was corrected now that YouTube requires referrers on embeds (#5642)
|
||||
- The listen button on the title updates its elapsed time, and the unused "sort by rating/date" search filter options were removed (#5625, #5629)
|
||||
|
||||
#### For instance owners
|
||||
- Docker builds switched to the 84codes Crystal compiler container image, and OCI images were updated to Crystal 1.20.x with OpenSSL 3.6.2 (#5473, #5692)
|
||||
- Alpine was bumped to 3.24 in the Docker image (#5778)
|
||||
- ARM64 and AMD64 Dockerfiles were unified into a single workflow (#5700)
|
||||
|
||||
#### For developers
|
||||
- Constants and functions were encapsulated into dedicated `I18n`, `Helpers`, `Invidious::Videos::Parser` and `Invidious::Videos::Clip` modules (#5637, #5639, #5745)
|
||||
- `api/v1/channels.cr` received a lint pass and trailing whitespaces were removed from the codebase (#5693, #5634)
|
||||
- CI bumped the Crystal version matrix and displayed compile progress/stats, and the `crystal-lang/install-crystal` action was updated (#5691, #5696, #5703, #5686)
|
||||
|
||||
### Bugs fixed
|
||||
#### User-side
|
||||
- Playlists showed no videos because of outdated playlist parsing; this is fixed along with paid course videos breaking the importer (#5774, #5207)
|
||||
- Private Invidious playlists were reachable through RSS feeds without authentication (#5776)
|
||||
- Channel videos and playlists failed to load from search, and channel author verification was broken (#5736, #5751)
|
||||
- A missing `collectionThumbnailViewModel` hash key crashed channel browsing (#5725)
|
||||
- The `quality=medium` query parameter was appended to videos about to premiere (#5755)
|
||||
- YouTube/Invidious links did not rewind their timestamp when playback position was rewound (#5601)
|
||||
- The last character of a comment was lost when the comment contained emoji (#5587)
|
||||
- Playlist RSS `watch` URLs only joined params with `&` when params were present, and thumbnail paths `/pl_c` and `/tvfilm_banner` are now supported (#5646, #5742)
|
||||
|
||||
#### For instance owners
|
||||
- Docker/OCI builds keep current with Crystal 1.20.1, OpenSSL 3.6.2, Alpine 3.24 and the unified multi-arch Dockerfile (#5703, #5701, #5778, #5700)
|
||||
|
||||
#### For developers
|
||||
- Dependency and GitHub Actions bumps kept CI current: `docker/login-action`, `build-push-action`, `metadata-action`, `setup-buildx-action`, `int128/docker-manifest-create-action` and `crystal-lang/install-crystal` (#5705, #5766, #5721, #5686, #5661, #5662, #5663, #5664)
|
||||
|
||||
### Full list of pull requests merged since the last release (newest first)
|
||||
|
||||
* fix: fix playlists not showing any videos due to outdated playlist parsing (https://github.com/iv-org/invidious/pull/5774, by @Fijxu)
|
||||
* chore(deps): bump alpine from 3.23 to 3.24 in /docker (https://github.com/iv-org/invidious/pull/5778, by @dependabot[bot])
|
||||
* fix: fix private invidious playlists on rss feeds from being fetched without authentication (https://github.com/iv-org/invidious/pull/5776, by @Fijxu)
|
||||
* Use "www.youtube.com" consistently (https://github.com/iv-org/invidious/pull/5768, by @janmoesen)
|
||||
* chore(deps): bump int128/docker-manifest-create-action from 2.21.0 to 2.22.0 (https://github.com/iv-org/invidious/pull/5766, by @dependabot[bot])
|
||||
* Add support for alternative domains for Invidious cookies (https://github.com/iv-org/invidious/pull/5647, by @Fijxu)
|
||||
* Only include '&' if params are present in `watch` urls for playlist RSS (https://github.com/iv-org/invidious/pull/5646, by @Fijxu)
|
||||
* Dockerfile: Switch to 84codes crystal compiler container image (https://github.com/iv-org/invidious/pull/5473, by @Fijxu)
|
||||
* fix: Do not append query params `quality=medium` to videos that are about to premiere (https://github.com/iv-org/invidious/pull/5755, by @Fijxu)
|
||||
* Fix Youtube and Invidious links not rewinding their time when video playback position is rewound (https://github.com/iv-org/invidious/pull/5601, by @Fijxu)
|
||||
* feat: Add support for POST requests on searches for privacy (https://github.com/iv-org/invidious/pull/5551, by @Fijxu)
|
||||
* Fix last character disappearance if emoji are in comment (https://github.com/iv-org/invidious/pull/5587, by @shiny-comic)
|
||||
* Encapsulate videos parser and clip functions inside it's own `Invidious::Videos::Parser` and `Invidious::Videos::Clip` module (https://github.com/iv-org/invidious/pull/5745, by @Fijxu)
|
||||
* fix: fix author verification in channels (https://github.com/iv-org/invidious/pull/5751, by @Fijxu)
|
||||
* Add support for `/pl_c` and `/tvfilm_banner` paths (thumbnails used in some playlists) (https://github.com/iv-org/invidious/pull/5742, by @Fijxu)
|
||||
* fix: fix channel videos and playlists on searches (https://github.com/iv-org/invidious/pull/5736, by @Fijxu)
|
||||
* fix: fix `Missing hash key: "collectionThumbnailViewModel"` (https://github.com/iv-org/invidious/pull/5725, by @Fijxu)
|
||||
* chore(deps): bump int128/docker-manifest-create-action from 2.20.0 to 2.21.0 (https://github.com/iv-org/invidious/pull/5721, by @dependabot[bot])
|
||||
* chore: update openssl to 3.6.2 in OCI (https://github.com/iv-org/invidious/pull/5701, by @Fijxu)
|
||||
* Bump int128/docker-manifest-create-action from 2.19.0 to 2.20.0 (https://github.com/iv-org/invidious/pull/5705, by @dependabot[bot])
|
||||
* CI: Unify ARM64 and AMD64 Dockerfiles (https://github.com/iv-org/invidious/pull/5700, by @Fijxu)
|
||||
* CI: update Crystal 1.20.0 to 1.20.1 in ci.yml matrix (https://github.com/iv-org/invidious/pull/5703, by @Fijxu)
|
||||
* CI: display progress and stats when compiling Invidious in ci.yml matrix (https://github.com/iv-org/invidious/pull/5696, by @Fijxu)
|
||||
* CI: Bump Crystal version matrix (https://github.com/iv-org/invidious/pull/5691, by @Fijxu)
|
||||
* chore: update Crystal to 1.20.0 in OCI (https://github.com/iv-org/invidious/pull/5692, by @Fijxu)
|
||||
* player: Use correct time parameter for YouTube embed redirects (https://github.com/iv-org/invidious/pull/5660, by @radmorecameron)
|
||||
* chore: lint api/v1/channels.cr (https://github.com/iv-org/invidious/pull/5693, by @Fijxu)
|
||||
* Encapsulate helpers constants and functions inside it's own `Helpers` module (https://github.com/iv-org/invidious/pull/5639, by @Fijxu)
|
||||
* Encapsulate translation constants and functions inside it's own `I18n` module (https://github.com/iv-org/invidious/pull/5637, by @Fijxu)
|
||||
* Bump crystal-lang/install-crystal from 1.9.1 to 1.9.2 (https://github.com/iv-org/invidious/pull/5686, by @dependabot[bot])
|
||||
* Playlists: fix parsing error when some videos are paid for in a course (https://github.com/iv-org/invidious/pull/5207, by @ChunkyProgrammer)
|
||||
* Bump docker/login-action from 3 to 4 (https://github.com/iv-org/invidious/pull/5661, by @dependabot[bot])
|
||||
* Bump docker/build-push-action from 6 to 7 (https://github.com/iv-org/invidious/pull/5662, by @dependabot[bot])
|
||||
* Bump docker/metadata-action from 5 to 6 (https://github.com/iv-org/invidious/pull/5663, by @dependabot[bot])
|
||||
* Bump docker/setup-buildx-action from 3 to 4 (https://github.com/iv-org/invidious/pull/5664, by @dependabot[bot])
|
||||
* Remove noreferrer since youtube now requires referrers on embeds (https://github.com/iv-org/invidious/pull/5642, by @ashleyirispuppy143)
|
||||
* Remove trailing whitespaces from codebase (https://github.com/iv-org/invidious/pull/5634, by @Fijxu)
|
||||
* Add title listen button time updates (https://github.com/iv-org/invidious/pull/5625, by @JeroenBoersma)
|
||||
* Remove sort by rating and date in video search filters (https://github.com/iv-org/invidious/pull/5629, by @Fijxu)
|
||||
|
||||
## v2.20260207.0
|
||||
|
||||
### Wrap-up
|
||||
|
||||
@@ -902,4 +902,11 @@ 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;
|
||||
}
|
||||
@@ -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); }
|
||||
};
|
||||
|
||||
+15
-8
@@ -104,14 +104,15 @@ if (video_data.params.quality === 'dash') {
|
||||
*
|
||||
* @param {String} url
|
||||
* @param {String} [base]
|
||||
* @param {'t' | 'start'} param
|
||||
* @returns {URL} urlWithTimeArg
|
||||
*/
|
||||
function addCurrentTimeToURL(url, base) {
|
||||
function addCurrentTimeToURL(url, base, param = 't') {
|
||||
var urlUsed = new URL(url, base);
|
||||
urlUsed.searchParams.delete('start');
|
||||
var currentTime = Math.ceil(player.currentTime());
|
||||
if (currentTime > 0)
|
||||
urlUsed.searchParams.set('t', currentTime);
|
||||
urlUsed.searchParams.set(param, currentTime);
|
||||
else if (urlUsed.searchParams.has('t'))
|
||||
urlUsed.searchParams.delete('t');
|
||||
return urlUsed;
|
||||
@@ -132,7 +133,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
|
||||
@@ -143,11 +144,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);
|
||||
elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed, undefined, 'start');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,12 +161,18 @@ 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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -628,7 +635,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';
|
||||
@@ -733,7 +740,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;
|
||||
|
||||
|
||||
@@ -53,7 +53,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 +63,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,6 +151,26 @@ 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
|
||||
@@ -232,7 +252,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
|
||||
@@ -885,7 +905,7 @@ default_user_preferences:
|
||||
## Default: true
|
||||
##
|
||||
#vr_mode: true
|
||||
|
||||
|
||||
##
|
||||
## Save the playback position
|
||||
## Allow to continue watching at the previous position when
|
||||
|
||||
+6
-6
@@ -1,8 +1,8 @@
|
||||
# https://github.com/openssl/openssl/releases/tag/openssl-3.5.2
|
||||
ARG OPENSSL_VERSION='3.5.2'
|
||||
ARG OPENSSL_SHA256='c53a47e5e441c930c3928cf7bf6fb00e5d129b630e0aa873b08258656e7345ec'
|
||||
# https://github.com/openssl/openssl/releases/tag/openssl-3.6.2
|
||||
ARG OPENSSL_VERSION='3.6.2'
|
||||
ARG OPENSSL_SHA256='aaf51a1fe064384f811daeaeb4ec4dce7340ec8bd893027eee676af31e83a04f'
|
||||
|
||||
FROM crystallang/crystal:1.16.3-alpine AS dependabot-crystal
|
||||
FROM 84codes/crystal:1.20.2-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}
|
||||
@@ -61,7 +61,7 @@ RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ;
|
||||
--link-flags "-lxml2 -llzma"; \
|
||||
fi
|
||||
|
||||
FROM alpine:3.23
|
||||
FROM alpine:3.24
|
||||
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata
|
||||
WORKDIR /invidious
|
||||
RUN addgroup -g 1000 -S invidious && \
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# 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" ]
|
||||
@@ -124,6 +124,8 @@
|
||||
"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",
|
||||
|
||||
@@ -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}\\">\#{translate(locale, "source")}</a></td>
|
||||
<td><a href=\\"#{source_location}\\">\#{I18n.translate(locale, "source")}</a></td>
|
||||
</tr>"
|
||||
HTML
|
||||
|
||||
|
||||
@@ -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 +1,5 @@
|
||||
name: invidious
|
||||
version: 2.20260207.0
|
||||
version: 2.20260626.0
|
||||
|
||||
authors:
|
||||
- Invidious team <contact@invidious.io>
|
||||
|
||||
@@ -48,9 +48,7 @@ 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 = parse_video_info("2isYuQZMbdU", raw_data)
|
||||
info = Invidious::Videos::Parser.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 = parse_video_info("iuevw6218F0", raw_data)
|
||||
info = Invidious::Videos::Parser.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 = parse_video_info("N-yVic7BbY0", raw_data)
|
||||
info = Invidious::Videos::Parser.parse_video_info("N-yVic7BbY0", raw_data)
|
||||
|
||||
# Some basic verifications
|
||||
expect(typeof(info)).to eq(Hash(String, JSON::Any))
|
||||
|
||||
@@ -83,11 +83,16 @@ 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_verified = has_verified_badge?(initdata.dig?("header", "c4TabbedHeaderRenderer", "badges"))
|
||||
|
||||
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
|
||||
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?
|
||||
|
||||
@@ -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", translate(locale, "`x` ago", recode_date(self.published, locale))
|
||||
json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(self.published, locale))
|
||||
|
||||
json.field "viewCount", self.views
|
||||
end
|
||||
|
||||
@@ -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", html_to_content(content_html)
|
||||
json.field "content", Helpers.html_to_content(content_html)
|
||||
json.field "contentHtml", content_html
|
||||
|
||||
json.field "published", published.to_unix
|
||||
json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
|
||||
json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(published, locale))
|
||||
|
||||
json.field "likeCount", like_count
|
||||
json.field "replyCount", reply_count
|
||||
|
||||
@@ -254,7 +254,7 @@ module Invidious::Comments
|
||||
end
|
||||
|
||||
content_html = html_content || ""
|
||||
json.field "content", html_to_content(content_html)
|
||||
json.field "content", Helpers.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", translate(locale, "`x` ago", recode_date(published, locale))
|
||||
json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(published, locale))
|
||||
end
|
||||
|
||||
if node_replies && !response["commentRepliesContinuation"]?
|
||||
|
||||
@@ -54,6 +54,7 @@ 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 %}
|
||||
@@ -120,6 +121,8 @@ 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
|
||||
|
||||
@@ -28,14 +28,14 @@ module Invidious::Frontend::ChannelPage
|
||||
|
||||
if tab == selected_tab
|
||||
str << "\t<b>"
|
||||
str << translate(locale, "channel_tab_#{tab_name}_label")
|
||||
str << I18n.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 << translate(locale, "channel_tab_#{tab_name}_label")
|
||||
str << I18n.translate(locale, "channel_tab_#{tab_name}_label")
|
||||
str << "</a>\n"
|
||||
end
|
||||
|
||||
|
||||
@@ -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>
|
||||
#{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>
|
||||
#{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>
|
||||
</p>
|
||||
<div>
|
||||
#{body_html}
|
||||
|
||||
@@ -6,10 +6,10 @@ module Invidious::Frontend::Comments
|
||||
root = comments["comments"].as_a
|
||||
root.each do |child|
|
||||
if child["replies"]?
|
||||
replies_count_text = translate_count(locale,
|
||||
replies_count_text = I18n.translate_count(locale,
|
||||
"comments_view_x_replies",
|
||||
child["replies"]["replyCount"].as_i64 || 0,
|
||||
NumberFormatting::Separator
|
||||
I18n::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 = translate_count(locale,
|
||||
replies_count_text = I18n.translate_count(locale,
|
||||
"comments_view_x_replies",
|
||||
child["replyCount"].as_i64 || 0,
|
||||
NumberFormatting::Separator
|
||||
I18n::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=") << translate(locale, "Channel Sponsor") << "\" "
|
||||
str << %(title=") << I18n.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"]}">#{translate(locale, "carousel_skip")}</a>
|
||||
<a class="skip-link" href="#skip-#{child["commentId"]}">#{I18n.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="#{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="#{I18n.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="#{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="#{I18n.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(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>
|
||||
<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>
|
||||
|
|
||||
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="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
||||
<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>
|
||||
|
|
||||
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="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
||||
<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>
|
||||
|
|
||||
END_HTML
|
||||
end
|
||||
@@ -172,7 +172,7 @@ module Invidious::Frontend::Comments
|
||||
|
||||
html << <<-END_HTML
|
||||
|
||||
<span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}">
|
||||
<span class="creator-heart-container" title="#{I18n.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}>#{translate(locale, "Load more")}</a>
|
||||
data-onclick="get_youtube_replies" data-load-more #{"data-load-replies" if is_replies}>#{I18n.translate(locale, "Load more")}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 locale_is_rtl?(locale)
|
||||
if I18n.locale_is_rtl?(locale)
|
||||
# Inverted arrow ("first" points to the right)
|
||||
str << translate(locale, "First page")
|
||||
str << I18n.translate(locale, "First page")
|
||||
str << " "
|
||||
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 << " "
|
||||
str << translate(locale, "First page")
|
||||
str << I18n.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 locale_is_rtl?(locale)
|
||||
if I18n.locale_is_rtl?(locale)
|
||||
# Inverted arrow ("previous" points to the right)
|
||||
str << translate(locale, "Previous page")
|
||||
str << I18n.translate(locale, "Previous page")
|
||||
str << " "
|
||||
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 << " "
|
||||
str << translate(locale, "Previous page")
|
||||
str << I18n.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 locale_is_rtl?(locale)
|
||||
if I18n.locale_is_rtl?(locale)
|
||||
# Inverted arrow ("next" points to the left)
|
||||
str << %(<i class="icon ion-ios-arrow-back"></i>)
|
||||
str << " "
|
||||
str << translate(locale, "Next page")
|
||||
str << I18n.translate(locale, "Next page")
|
||||
else
|
||||
# Regular arrow ("next" points to the right)
|
||||
str << translate(locale, "Next page")
|
||||
str << I18n.translate(locale, "Next page")
|
||||
str << " "
|
||||
str << %(<i class="icon ion-ios-arrow-forward"></i>)
|
||||
end
|
||||
|
||||
@@ -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>" << translate(locale, "search_filters_title") << "</summary>\n"
|
||||
str << "\t\t<summary>" << I18n.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 << translate(locale, "search_filters_apply_button")
|
||||
str << I18n.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 << translate(locale, "search_filters_{{name}}_label")
|
||||
str << I18n.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 << translate(locale, "search_filters_date_option_{{date}}")
|
||||
str << I18n.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 << translate(locale, "search_filters_type_option_{{type}}")
|
||||
str << I18n.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 << translate(locale, "search_filters_duration_option_{{duration}}")
|
||||
str << I18n.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 << translate(locale, "search_filters_features_option_{{feature}}")
|
||||
str << I18n.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 << translate(locale, "search_filters_sort_option_{{sort}}")
|
||||
str << I18n.translate(locale, "search_filters_sort_option_{{sort}}")
|
||||
str << "</label></div>\n"
|
||||
{% end %}
|
||||
end
|
||||
|
||||
@@ -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\">#{translate(locale, "Download is disabled")}</p>"
|
||||
return "<p id=\"download\">#{I18n.translate(locale, "Download is disabled")}</p>"
|
||||
end
|
||||
|
||||
if CONFIG.dmca_content.includes?(video.id)
|
||||
return "<p id=\"download\">#{translate(locale, "dmca_content")}</p>"
|
||||
return "<p id=\"download\">#{I18n.translate(locale, "dmca_content")}</p>"
|
||||
end
|
||||
|
||||
url = "/download"
|
||||
@@ -49,7 +49,7 @@ module Invidious::Frontend::WatchPage
|
||||
str << "\t<div class=\"pure-control-group\">\n"
|
||||
|
||||
str << "\t\t<label for='download_widget'>"
|
||||
str << translate(locale, "Download as: ")
|
||||
str << I18n.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 << translate(locale, "download_subtitles", translate(locale, caption.name))
|
||||
str << I18n.translate(locale, "download_subtitles", I18n.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>" << translate(locale, "Download") << "</b>\n"
|
||||
str << "\t\t<b>" << I18n.translate(locale, "Download") << "</b>\n"
|
||||
str << "\t</button>\n"
|
||||
|
||||
str << "</form>\n"
|
||||
|
||||
@@ -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>#{translate(locale, "crash_page_you_found_a_bug")}</h2>
|
||||
<h2>#{I18n.translate(locale, "crash_page_you_found_a_bug")}</h2>
|
||||
<br/><br/>
|
||||
|
||||
<p><b>#{translate(locale, "crash_page_before_reporting")}</b></p>
|
||||
<p><b>#{I18n.translate(locale, "crash_page_before_reporting")}</b></p>
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
|
||||
<br/>
|
||||
<p>#{translate(locale, "crash_page_report_issue", url_new_issue)}</p>
|
||||
<p>#{I18n.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 = translate(locale, message)
|
||||
error_message = I18n.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 = 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")
|
||||
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")
|
||||
|
||||
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://youtube.com#{env.request.resource}">#{go_to_youtube}</a>
|
||||
<a rel="noreferrer noopener" href="https://www.youtube.com#{env.request.resource}">#{go_to_youtube}</a>
|
||||
</li>
|
||||
</ul>
|
||||
END_HTML
|
||||
|
||||
+140
-136
@@ -1,7 +1,5 @@
|
||||
require "./macros"
|
||||
|
||||
TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
|
||||
|
||||
struct Nonce
|
||||
include DB::Serializable
|
||||
|
||||
@@ -24,60 +22,124 @@ struct Annotation
|
||||
property annotations : String
|
||||
end
|
||||
|
||||
def html_to_content(description_html : String)
|
||||
description = description_html.gsub(/(<br>)|(<br\/>)/, {
|
||||
"<br>": "\n",
|
||||
"<br/>": "\n",
|
||||
})
|
||||
module Helpers
|
||||
extend self
|
||||
|
||||
if !description.empty?
|
||||
description = XML.parse_html(description).content.strip("\n ")
|
||||
end
|
||||
private TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
|
||||
|
||||
return description
|
||||
end
|
||||
def html_to_content(description_html : String)
|
||||
description = description_html.gsub(/(<br>)|(<br\/>)/, {
|
||||
"<br>": "\n",
|
||||
"<br/>": "\n",
|
||||
})
|
||||
|
||||
def cache_annotation(id, annotations)
|
||||
if !CONFIG.cache_annotations
|
||||
return
|
||||
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
|
||||
if !description.empty?
|
||||
description = XML.parse_html(description).content.strip("\n ")
|
||||
end
|
||||
|
||||
return description
|
||||
end
|
||||
|
||||
Invidious::Database::Annotations.insert(id, annotations) if has_legacy_annotations
|
||||
end
|
||||
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
|
||||
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
|
||||
end
|
||||
|
||||
def create_notification_stream(env, topics, connection_channel)
|
||||
connection = Channel(PQ::Notification).new(8)
|
||||
connection_channel.send({true, connection})
|
||||
|
||||
locale = env.get("preferences").as(Preferences).locale
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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)]
|
||||
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 = published
|
||||
video.published = Time.unix(published)
|
||||
response = JSON.parse(video.to_json(locale, nil))
|
||||
|
||||
env.response.puts "id: #{id}"
|
||||
@@ -86,65 +148,20 @@ def create_notification_stream(env, topics, connection_channel)
|
||||
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
|
||||
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 ":keepalive #{Time.utc.to_unix}"
|
||||
env.response.puts
|
||||
env.response.flush
|
||||
|
||||
id += 1
|
||||
sleep (20 + rand(11)).seconds
|
||||
end
|
||||
rescue ex
|
||||
ensure
|
||||
@@ -152,51 +169,38 @@ def create_notification_stream(env, topics, connection_channel)
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
return tracker.as(Hash(String, Int64 | Float64))
|
||||
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
|
||||
end
|
||||
|
||||
+183
-179
@@ -1,199 +1,203 @@
|
||||
# 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)
|
||||
}
|
||||
module I18n
|
||||
extend self
|
||||
|
||||
LOCALES = load_all_locales()
|
||||
# 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)
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
LOCALES = load_all_locales()
|
||||
|
||||
# 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
|
||||
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",
|
||||
}
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
return locales
|
||||
end
|
||||
def load_all_locales
|
||||
locales = {} of String => Hash(String, JSON::Any)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
LOCALES_LIST.each_key do |name|
|
||||
locales[name] = JSON.parse(File.read("locales/#{name}.json")).as_h
|
||||
end
|
||||
when .as_s?
|
||||
translation = raw_data.as_s
|
||||
else
|
||||
raise "Invalid translation \"#{raw_data}\""
|
||||
|
||||
return locales
|
||||
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
|
||||
# Return key if we're already in english, as the translation is missing
|
||||
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
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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")
|
||||
end
|
||||
end
|
||||
|
||||
def translate_bool(locale : String?, translation : Bool)
|
||||
case translation
|
||||
when true
|
||||
return translate(locale, "Yes")
|
||||
when false
|
||||
return translate(locale, "No")
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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 html_to_content(self.description_html) }
|
||||
xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text Helpers.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 html_to_content(self.description_html) }
|
||||
xml.element("media:description") { xml.text Helpers.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", html_to_content(self.description_html)
|
||||
json.field "description", Helpers.html_to_content(self.description_html)
|
||||
json.field "descriptionHtml", self.description_html
|
||||
|
||||
json.field "viewCount", self.views
|
||||
json.field "viewCountText", translate_count(locale, "generic_views_count", self.views, NumberFormatting::Short)
|
||||
json.field "viewCountText", I18n.translate_count(locale, "generic_views_count", self.views, I18n::NumberFormatting::Short)
|
||||
json.field "published", self.published.to_unix
|
||||
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
|
||||
json.field "publishedText", I18n.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", html_to_content(self.description_html)
|
||||
json.field "description", Helpers.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") { translate(locale, "timeline_parse_error_placeholder_heading") }
|
||||
xml.element("p") { translate(locale, "timeline_parse_error_placeholder_message") }
|
||||
xml.element("h4") { I18n.translate(locale, "timeline_parse_error_placeholder_heading") }
|
||||
xml.element("p") { I18n.translate(locale, "timeline_parse_error_placeholder_message") }
|
||||
end
|
||||
|
||||
xml.element("pre") do
|
||||
|
||||
@@ -146,19 +146,19 @@ def recode_date(time : Time, locale)
|
||||
span = Time.utc - time
|
||||
|
||||
if span.total_days > 365.0
|
||||
return translate_count(locale, "generic_count_years", span.total_days.to_i // 365)
|
||||
return I18n.translate_count(locale, "generic_count_years", span.total_days.to_i // 365)
|
||||
elsif span.total_days > 30.0
|
||||
return translate_count(locale, "generic_count_months", span.total_days.to_i // 30)
|
||||
return I18n.translate_count(locale, "generic_count_months", span.total_days.to_i // 30)
|
||||
elsif span.total_days > 7.0
|
||||
return translate_count(locale, "generic_count_weeks", span.total_days.to_i // 7)
|
||||
return I18n.translate_count(locale, "generic_count_weeks", span.total_days.to_i // 7)
|
||||
elsif span.total_hours > 24.0
|
||||
return translate_count(locale, "generic_count_days", span.total_days.to_i)
|
||||
return I18n.translate_count(locale, "generic_count_days", span.total_days.to_i)
|
||||
elsif span.total_minutes > 60.0
|
||||
return translate_count(locale, "generic_count_hours", span.total_hours.to_i)
|
||||
return I18n.translate_count(locale, "generic_count_hours", span.total_hours.to_i)
|
||||
elsif span.total_seconds > 60.0
|
||||
return translate_count(locale, "generic_count_minutes", span.total_minutes.to_i)
|
||||
return I18n.translate_count(locale, "generic_count_minutes", span.total_minutes.to_i)
|
||||
else
|
||||
return translate_count(locale, "generic_count_seconds", span.total_seconds.to_i)
|
||||
return I18n.translate_count(locale, "generic_count_seconds", span.total_seconds.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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", translate(locale, "`x` ago", recode_date(video.published, locale))
|
||||
json.field "publishedText", I18n.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", translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].to_s), locale))
|
||||
json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].to_s), locale))
|
||||
else
|
||||
json.field "publishedText", ""
|
||||
end
|
||||
|
||||
@@ -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 = extract_initial_data(response.body)
|
||||
initial_data = Helpers.extract_initial_data(response.body)
|
||||
|
||||
if !initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]?
|
||||
raise InfoException.new("Could not create mix.")
|
||||
|
||||
+46
-19
@@ -199,7 +199,7 @@ struct InvidiousPlaylist
|
||||
json.field "authorUrl", nil
|
||||
json.field "authorThumbnails", [] of String
|
||||
|
||||
json.field "description", html_to_content(self.description_html)
|
||||
json.field "description", Helpers.html_to_content(self.description_html)
|
||||
json.field "descriptionHtml", self.description_html
|
||||
json.field "videoCount", self.video_count
|
||||
|
||||
@@ -384,7 +384,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
|
||||
else
|
||||
elsif !text.includes? "Pay to watch"
|
||||
updated = decode_date(text.lchop("Last updated on ").lchop("Updated "))
|
||||
end
|
||||
end
|
||||
@@ -445,7 +445,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(initial_data)
|
||||
videos += extract_playlist_videos(playlist.id, initial_data)
|
||||
|
||||
offset += 100
|
||||
end
|
||||
@@ -454,7 +454,11 @@ def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32,
|
||||
end
|
||||
end
|
||||
|
||||
def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
|
||||
# TODO (2026-06-24): Migrate this function to use parsers instead, as it uses,
|
||||
# the same LockupViewModel used in Channel videos and Youtube playlists that
|
||||
# appears on searches (Invidious /search endpoint).
|
||||
# Related to https://github.com/iv-org/invidious/pull/5736
|
||||
def extract_playlist_videos(playlist_id : String, initial_data : Hash(String, JSON::Any))
|
||||
videos = [] of PlaylistVideo | ProblematicTimelineItem
|
||||
|
||||
if initial_data["contents"]?
|
||||
@@ -467,8 +471,7 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
|
||||
tabs_contents = tabs_renderer.["contents"]? || tabs_renderer.["content"]
|
||||
|
||||
list_renderer = tabs_contents.["sectionListRenderer"]["contents"][0]
|
||||
item_renderer = list_renderer.["itemSectionRenderer"]["contents"][0]
|
||||
contents = item_renderer.["playlistVideoListRenderer"]["contents"].as_a
|
||||
contents = list_renderer.["itemSectionRenderer"]["contents"].as_a
|
||||
else
|
||||
# Continuation data
|
||||
contents = initial_data["onResponseReceivedActions"][0]?
|
||||
@@ -479,15 +482,39 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
|
||||
end
|
||||
|
||||
contents.try &.each do |item|
|
||||
if i = item["playlistVideoRenderer"]?
|
||||
video_id = i["navigationEndpoint"]["watchEndpoint"]["videoId"].as_s
|
||||
plid = i["navigationEndpoint"]["watchEndpoint"]["playlistId"].as_s
|
||||
index = i["navigationEndpoint"]["watchEndpoint"]["index"].as_i64
|
||||
if i = item["lockupViewModel"]?
|
||||
thumbnail_view_model = i.dig?(
|
||||
"contentImage", "thumbnailViewModel"
|
||||
)
|
||||
|
||||
watch_endpoint = i.dig?("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint")
|
||||
video_id = watch_endpoint.try &.["videoId"]?.try &.as_s
|
||||
plid = watch_endpoint.try &.["playlistId"]?.try &.as_s || playlist_id
|
||||
index = watch_endpoint.try &.["index"]?.try &.as_i64
|
||||
|
||||
metadata = i["metadata"]?
|
||||
lockup_metadata_view_model = metadata.try &.dig?("lockupMetadataViewModel")
|
||||
title = lockup_metadata_view_model.try &.dig?("title", "content").try &.as_s
|
||||
lockup_metadata = lockup_metadata_view_model.try &.dig?("metadata")
|
||||
metadata_rows = lockup_metadata.try &.dig?("contentMetadataViewModel", "metadataRows").try &.as_a
|
||||
|
||||
# Find the metadataParts with commandRuns inside, which contains author
|
||||
# information.
|
||||
metadata_parts = metadata_rows.try &.find { |row|
|
||||
parts = row["metadataParts"]?.try &.as_a
|
||||
parts && parts.any? { |item2| item2.dig?("text", "commandRuns").try &.as_a }
|
||||
}.try &.["metadataParts"].as_a
|
||||
|
||||
if author_info = metadata_parts.try &.find(&.dig?("text", "commandRuns"))
|
||||
.try &.["text"]
|
||||
author = author_info["content"].as_s
|
||||
ucid = author_info.dig?("commandRuns", 0, "onTap", "innertubeCommand", "browseEndpoint", "browseId")
|
||||
.try &.as_s
|
||||
end
|
||||
|
||||
length = thumbnail_view_model.try &.dig?("overlays", 0, "thumbnailBottomOverlayViewModel", "badges", 0, "thumbnailBadgeViewModel", "text").try &.as_s
|
||||
length_seconds = decode_length_seconds(length) if length
|
||||
|
||||
title = i["title"].try { |t| t["simpleText"]? || t["runs"]?.try &.[0]["text"]? }.try &.as_s || ""
|
||||
author = i["shortBylineText"]?.try &.["runs"][0]["text"].as_s || ""
|
||||
ucid = i["shortBylineText"]?.try &.["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s || ""
|
||||
length_seconds = i["lengthSeconds"]?.try &.as_s.to_i
|
||||
live = false
|
||||
|
||||
if !length_seconds
|
||||
@@ -496,15 +523,15 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
|
||||
end
|
||||
|
||||
videos << PlaylistVideo.new({
|
||||
title: title,
|
||||
id: video_id,
|
||||
author: author,
|
||||
ucid: ucid,
|
||||
title: title || "",
|
||||
id: video_id || "",
|
||||
author: author || "",
|
||||
ucid: ucid || "",
|
||||
length_seconds: length_seconds,
|
||||
published: Time.utc,
|
||||
plid: plid,
|
||||
live_now: live,
|
||||
index: index,
|
||||
index: index || -1_i64,
|
||||
})
|
||||
end
|
||||
rescue ex
|
||||
|
||||
@@ -8,7 +8,7 @@ module Invidious::Routes::API::V1::Authenticated
|
||||
# topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000)
|
||||
# topics ||= [] of String
|
||||
|
||||
# create_notification_stream(env, topics, connection_channel)
|
||||
# Helpers.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
|
||||
|
||||
create_notification_stream(env, topics, CONNECTION_CHANNEL)
|
||||
Helpers.create_notification_stream(env, topics, CONNECTION_CHANNEL)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -97,7 +97,7 @@ 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", html_to_content(channel.description_html)
|
||||
json.field "description", Helpers.html_to_content(channel.description_html)
|
||||
json.field "descriptionHtml", channel.description_html
|
||||
|
||||
json.field "allowedRegions", channel.allowed_regions
|
||||
@@ -128,7 +128,6 @@ module Invidious::Routes::API::V1::Channels
|
||||
end
|
||||
end
|
||||
end # relatedChannels
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -300,7 +300,7 @@ module Invidious::Routes::API::V1::Videos
|
||||
|
||||
annotations = response.body
|
||||
|
||||
cache_annotation(id, annotations)
|
||||
Helpers.cache_annotation(id, annotations)
|
||||
end
|
||||
else # "youtube"
|
||||
response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}")
|
||||
@@ -410,7 +410,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 = parse_clip_parameters(params)
|
||||
start_time, end_time, clip_title = Invidious::Videos::Clip.parse_clip_parameters(params)
|
||||
end
|
||||
|
||||
begin
|
||||
|
||||
@@ -19,7 +19,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, LOCALES.keys)
|
||||
if language = ANG.language_negotiator.best(language_header, I18n::LOCALES.keys)
|
||||
preferences.locale = language.header
|
||||
end
|
||||
end
|
||||
@@ -32,6 +32,8 @@ 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:"
|
||||
|
||||
@@ -351,10 +351,10 @@ module Invidious::Routes::Channels
|
||||
invidious_url_params.delete_all("user")
|
||||
|
||||
begin
|
||||
resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}")
|
||||
resolved_url = YoutubeAPI.resolve_url("https://www.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, translate(locale, "This channel does not exist."))
|
||||
return error_template(404, I18n.translate(locale, "This channel does not exist."))
|
||||
end
|
||||
|
||||
selected_tab = env.params.url["tab"]?
|
||||
|
||||
@@ -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(translate(locale, "error_video_not_in_playlist", url))
|
||||
raise NotFoundException.new(I18n.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(translate(locale, "error_video_not_in_playlist", url))
|
||||
raise NotFoundException.new(I18n.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 = process_video_params(env.params.query, preferences)
|
||||
params = Invidious::Videos.process_video_params(env.params.query, preferences)
|
||||
|
||||
user = env.get?("user").try &.as(User)
|
||||
if user
|
||||
|
||||
@@ -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://youtube.com/gaming
|
||||
# Check if item is branding URL e.g. https://www.youtube.com/gaming
|
||||
response = YT_POOL.client &.get("/#{item}")
|
||||
|
||||
if response.status_code == 301
|
||||
|
||||
@@ -37,7 +37,7 @@ module Invidious::Routes::Feeds
|
||||
if CONFIG.popular_enabled
|
||||
templated "feeds/popular"
|
||||
else
|
||||
message = translate(locale, "The Popular feed has been disabled by the administrator.")
|
||||
message = I18n.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 translate(locale, "Invidious Private Feed for `x`", user.email) }
|
||||
xml.element("title") { xml.text I18n.translate(locale, "Invidious Private Feed for `x`", user.email) }
|
||||
|
||||
(notifications + videos).each do |video|
|
||||
video.to_xml(locale, params, xml)
|
||||
@@ -283,6 +283,11 @@ module Invidious::Routes::Feeds
|
||||
if playlist = Invidious::Database::Playlists.select(id: plid)
|
||||
videos = get_playlist_videos(playlist, offset: 0)
|
||||
|
||||
user = env.get?("user").try &.as(User)
|
||||
if !playlist || playlist.privacy.private? && playlist.author != user.try &.email
|
||||
return error_atom(404, "Playlist does not exist.")
|
||||
end
|
||||
|
||||
return XML.build(indent: " ", encoding: "UTF-8") do |xml|
|
||||
xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015",
|
||||
"xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom",
|
||||
@@ -320,7 +325,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}" : ""
|
||||
query_string_opt = request_target.starts_with?("/watch?v=") ? ("&#{params}" if !params.empty?) : ""
|
||||
node[attribute.name] = "#{HOST_URL}#{request_target}#{query_string_opt}"
|
||||
else nil # Skip
|
||||
end
|
||||
|
||||
@@ -51,7 +51,7 @@ module Invidious::Routes::Images
|
||||
end
|
||||
|
||||
# ??? maybe also for storyboards?
|
||||
def self.s_p_image(env)
|
||||
def self.s_p_image(env, authority = "i9")
|
||||
id = env.params.url["id"]
|
||||
name = env.params.url["name"]
|
||||
url = env.request.resource
|
||||
@@ -65,13 +65,23 @@ module Invidious::Routes::Images
|
||||
end
|
||||
|
||||
begin
|
||||
get_ytimg_pool("i9").client &.get(url, headers) do |resp|
|
||||
get_ytimg_pool(authority).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|
|
||||
@@ -96,7 +106,7 @@ module Invidious::Routes::Images
|
||||
break
|
||||
end
|
||||
|
||||
proxy_file(response, env)
|
||||
Helpers.proxy_file(response, env)
|
||||
end
|
||||
rescue ex
|
||||
end
|
||||
@@ -148,6 +158,6 @@ module Invidious::Routes::Images
|
||||
return env.response.headers.delete("Transfer-Encoding")
|
||||
end
|
||||
|
||||
return proxy_file(response, env)
|
||||
return Helpers.proxy_file(response, env)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,6 +26,7 @@ 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")
|
||||
|
||||
@@ -57,7 +58,11 @@ module Invidious::Routes::Login
|
||||
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
||||
Invidious::Database::SessionIDs.insert(sid, email)
|
||||
|
||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
|
||||
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
|
||||
else
|
||||
return error_template(401, "Wrong username or password")
|
||||
end
|
||||
@@ -112,7 +117,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, LOCALES.keys)
|
||||
if language = ANG.language_negotiator.best(language_header, I18n::LOCALES.keys)
|
||||
user.preferences.locale = language.header
|
||||
end
|
||||
end
|
||||
@@ -123,7 +128,11 @@ 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)}")
|
||||
|
||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
|
||||
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
|
||||
|
||||
if env.request.cookies["PREFS"]?
|
||||
user.preferences = env.get("preferences").as(Preferences)
|
||||
|
||||
@@ -145,6 +145,10 @@ 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,
|
||||
@@ -182,6 +186,7 @@ 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"
|
||||
@@ -226,7 +231,12 @@ module Invidious::Routes::PreferencesRoute
|
||||
File.write("config/config.yml", CONFIG.to_yaml)
|
||||
end
|
||||
else
|
||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
|
||||
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
|
||||
end
|
||||
|
||||
env.redirect referer
|
||||
@@ -261,7 +271,12 @@ module Invidious::Routes::PreferencesRoute
|
||||
preferences.dark_mode = "dark"
|
||||
end
|
||||
|
||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
|
||||
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
|
||||
end
|
||||
|
||||
if redirect
|
||||
|
||||
@@ -40,9 +40,16 @@ module Invidious::Routes::Search
|
||||
preferences = env.get("preferences").as(Preferences)
|
||||
locale = preferences.locale
|
||||
|
||||
region = env.params.query["region"]? || preferences.region
|
||||
uri_params = URI::Params.new
|
||||
if env.request.method == "GET"
|
||||
uri_params = env.params.query
|
||||
else
|
||||
uri_params = env.params.body
|
||||
end
|
||||
|
||||
query = Invidious::Search::Query.new(env.params.query, :regular, region)
|
||||
region = uri_params["region"]? || preferences.region
|
||||
|
||||
query = Invidious::Search::Query.new(uri_params, :regular, region)
|
||||
|
||||
if query.empty?
|
||||
# Display the full page search box implemented in #1977
|
||||
|
||||
@@ -83,7 +83,7 @@ module Invidious::Routes::VideoPlayback
|
||||
# Remove the Range header added previously.
|
||||
headers.delete("Range") if range_header.nil?
|
||||
|
||||
playback_statistics = get_playback_statistic()
|
||||
playback_statistics = Helpers.get_playback_statistic
|
||||
playback_statistics["totalRequests"] += 1
|
||||
|
||||
if response.status_code >= 400
|
||||
@@ -195,7 +195,7 @@ module Invidious::Routes::VideoPlayback
|
||||
end
|
||||
end
|
||||
|
||||
proxy_file(resp, env)
|
||||
Helpers.proxy_file(resp, env)
|
||||
end
|
||||
rescue ex
|
||||
if ex.message != "Error reading socket: Connection reset by peer"
|
||||
|
||||
@@ -47,7 +47,7 @@ module Invidious::Routes::Watch
|
||||
end
|
||||
subscriptions ||= [] of String
|
||||
|
||||
params = process_video_params(env.params.query, preferences)
|
||||
params = Invidious::Videos.process_video_params(env.params.query, preferences)
|
||||
env.params.query.delete_all("listen")
|
||||
|
||||
begin
|
||||
@@ -129,17 +129,20 @@ module Invidious::Routes::Watch
|
||||
video_streams = video.video_streams
|
||||
audio_streams = video.audio_streams
|
||||
|
||||
# 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}"
|
||||
# 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
|
||||
end
|
||||
end
|
||||
|
||||
@@ -273,7 +276,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, _ = parse_clip_parameters(params)
|
||||
start_time, end_time, _ = Invidious::Videos::Clip.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
|
||||
|
||||
@@ -185,6 +185,7 @@ 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
|
||||
|
||||
@@ -222,6 +223,8 @@ 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
|
||||
|
||||
@@ -57,8 +57,6 @@ module Invidious::Search
|
||||
# Values correspond to { "1:varint": <X> }
|
||||
enum Sort
|
||||
Relevance = 0
|
||||
Rating = 1
|
||||
Date = 2
|
||||
Views = 3
|
||||
end
|
||||
|
||||
|
||||
@@ -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 = extract_initial_data(response.body)
|
||||
initial_data = Helpers.extract_initial_data(response.body)
|
||||
ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?)
|
||||
raise ChannelSearchException.new(query.channel) if !ucid
|
||||
else
|
||||
|
||||
@@ -6,17 +6,24 @@ 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
|
||||
)
|
||||
@@ -25,12 +32,19 @@ 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
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ struct Invidious::User
|
||||
playlists.each do |playlist|
|
||||
json.object do
|
||||
json.field "title", playlist.title
|
||||
json.field "description", html_to_content(playlist.description_html)
|
||||
json.field "description", Helpers.html_to_content(playlist.description_html)
|
||||
json.field "privacy", playlist.privacy.to_s
|
||||
json.field "videos" do
|
||||
json.array do
|
||||
|
||||
@@ -57,6 +57,7 @@ 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)
|
||||
|
||||
@@ -81,9 +81,10 @@ struct Video
|
||||
end
|
||||
|
||||
def premiere_timestamp : Time?
|
||||
info
|
||||
.dig?("microformat", "playerMicroformatRenderer", "liveBroadcastDetails", "startTimestamp")
|
||||
.try { |t| Time.parse_rfc3339(t.as_s) }
|
||||
if self.video_type == VideoType::Scheduled
|
||||
return info["published"]?
|
||||
.try { |t| Time.parse_rfc3339(t.as_s) }
|
||||
end
|
||||
end
|
||||
|
||||
def related_videos
|
||||
@@ -324,7 +325,7 @@ rescue DB::Error
|
||||
end
|
||||
|
||||
def fetch_video(id, region)
|
||||
info = extract_video_info(video_id: id)
|
||||
info = Invidious::Videos::Parser.extract_video_info(video_id: id)
|
||||
|
||||
if info.nil?
|
||||
raise InfoException.new("Invidious companion is not available. \
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
require "json"
|
||||
|
||||
# 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) }
|
||||
module Invidious::Videos::Clip
|
||||
extend self
|
||||
|
||||
start_time = decoded_protobuf
|
||||
.try(&.["50:0:embedded"]["2:1:varint"].as_i64)
|
||||
.try { |i| i/1000 }
|
||||
# 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) }
|
||||
|
||||
end_time = decoded_protobuf
|
||||
.try(&.["50:0:embedded"]["3:2:varint"].as_i64)
|
||||
.try { |i| i/1000 }
|
||||
start_time = decoded_protobuf
|
||||
.try(&.["50:0:embedded"]["2:1:varint"].as_i64)
|
||||
.try { |i| i/1000 }
|
||||
|
||||
clip_title = decoded_protobuf
|
||||
.try(&.["50:0:embedded"]["4:3:string"].as_s)
|
||||
end_time = decoded_protobuf
|
||||
.try(&.["50:0:embedded"]["3:2:varint"].as_i64)
|
||||
.try { |i| i/1000 }
|
||||
|
||||
return start_time, end_time, clip_title
|
||||
clip_title = decoded_protobuf
|
||||
.try(&.["50:0:embedded"]["4:3:string"].as_s)
|
||||
|
||||
return start_time, end_time, clip_title
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,8 +21,6 @@ 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
|
||||
|
||||
@@ -44,10 +42,6 @@ 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
|
||||
|
||||
+412
-408
@@ -1,465 +1,469 @@
|
||||
require "json"
|
||||
|
||||
# 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"]?
|
||||
module Invidious::Videos::Parser
|
||||
extend self
|
||||
|
||||
# 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
|
||||
# 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 || ""),
|
||||
}
|
||||
end
|
||||
|
||||
# Both have "short", so the "long" option shouldn't be required
|
||||
channel_info = (related["shortBylineText"]? || related["longBylineText"]?)
|
||||
.try &.dig?("runs", 0)
|
||||
def extract_video_info(video_id : String)
|
||||
# Fetch data from the player endpoint
|
||||
player_response = YoutubeAPI.player(video_id: video_id)
|
||||
|
||||
author = channel_info.try &.dig?("text")
|
||||
author_verified = has_verified_badge?(related["ownerBadges"]?).to_s
|
||||
if player_response.nil?
|
||||
return nil
|
||||
end
|
||||
|
||||
ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) }
|
||||
playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
|
||||
|
||||
short_view_count = related.try do |r|
|
||||
HelperExtractors.get_short_view_count(r).to_s
|
||||
end
|
||||
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
|
||||
|
||||
LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container")
|
||||
# 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.
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
# 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(reason),
|
||||
"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
|
||||
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.
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
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
|
||||
params = self.parse_video_info(video_id, player_response)
|
||||
params["reason"] = JSON::Any.new(reason) if reason
|
||||
|
||||
# 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
|
||||
{"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f|
|
||||
params[f] = player_response[f] if player_response[f]?
|
||||
end
|
||||
|
||||
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"]
|
||||
# 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
|
||||
format["url"] = JSON::Any.new(convert_url(format))
|
||||
end
|
||||
|
||||
params["streamingData"] = streaming_data
|
||||
end
|
||||
|
||||
params["streamingData"] = streaming_data
|
||||
# Data structure version, for cache control
|
||||
params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64)
|
||||
|
||||
return params
|
||||
end
|
||||
|
||||
# Data structure version, for cache control
|
||||
params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64)
|
||||
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)
|
||||
|
||||
return params
|
||||
end
|
||||
playability_status = response["playabilityStatus"]["status"]
|
||||
LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.")
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
# 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"
|
||||
)
|
||||
def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any)
|
||||
# Top level elements
|
||||
|
||||
player_overlays.try &.as_a.each do |element|
|
||||
if item = element["endScreenVideoRenderer"]?
|
||||
related_video = parse_related_video(item)
|
||||
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 = self.parse_related_video(item)
|
||||
related << JSON::Any.new(related_video) if related_video
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# 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 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"
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
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"))
|
||||
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
|
||||
music_list << VideoMusic.new(song.to_s, album.to_s, artist.to_s, music_license.to_s)
|
||||
end
|
||||
|
||||
# Author infos
|
||||
# Likes
|
||||
|
||||
author = video_details["author"]?.try &.as_s
|
||||
ucid = video_details["channelId"]?.try &.as_s
|
||||
toplevel_buttons = video_primary_renderer
|
||||
.try &.dig?("videoActions", "menuRenderer", "topLevelButtons")
|
||||
|
||||
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"]?)
|
||||
if toplevel_buttons
|
||||
# New Format as of december 2023
|
||||
likes_button = toplevel_buttons.dig?(0,
|
||||
"segmentedLikeDislikeButtonViewModel",
|
||||
"likeButtonViewModel",
|
||||
"likeButtonViewModel",
|
||||
"toggleButtonViewModel",
|
||||
"toggleButtonViewModel",
|
||||
"defaultButtonViewModel",
|
||||
"buttonViewModel"
|
||||
)
|
||||
|
||||
subs_text = author_info["subscriberCountText"]?
|
||||
.try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }
|
||||
.try &.as_s.split(" ", 2)[0]
|
||||
end
|
||||
likes_button ||= toplevel_buttons.try &.as_a
|
||||
.find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE")
|
||||
.try &.["toggleButtonRenderer"]
|
||||
|
||||
# Return data
|
||||
# New format as of september 2022
|
||||
likes_button ||= toplevel_buttons.try &.as_a
|
||||
.find(&.["segmentedLikeDislikeButtonRenderer"]?)
|
||||
.try &.dig?(
|
||||
"segmentedLikeDislikeButtonRenderer",
|
||||
"likeButton", "toggleButtonRenderer"
|
||||
)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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),
|
||||
|
||||
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
|
||||
"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 || ""),
|
||||
|
||||
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" => JSON.parse(music_list.to_json),
|
||||
|
||||
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" => 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
|
||||
author = video_details["author"]?.try &.as_s
|
||||
ucid = video_details["channelId"]?.try &.as_s
|
||||
|
||||
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
|
||||
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"]?)
|
||||
|
||||
LOGGER.debug("convert_url: Decoding '#{cfr}'")
|
||||
else
|
||||
url = URI.parse(fmt["url"].as_s)
|
||||
params = url.query_params
|
||||
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
|
||||
|
||||
url.query_params = params
|
||||
LOGGER.trace("convert_url: new url is '#{url}'")
|
||||
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
|
||||
|
||||
return url.to_s
|
||||
rescue ex
|
||||
LOGGER.debug("convert_url: Error when parsing video URL")
|
||||
LOGGER.trace(ex.inspect_with_backtrace)
|
||||
return ""
|
||||
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
|
||||
end
|
||||
|
||||
@@ -1,162 +1,166 @@
|
||||
struct VideoPreferences
|
||||
include JSON::Serializable
|
||||
module Invidious::Videos
|
||||
extend self
|
||||
|
||||
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
|
||||
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
|
||||
end
|
||||
|
||||
@@ -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 %>"><%= translate(locale, "Editing playlist `x`", %|"#{HTML.escape(playlist.title)}"|) %></a></legend>
|
||||
<legend><a href="/playlist?list=<%= playlist.id %>"><%= I18n.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="<%= translate(locale, "Search for videos") %>">
|
||||
placeholder="<%= I18n.translate(locale, "Search for videos") %>">
|
||||
<input type="hidden" name="list" value="<%= plid %>">
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
<%=
|
||||
{
|
||||
"ucid" => ucid,
|
||||
"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")),
|
||||
"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")),
|
||||
"preferences" => env.get("preferences").as(Preferences)
|
||||
}.to_pretty_json
|
||||
%>
|
||||
|
||||
@@ -27,7 +27,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> <%= translate(locale, "generic_button_rss") %>
|
||||
<i class="icon ion-logo-rss"></i> <%= I18n.translate(locale, "generic_button_rss") %>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,10 +40,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 %>"><%= translate(locale, "View channel on YouTube") %></a>
|
||||
<a href="<%= youtube_url %>"><%= I18n.translate(locale, "View channel on YouTube") %></a>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-md-1-3">
|
||||
<a href="<%= redirect_url %>"><%= translate(locale, "Switch Invidious Instance") %></a>
|
||||
<a href="<%= redirect_url %>"><%= I18n.translate(locale, "Switch Invidious Instance") %></a>
|
||||
</div>
|
||||
|
||||
<%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %>
|
||||
@@ -53,9 +53,9 @@
|
||||
<% sort_options.each do |sort| %>
|
||||
<div class="pure-u-1 pure-md-1-3">
|
||||
<% if sort_by == sort %>
|
||||
<b><%= translate(locale, sort) %></b>
|
||||
<b><%= I18n.translate(locale, sort) %></b>
|
||||
<% else %>
|
||||
<a href="<%= relative_url %>?sort_by=<%= sort %>"><%= translate(locale, sort) %></a>
|
||||
<a href="<%= relative_url %>?sort_by=<%= sort %>"><%= I18n.translate(locale, sort) %></a>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<% end %>
|
||||
<% feed_menu.each do |feed| %>
|
||||
<a href="/feed/<%= feed.downcase %>" class="feed-menu-item pure-menu-heading">
|
||||
<%= translate(locale, feed) %>
|
||||
<%= I18n.translate(locale, feed) %>
|
||||
</a>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
</div>
|
||||
|
||||
<% if !item.channel_handle.nil? %><p class="channel-name" dir="auto"><%= item.channel_handle %></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 %>
|
||||
<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 %>
|
||||
<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><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
|
||||
<p><%= I18n.translate_count(locale, "generic_videos_count", item.video_count, I18n::NumberFormatting::Separator) %></p>
|
||||
<%- end -%>
|
||||
</div>
|
||||
|
||||
<div class="video-card-row">
|
||||
<%- if item.channel_count != 0 -%>
|
||||
<p><%= translate_count(locale, "generic_channels_count", item.channel_count, NumberFormatting::Separator) %></p>
|
||||
<p><%= I18n.translate_count(locale, "generic_channels_count", item.channel_count, I18n::NumberFormatting::Separator) %></p>
|
||||
<%- end -%>
|
||||
</div>
|
||||
<% when SearchPlaylist, InvidiousPlaylist %>
|
||||
@@ -73,7 +73,7 @@
|
||||
<%- end -%>
|
||||
|
||||
<div class="bottom-right-overlay">
|
||||
<p class="length"><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
|
||||
<p class="length"><%= I18n.translate_count(locale, "generic_videos_count", item.video_count, I18n::NumberFormatting::Separator) %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,11 +101,11 @@
|
||||
<div class="error-card">
|
||||
<div class="explanation">
|
||||
<i class="icon ion-ios-alert"></i>
|
||||
<h4><%=translate(locale, "timeline_parse_error_placeholder_heading")%></h4>
|
||||
<p><%=translate(locale, "timeline_parse_error_placeholder_message")%></p>
|
||||
<h4><%=I18n.translate(locale, "timeline_parse_error_placeholder_heading")%></h4>
|
||||
<p><%=I18n.translate(locale, "timeline_parse_error_placeholder_message")%></p>
|
||||
</div>
|
||||
<details>
|
||||
<summary class="pure-button pure-button-secondary"><%=translate(locale, "timeline_parse_error_show_technical_details")%></summary>
|
||||
<summary class="pure-button pure-button-secondary"><%=I18n.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> <%= translate(locale, "LIVE") %></p>
|
||||
<p class="length" dir="auto"><i class="icon ion-ios-play-circle"></i> <%= I18n.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"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></p>
|
||||
<p class="video-data" dir="auto"><%= I18n.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"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></p>
|
||||
<p class="video-data" dir="auto"><%= I18n.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"><%= translate_count(locale, "generic_views_count", item.views || 0, NumberFormatting::Short) %></p>
|
||||
<p class="video-data" dir="auto"><%= I18n.translate_count(locale, "generic_views_count", item.views || 0, I18n::NumberFormatting::Short) %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
<script id="pagination-data" type="application/json">
|
||||
<%=
|
||||
{
|
||||
"next_page" => translate(locale, "Next page"),
|
||||
"prev_page" => translate(locale, "Previous page"),
|
||||
"is_rtl" => locale_is_rtl?(locale)
|
||||
"next_page" => I18n.translate(locale, "Next page"),
|
||||
"prev_page" => I18n.translate(locale, "Previous page"),
|
||||
"is_rtl" => I18n.locale_is_rtl?(locale)
|
||||
}.to_pretty_json
|
||||
%>
|
||||
</script>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<%
|
||||
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="<%= translate(locale, "search") %>"
|
||||
title="<%= translate(locale, "search") %>"
|
||||
name="q" placeholder="<%= I18n.translate(locale, "search") %>"
|
||||
title="<%= I18n.translate(locale, "search") %>"
|
||||
value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
|
||||
</fieldset>
|
||||
<button type="submit" id="searchbutton" aria-label="<%= translate(locale, "search") %>">
|
||||
<button type="submit" id="searchbutton" aria-label="<%= I18n.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="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b>
|
||||
<b><input style="all:unset" type="submit" value="<%= I18n.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="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b>
|
||||
<b><input style="all:unset" type="submit" value="<%= I18n.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(translate(locale, "Subscribe")),
|
||||
"unsubscribe_text" => HTML.escape(translate(locale, "Unsubscribe"))
|
||||
"subscribe_text" => HTML.escape(I18n.translate(locale, "Subscribe")),
|
||||
"unsubscribe_text" => HTML.escape(I18n.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><%= translate(locale, "Subscribe") %> | <%= sub_count_text %></b>
|
||||
<b><%= I18n.translate(locale, "Subscribe") %> | <%= sub_count_text %></b>
|
||||
</a>
|
||||
<% end %>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<div class="flex-right flexible">
|
||||
<div class="icon-buttons">
|
||||
<a title="<%=translate(locale, "videoinfo_watch_on_youTube")%>" rel="noreferrer noopener" href="https://www.youtube.com/watch<%=endpoint_params%>">
|
||||
<a title="<%=I18n.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="<%=translate(locale, "Audio mode")%>" href="/watch<%=endpoint_params%>&listen=1">
|
||||
<a title="<%=I18n.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="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=%2Fwatch<%=URI.encode_www_form(endpoint_params)%>">
|
||||
<a title="<%=I18n.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="<%=translate(locale, "Switch Invidious Instance")%>" href="https://redirect.invidious.io/watch<%=endpoint_params%>">
|
||||
<a title="<%=I18n.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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% content_for "header" do %>
|
||||
<title><%= translate(locale, "Create playlist") %> - Invidious</title>
|
||||
<title><%= I18n.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><%= translate(locale, "Create playlist") %></legend>
|
||||
<legend><%= I18n.translate(locale, "Create playlist") %></legend>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="title"><%= translate(locale, "Title") %> :</label>
|
||||
<input required name="title" type="text" placeholder="<%= translate(locale, "Title") %>">
|
||||
<label for="title"><%= I18n.translate(locale, "Title") %> :</label>
|
||||
<input required name="title" type="text" placeholder="<%= I18n.translate(locale, "Title") %>">
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="privacy"><%= translate(locale, "Playlist privacy") %> :</label>
|
||||
<label for="privacy"><%= I18n.translate(locale, "Playlist privacy") %> :</label>
|
||||
<select name="privacy" id="privacy">
|
||||
<% PlaylistPrivacy.names.each do |option| %>
|
||||
<option value="<%= option %>" <% if option == "Public" %> selected <% end %>><%= translate(locale, option) %></option>
|
||||
<option value="<%= option %>" <% if option == "Public" %> selected <% end %>><%= I18n.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">
|
||||
<%= translate(locale, "Create playlist") %>
|
||||
<%= I18n.translate(locale, "Create playlist") %>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<% content_for "header" do %>
|
||||
<title><%= translate(locale, "Delete playlist") %> - Invidious</title>
|
||||
<title><%= I18n.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><%= translate(locale, "Delete playlist `x`?", %|"#{HTML.escape(playlist.title)}"|) %></legend>
|
||||
<legend><%= I18n.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">
|
||||
<%= translate(locale, "Yes") %>
|
||||
<%= I18n.translate(locale, "Yes") %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pure-u-1-2">
|
||||
<a class="pure-button" href="/playlist?list=<%= plid %>">
|
||||
<%= translate(locale, "No") %>
|
||||
<%= I18n.translate(locale, "No") %>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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> <%= translate(locale, "generic_button_cancel") %>
|
||||
<i class="icon ion-md-close"></i> <%= I18n.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> <%= translate(locale, "generic_button_save") %>
|
||||
<i class="icon ion-md-save"></i> <%= I18n.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> <%= translate(locale, "generic_button_delete") %>
|
||||
<i class="icon ion-md-trash"></i> <%= I18n.translate(locale, "generic_button_delete") %>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,11 +36,11 @@
|
||||
<div class="pure-u-1-1">
|
||||
<b>
|
||||
<%= HTML.escape(playlist.author) %> |
|
||||
<%= translate_count(locale, "generic_videos_count", playlist.video_count) %> |
|
||||
<%= I18n.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 %>><%= translate(locale, option) %></option>
|
||||
<option value="<%= option %>" <% if option == playlist.privacy.to_s %>selected<% end %>><%= I18n.translate(locale, option) %></option>
|
||||
<%- end -%>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<% content_for "header" do %>
|
||||
<title><%= translate(locale, "History") %> - Invidious</title>
|
||||
<title><%= I18n.translate(locale, "History") %> - Invidious</title>
|
||||
<% end %>
|
||||
|
||||
<div class="pure-g h-box">
|
||||
<div class="pure-u-1-3">
|
||||
<h3><%= translate_count(locale, "generic_videos_count", user.watched.size, NumberFormatting::HtmlSpan) %></h3>
|
||||
<h3><%= I18n.translate_count(locale, "generic_videos_count", user.watched.size, I18n::NumberFormatting::HtmlSpan) %></h3>
|
||||
</div>
|
||||
<div class="pure-u-1-3">
|
||||
<h3 style="text-align:center">
|
||||
<a href="/feed/subscriptions"><%= translate_count(locale, "generic_subscriptions_count", user.subscriptions.size, NumberFormatting::HtmlSpan) %></a>
|
||||
<a href="/feed/subscriptions"><%= I18n.translate_count(locale, "generic_subscriptions_count", user.subscriptions.size, I18n::NumberFormatting::HtmlSpan) %></a>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="pure-u-1-3">
|
||||
<h3 style="text-align:right">
|
||||
<a href="/clear_watch_history"><%= translate(locale, "Clear watch history") %></a>
|
||||
<a href="/clear_watch_history"><%= I18n.translate(locale, "Clear watch history") %></a>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<% content_for "header" do %>
|
||||
<title><%= translate(locale, "Playlists") %> - Invidious</title>
|
||||
<title><%= I18n.translate(locale, "Playlists") %> - Invidious</title>
|
||||
<% end %>
|
||||
|
||||
<%= rendered "components/feed_menu" %>
|
||||
|
||||
<div class="pure-g h-box">
|
||||
<div class="pure-u-1-3">
|
||||
<h3><%= translate(locale, "user_created_playlists", %(<span id="count">#{items_created.size}</span>)) %></h3>
|
||||
<h3><%= I18n.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") %>"><%= translate(locale, "Create playlist") %></a>
|
||||
<a href="/create_playlist?referer=<%= URI.encode_www_form("/feed/playlists") %>"><%= I18n.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") %>">
|
||||
<%= translate(locale, "Import/export") %>
|
||||
<%= I18n.translate(locale, "Import/export") %>
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
<div class="pure-g h-box">
|
||||
<div class="pure-u-1">
|
||||
<h3><%= translate(locale, "user_saved_playlists", %(<span id="count">#{items_saved.size}</span>)) %></h3>
|
||||
<h3><%= I18n.translate(locale, "user_saved_playlists", %(<span id="count">#{items_saved.size}</span>)) %></h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<% content_for "header" do %>
|
||||
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
|
||||
<meta name="description" content="<%= I18n.translate(locale, "An alternative front-end to YouTube") %>">
|
||||
<title>
|
||||
<% if env.get("preferences").as(Preferences).default_home != "Popular" %>
|
||||
<%= translate(locale, "Popular") %> - Invidious
|
||||
<%= I18n.translate(locale, "Popular") %> - Invidious
|
||||
<% else %>
|
||||
Invidious
|
||||
<% end %>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% content_for "header" do %>
|
||||
<title><%= translate(locale, "Subscriptions") %> - Invidious</title>
|
||||
<title><%= I18n.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"><%= translate(locale, "Manage subscriptions") %></a>
|
||||
<a href="/subscription_manager"><%= I18n.translate(locale, "Manage subscriptions") %></a>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="pure-u-1-3">
|
||||
<h3 style="text-align:center">
|
||||
<a href="/feed/history"><%= translate(locale, "Watch history") %></a>
|
||||
<a href="/feed/history"><%= I18n.translate(locale, "Watch history") %></a>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="pure-u-1-3">
|
||||
@@ -26,7 +26,7 @@
|
||||
<% if CONFIG.enable_user_notifications %>
|
||||
|
||||
<center>
|
||||
<%= translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %>
|
||||
<%= I18n.translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %>
|
||||
</center>
|
||||
|
||||
<% if !notifications.empty? %>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<% content_for "header" do %>
|
||||
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
|
||||
<meta name="description" content="<%= I18n.translate(locale, "An alternative front-end to YouTube") %>">
|
||||
<title>
|
||||
<% if env.get("preferences").as(Preferences).default_home != "Trending" %>
|
||||
<%= translate(locale, "Trending") %> - Invidious
|
||||
<%= I18n.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 %>">
|
||||
<%= translate(locale, "View as playlist") %>
|
||||
<%= I18n.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><%= translate(locale, option) %></b>
|
||||
<b><%= I18n.translate(locale, option) %></b>
|
||||
<% else %>
|
||||
<a href="/feed/trending?type=<%= option %>®ion=<%= region %>">
|
||||
<%= translate(locale, option) %>
|
||||
<%= I18n.translate(locale, option) %>
|
||||
</a>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1><%= translate(locale, "JavaScript license information") %></h1>
|
||||
<h1><%= I18n.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"><%= translate(locale, "source") %></a>
|
||||
<a href="https://github.com/iv-org/videojs-quality-selector"><%= I18n.translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://github.com/mpetazzoni/sse.js"><%= translate(locale, "source") %></a>
|
||||
<a href="https://github.com/mpetazzoni/sse.js"><%= I18n.translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://github.com/videojs/videojs-contrib-quality-levels"><%= translate(locale, "source") %></a>
|
||||
<a href="https://github.com/videojs/videojs-contrib-quality-levels"><%= I18n.translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://github.com/jfujita/videojs-http-source-selector"><%= translate(locale, "source") %></a>
|
||||
<a href="https://github.com/jfujita/videojs-http-source-selector"><%= I18n.translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://github.com/mister-ben/videojs-mobile-ui"><%= translate(locale, "source") %></a>
|
||||
<a href="https://github.com/mister-ben/videojs-mobile-ui"><%= I18n.translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://github.com/spchuang/videojs-markers"><%= translate(locale, "source") %></a>
|
||||
<a href="https://github.com/spchuang/videojs-markers"><%= I18n.translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://github.com/brightcove/videojs-overlay"><%= translate(locale, "source") %></a>
|
||||
<a href="https://github.com/brightcove/videojs-overlay"><%= I18n.translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://github.com/mkhazov/videojs-share"><%= translate(locale, "source") %></a>
|
||||
<a href="https://github.com/mkhazov/videojs-share"><%= I18n.translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://github.com/chrisboustead/videojs-vtt-thumbnails"><%= translate(locale, "source") %></a>
|
||||
<a href="https://github.com/chrisboustead/videojs-vtt-thumbnails"><%= I18n.translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://github.com/afrmtbl/videojs-youtube-annotations"><%= translate(locale, "source") %></a>
|
||||
<a href="https://github.com/afrmtbl/videojs-youtube-annotations"><%= I18n.translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://github.com/videojs/videojs-vr"><%= translate(locale, "source") %></a>
|
||||
<a href="https://github.com/videojs/videojs-vr"><%= I18n.translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://github.com/videojs/video.js"><%= translate(locale, "source") %></a>
|
||||
<a href="https://github.com/videojs/video.js"><%= I18n.translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% content_for "header" do %>
|
||||
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
|
||||
<meta name="description" content="<%= I18n.translate(locale, "An alternative front-end to YouTube") %>">
|
||||
<title>
|
||||
Invidious
|
||||
</title>
|
||||
|
||||
@@ -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> <%= translate(locale, "playlist_button_add_items") %>
|
||||
<i class="icon ion-md-add"></i> <%= I18n.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> <%= translate(locale, "generic_button_edit") %>
|
||||
<i class="icon ion-md-create"></i> <%= I18n.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> <%= translate(locale, "generic_button_delete") %>
|
||||
<i class="icon ion-md-trash"></i> <%= I18n.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> <%= translate(locale, "Subscribe") %>
|
||||
<i class="icon ion-md-add"></i> <%= I18n.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> <%= translate(locale, "Unsubscribe") %>
|
||||
<i class="icon ion-md-trash"></i> <%= I18n.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> <%= translate(locale, "generic_button_rss") %>
|
||||
<i class="icon ion-logo-rss"></i> <%= I18n.translate(locale, "generic_button_rss") %>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,15 +57,15 @@
|
||||
<% else %>
|
||||
<%= author %> |
|
||||
<% end %>
|
||||
<%= translate_count(locale, "generic_videos_count", playlist.video_count) %> |
|
||||
<%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> |
|
||||
<%= I18n.translate_count(locale, "generic_videos_count", playlist.video_count) %> |
|
||||
<%= I18n.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> <%= translate(locale, "Public") %>
|
||||
<i class="icon ion-md-globe"></i> <%= I18n.translate(locale, "Public") %>
|
||||
<% when PlaylistPrivacy::Unlisted %>
|
||||
<i class="icon ion-ios-unlock"></i> <%= translate(locale, "Unlisted") %>
|
||||
<i class="icon ion-ios-unlock"></i> <%= I18n.translate(locale, "Unlisted") %>
|
||||
<% when PlaylistPrivacy::Private %>
|
||||
<i class="icon ion-ios-lock"></i> <%= translate(locale, "Private") %>
|
||||
<i class="icon ion-ios-lock"></i> <%= I18n.translate(locale, "Private") %>
|
||||
<% end %>
|
||||
</b>
|
||||
<% else %>
|
||||
@@ -76,25 +76,25 @@
|
||||
<% subtitle = playlist.subtitle || "" %>
|
||||
<span><%= HTML.escape(subtitle[0..subtitle.rindex(" • ") || subtitle.size]) %></span> |
|
||||
<% end %>
|
||||
<%= translate_count(locale, "generic_videos_count", playlist.video_count) %> |
|
||||
<%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %>
|
||||
<%= I18n.translate_count(locale, "generic_videos_count", playlist.video_count) %> |
|
||||
<%= I18n.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 %>">
|
||||
<%= translate(locale, "View playlist on YouTube") %>
|
||||
<%= I18n.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") %>">
|
||||
<%= translate(locale, "Switch Invidious Instance") %>
|
||||
<%= I18n.translate(locale, "Switch Invidious Instance") %>
|
||||
</a>
|
||||
<% else %>
|
||||
<a href="https://redirect.invidious.io/playlist?list=<%= playlist.id %>">
|
||||
<%= translate(locale, "Switch Invidious Instance") %>
|
||||
<%= I18n.translate(locale, "Switch Invidious Instance") %>
|
||||
</a>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<% else %>
|
||||
<noscript>
|
||||
<a href="/post/<%= id %>?ucid=<%= ucid %>&nojs=1">
|
||||
<%= 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.") %>
|
||||
<%= 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.") %>
|
||||
</a>
|
||||
</noscript>
|
||||
<% end %>
|
||||
@@ -29,12 +29,12 @@
|
||||
<%=
|
||||
{
|
||||
"id" => id,
|
||||
"youtube_comments_text" => HTML.escape(translate(locale, "View YouTube comments")),
|
||||
"youtube_comments_text" => HTML.escape(I18n.translate(locale, "View YouTube comments")),
|
||||
"reddit_comments_text" => "",
|
||||
"reddit_permalink_text" => "",
|
||||
"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")),
|
||||
"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")),
|
||||
"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>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<%
|
||||
search_privacy = preferences.search_privacy
|
||||
search_query = query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "…" : HTML.escape(query.text)
|
||||
%>
|
||||
|
||||
<% content_for "header" do %>
|
||||
<title><%= query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "…" : HTML.escape(query.text) %> - Invidious</title>
|
||||
<title><%= search_privacy ? "Search" : search_query %> - Invidious</title>
|
||||
<link rel="stylesheet" href="/css/search.css?v=<%= ASSET_COMMIT %>">
|
||||
<% end %>
|
||||
|
||||
@@ -11,9 +16,9 @@
|
||||
<%- if items.empty? -%>
|
||||
<div class="h-box no-results-error">
|
||||
<div>
|
||||
<%= 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) %>
|
||||
<%= 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) %>
|
||||
</div>
|
||||
</div>
|
||||
<%- else -%>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<% content_for "header" do %>
|
||||
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
|
||||
<meta name="description" content="<%= I18n.translate(locale, "An alternative front-end to YouTube") %>">
|
||||
<title>
|
||||
Invidious - <%= translate(locale, "search") %>
|
||||
Invidious - <%= I18n.translate(locale, "search") %>
|
||||
</title>
|
||||
<link rel="stylesheet" href="/css/empty.css?v=<%= ASSET_COMMIT %>">
|
||||
<% end %>
|
||||
|
||||
@@ -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="<%= translate(locale, "toggle_theme") %>">
|
||||
<a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading" title="<%= I18n.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="<%= translate(locale, "Subscriptions") %>" href="/feed/subscriptions" class="pure-menu-heading">
|
||||
<a id="notification_ticker" title="<%= I18n.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="<%= translate(locale, "Preferences") %>" href="/preferences?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
|
||||
<a title="<%= I18n.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="<%= translate(locale, "Log out") %>">
|
||||
<input style="all:unset" type="submit" value="<%= I18n.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="<%= translate(locale, "toggle_theme") %>">
|
||||
<a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading" title="<%= I18n.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="<%= translate(locale, "Preferences") %>" href="/preferences?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
|
||||
<a title="<%= I18n.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">
|
||||
<%= translate(locale, "Log in") %>
|
||||
<%= I18n.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"><%= translate(locale, "footer_original_source_code") %></a> /
|
||||
<a href="<%= CONFIG.modified_source_code_url %>"><%= translate(locale, "footer_modfied_source_code") %></a>
|
||||
<a href="https://github.com/iv-org/invidious"><%= I18n.translate(locale, "footer_original_source_code") %></a> /
|
||||
<a href="<%= CONFIG.modified_source_code_url %>"><%= I18n.translate(locale, "footer_modfied_source_code") %></a>
|
||||
<% else %>
|
||||
<a href="https://github.com/iv-org/invidious"><%= translate(locale, "footer_source_code") %></a>
|
||||
<a href="https://github.com/iv-org/invidious"><%= I18n.translate(locale, "footer_source_code") %></a>
|
||||
<% end %>
|
||||
</span>
|
||||
<span>
|
||||
<i class="icon ion-ios-paper"></i>
|
||||
<a href="https://github.com/iv-org/documentation"><%= translate(locale, "footer_documentation") %></a>
|
||||
<a href="https://github.com/iv-org/documentation"><%= I18n.translate(locale, "footer_documentation") %></a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<span>
|
||||
<a href="https://github.com/iv-org/invidious/blob/master/LICENSE"><%= translate(locale, "Released under the AGPLv3 on Github.") %></a>
|
||||
<a href="https://github.com/iv-org/invidious/blob/master/LICENSE"><%= I18n.translate(locale, "Released under the AGPLv3 on Github.") %></a>
|
||||
</span>
|
||||
<span>
|
||||
<i class="icon ion-logo-javascript"></i>
|
||||
<a rel="jslicense" href="/licenses"><%= translate(locale, "View JavaScript license information.") %></a>
|
||||
<a rel="jslicense" href="/licenses"><%= I18n.translate(locale, "View JavaScript license information.") %></a>
|
||||
</span>
|
||||
<span>
|
||||
<i class="icon ion-ios-paper"></i>
|
||||
<a href="/privacy"><%= translate(locale, "View privacy policy.") %></a>
|
||||
<a href="/privacy"><%= I18n.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/"><%= translate(locale, "footer_donate_page") %></a>
|
||||
<a href="https://invidious.io/donate/"><%= I18n.translate(locale, "footer_donate_page") %></a>
|
||||
</span>
|
||||
<span>
|
||||
<%= translate(locale, "Current version: ") %>
|
||||
<%= I18n.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(translate(locale, "`x` uploaded a video")),
|
||||
"live_upload_text" => HTML.escape(translate(locale, "`x` is live"))
|
||||
"upload_text" => HTML.escape(I18n.translate(locale, "`x` uploaded a video")),
|
||||
"live_upload_text" => HTML.escape(I18n.translate(locale, "`x` is live"))
|
||||
}.to_pretty_json
|
||||
%>
|
||||
</script>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<% content_for "header" do %>
|
||||
<title><%= translate(locale, "Token") %> - Invidious</title>
|
||||
<title><%= I18n.translate(locale, "Token") %> - Invidious</title>
|
||||
<% end %>
|
||||
|
||||
<% if env.get? "access_token" %>
|
||||
<div class="pure-g h-box">
|
||||
<div class="pure-u-1-3">
|
||||
<h3>
|
||||
<%= translate(locale, "Token") %>
|
||||
<%= I18n.translate(locale, "Token") %>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="pure-u-1-3">
|
||||
<h3 style="text-align:center">
|
||||
<a href="/token_manager"><%= translate(locale, "Token manager") %></a>
|
||||
<a href="/token_manager"><%= I18n.translate(locale, "Token manager") %></a>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="pure-u-1-3">
|
||||
<h3 style="text-align:right">
|
||||
<a href="/preferences"><%= translate(locale, "Preferences") %></a>
|
||||
<a href="/preferences"><%= I18n.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><%= translate(locale, "Authorize token for `x`?", "#{callback_url.scheme}://#{callback_url.host}") %></legend>
|
||||
<legend><%= I18n.translate(locale, "Authorize token for `x`?", "#{callback_url.scheme}://#{callback_url.host}") %></legend>
|
||||
<% else %>
|
||||
<legend><%= translate(locale, "Authorize token?") %></legend>
|
||||
<legend><%= I18n.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">
|
||||
<%= translate(locale, "Yes") %>
|
||||
<%= I18n.translate(locale, "Yes") %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pure-u-1-2">
|
||||
@@ -57,7 +57,7 @@
|
||||
<% else %>
|
||||
<a class="pure-button" href="/">
|
||||
<% end %>
|
||||
<%= translate(locale, "No") %>
|
||||
<%= I18n.translate(locale, "No") %>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% content_for "header" do %>
|
||||
<title><%= translate(locale, "Change password") %> - Invidious</title>
|
||||
<title><%= I18n.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><%= translate(locale, "Change password") %></legend>
|
||||
<legend><%= I18n.translate(locale, "Change password") %></legend>
|
||||
|
||||
<fieldset>
|
||||
<label for="password"><%= translate(locale, "Password") %> :</label>
|
||||
<input required class="pure-input-1" name="password" type="password" placeholder="<%= translate(locale, "Password") %>">
|
||||
<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="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[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[1]"><%= translate(locale, "New password") %> :</label>
|
||||
<input required class="pure-input-1" name="new_password[1]" 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") %>">
|
||||
|
||||
<button type="submit" name="action" value="change_password" class="pure-button pure-button-primary">
|
||||
<%= translate(locale, "Change password") %>
|
||||
<%= I18n.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><%= translate(locale, "Clear watch history") %> - Invidious</title>
|
||||
<title><%= I18n.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><%= translate(locale, "Clear watch history?") %></legend>
|
||||
<legend><%= I18n.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">
|
||||
<%= translate(locale, "Yes") %>
|
||||
<%= I18n.translate(locale, "Yes") %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pure-u-1-2">
|
||||
<a class="pure-button" href="<%= URI.encode_www_form(referer) %>">
|
||||
<%= translate(locale, "No") %>
|
||||
<%= I18n.translate(locale, "No") %>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
<% content_for "header" do %>
|
||||
<title><%= translate(locale, "Import and Export Data") %> - Invidious</title>
|
||||
<title><%= I18n.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><%= translate(locale, "Import") %></legend>
|
||||
<legend><%= I18n.translate(locale, "Import") %></legend>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="import_invidious"><%= translate(locale, "Import Invidious data") %></label>
|
||||
<label for="import_invidious"><%= I18n.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">
|
||||
<%= translate(locale, "Import YouTube subscriptions") %>
|
||||
<%= I18n.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"><%= translate(locale, "Import YouTube playlist (.csv)") %></label>
|
||||
<label for="import_youtube_pl"><%= I18n.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"><%= translate(locale, "Import YouTube watch history (.json)") %></label>
|
||||
<label for="import_youtube_wh"><%= I18n.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"><%= translate(locale, "Import FreeTube subscriptions (.db)") %></label>
|
||||
<label for="import_freetube"><%= I18n.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"><%= translate(locale, "Import NewPipe subscriptions (.json)") %></label>
|
||||
<label for="import_newpipe_subscriptions"><%= I18n.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"><%= translate(locale, "Import NewPipe data (.zip)") %></label>
|
||||
<label for="import_newpipe"><%= I18n.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"><%= translate(locale, "Import") %></button>
|
||||
<button type="submit" class="pure-button pure-button-primary"><%= I18n.translate(locale, "Import") %></button>
|
||||
</div>
|
||||
|
||||
<legend><%= translate(locale, "Export") %></legend>
|
||||
<legend><%= I18n.translate(locale, "Export") %></legend>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<a href="/subscription_manager?action_takeout=1"><%= translate(locale, "Export subscriptions as OPML") %></a>
|
||||
<a href="/subscription_manager?action_takeout=1"><%= I18n.translate(locale, "Export subscriptions as OPML") %></a>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<a href="/subscription_manager?action_takeout=1&format=newpipe"><%= translate(locale, "Export subscriptions as OPML (for NewPipe & FreeTube)") %></a>
|
||||
<a href="/subscription_manager?action_takeout=1&format=newpipe"><%= I18n.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"><%= translate(locale, "Export data as JSON") %></a>
|
||||
<a href="/subscription_manager?action_takeout=1&format=json"><%= I18n.translate(locale, "Export data as JSON") %></a>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<% content_for "header" do %>
|
||||
<title><%= translate(locale, "Delete account") %> - Invidious</title>
|
||||
<title><%= I18n.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><%= translate(locale, "Delete account?") %></legend>
|
||||
<legend><%= I18n.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">
|
||||
<%= translate(locale, "Yes") %>
|
||||
<%= I18n.translate(locale, "Yes") %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pure-u-1-2">
|
||||
<a class="pure-button" href="<%= URI.encode_www_form(referer) %>">
|
||||
<%= translate(locale, "No") %>
|
||||
<%= I18n.translate(locale, "No") %>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% content_for "header" do %>
|
||||
<title><%= translate(locale, "Log in") %> - Invidious</title>
|
||||
<title><%= I18n.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"><%= translate(locale, "User ID") %> :</label>
|
||||
<input required class="pure-input-1" name="email" type="text" placeholder="<%= translate(locale, "User ID") %>">
|
||||
<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") %>">
|
||||
<% end %>
|
||||
|
||||
<% if password %>
|
||||
<input name="password" type="hidden" value="<%= HTML.escape(password) %>">
|
||||
<% else %>
|
||||
<label for="password"><%= translate(locale, "Password") %> :</label>
|
||||
<input required class="pure-input-1" name="password" type="password" placeholder="<%= translate(locale, "Password") %>">
|
||||
<label for="password"><%= I18n.translate(locale, "Password") %> :</label>
|
||||
<input required class="pure-input-1" name="password" type="password" placeholder="<%= I18n.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"><%= translate(locale, "Time (h:mm:ss):") %></label>
|
||||
<label for="answer"><%= I18n.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">
|
||||
<%= translate(locale, "Register") %>
|
||||
<%= I18n.translate(locale, "Register") %>
|
||||
</button>
|
||||
<% else %>
|
||||
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">
|
||||
<%= translate(locale, "Sign In") %>/<%= translate(locale, "Register") %>
|
||||
<%= I18n.translate(locale, "Sign In") %>/<%= I18n.translate(locale, "Register") %>
|
||||
</button>
|
||||
<% end %>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
<% content_for "header" do %>
|
||||
<title><%= translate(locale, "Preferences") %> - Invidious</title>
|
||||
<title><%= I18n.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><%= translate(locale, "preferences_category_player") %></legend>
|
||||
<legend><%= I18n.translate(locale, "preferences_category_player") %></legend>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="video_loop"><%= translate(locale, "preferences_video_loop_label") %></label>
|
||||
<label for="video_loop"><%= I18n.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"><%= translate(locale, "preferences_preload_label") %></label>
|
||||
<label for="preload"><%= I18n.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"><%= translate(locale, "preferences_autoplay_label") %></label>
|
||||
<label for="autoplay"><%= I18n.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"><%= translate(locale, "preferences_continue_label") %></label>
|
||||
<label for="continue"><%= I18n.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"><%= translate(locale, "preferences_continue_autoplay_label") %></label>
|
||||
<label for="continue_autoplay"><%= I18n.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"><%= translate(locale, "preferences_local_label") %></label>
|
||||
<label for="local"><%= I18n.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"><%= translate(locale, "preferences_listen_label") %></label>
|
||||
<label for="listen"><%= I18n.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"><%= translate(locale, "preferences_speed_label") %></label>
|
||||
<label for="speed"><%= I18n.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"><%= translate(locale, "preferences_quality_label") %></label>
|
||||
<label for="quality"><%= I18n.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 %>><%= translate(locale, "preferences_quality_option_" + option) %></option>
|
||||
<option value="<%= option %>" <% if preferences.quality == option %> selected <% end %>><%= I18n.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"><%= translate(locale, "preferences_quality_dash_label") %></label>
|
||||
<label for="quality_dash"><%= I18n.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 %>><%= translate(locale, "preferences_quality_dash_option_" + option) %></option>
|
||||
<option value="<%= option %>" <% if preferences.quality_dash == option %> selected <% end %>><%= I18n.translate(locale, "preferences_quality_dash_option_" + option) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="volume"><%= translate(locale, "preferences_volume_label") %></label>
|
||||
<label for="volume"><%= I18n.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]"><%= translate(locale, "preferences_comments_label") %></label>
|
||||
<label for="comments[0]"><%= I18n.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 %>><%= translate(locale, option.blank? ? "none" : option) %></option>
|
||||
<option value="<%= option %>" <% if preferences.comments[index] == option %> selected <% end %>><%= I18n.translate(locale, option.blank? ? "none" : option) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="captions[0]"><%= translate(locale, "preferences_captions_label") %></label>
|
||||
<label for="captions[0]"><%= I18n.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 %>><%= translate(locale, option.blank? ? "none" : option) %></option>
|
||||
<option value="<%= option %>" <% if preferences.captions[index] == option %> selected <% end %>><%= I18n.translate(locale, option.blank? ? "none" : option) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="related_videos"><%= translate(locale, "preferences_related_videos_label") %></label>
|
||||
<label for="related_videos"><%= I18n.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"><%= translate(locale, "preferences_annotations_label") %></label>
|
||||
<label for="annotations"><%= I18n.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"><%= translate(locale, "preferences_extend_desc_label") %></label>
|
||||
<label for="extend_desc"><%= I18n.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"><%= translate(locale, "preferences_vr_mode_label") %></label>
|
||||
<label for="vr_mode"><%= I18n.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"><%= translate(locale, "preferences_save_player_pos_label") %></label>
|
||||
<label for="save_player_pos"><%= I18n.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"><%= translate(locale, "preferences_default_playlist") %></label>
|
||||
<label for="default_playlist"><%= I18n.translate(locale, "preferences_default_playlist") %></label>
|
||||
<select name="default_playlist" id="default_playlist">
|
||||
<option value=""><%= translate(locale, "preferences_default_playlist_none") %></option>
|
||||
<option value=""><%= I18n.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><%= translate(locale, "preferences_category_visual") %></legend>
|
||||
<legend><%= I18n.translate(locale, "preferences_category_visual") %></legend>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="locale"><%= translate(locale, "preferences_locale_label") %></label>
|
||||
<label for="locale"><%= I18n.translate(locale, "preferences_locale_label") %></label>
|
||||
<select name="locale" id="locale">
|
||||
<% LOCALES_LIST.each do |iso_name, full_name| %>
|
||||
<% I18n::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"><%= translate(locale, "preferences_region_label") %></label>
|
||||
<label for="region"><%= I18n.translate(locale, "preferences_region_label") %></label>
|
||||
<select name="region" id="region">
|
||||
<% CONTENT_REGIONS.each do |option| %>
|
||||
<% I18n::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"><%= translate(locale, "preferences_player_style_label") %></label>
|
||||
<label for="player_style"><%= I18n.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 %>><%= translate(locale, option) %></option>
|
||||
<option value="<%= option %>" <% if preferences.player_style == option %> selected <% end %>><%= I18n.translate(locale, option) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="dark_mode"><%= translate(locale, "preferences_dark_mode_label") %></label>
|
||||
<label for="dark_mode"><%= I18n.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 %>><%= translate(locale, option.blank? ? "auto" : option) %></option>
|
||||
<option value="<%= option %>" <% if preferences.dark_mode == option %> selected <% end %>><%= I18n.translate(locale, option.blank? ? "auto" : option) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="thin_mode"><%= translate(locale, "preferences_thin_mode_label") %></label>
|
||||
<label for="thin_mode"><%= I18n.translate(locale, "preferences_thin_mode_label") %></label>
|
||||
<input name="thin_mode" id="thin_mode" type="checkbox" <% if preferences.thin_mode %>checked<% end %>>
|
||||
</div>
|
||||
|
||||
@@ -189,187 +189,193 @@
|
||||
<% end %>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="default_home"><%= translate(locale, "preferences_default_home_label") %></label>
|
||||
<label for="default_home"><%= I18n.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 %>><%= translate(locale, option.blank? ? "Search" : option) %></option>
|
||||
<option value="<%= option %>" <% if preferences.default_home == option %> selected <% end %>><%= I18n.translate(locale, option.blank? ? "Search" : option) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="feed_menu"><%= translate(locale, "preferences_feed_menu_label") %></label>
|
||||
<label for="feed_menu"><%= I18n.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 %>><%= translate(locale, option.blank? ? "Search" : option) %></option>
|
||||
<option value="<%= option %>" <% if preferences.feed_menu[index]? == option %> selected <% end %>><%= I18n.translate(locale, option.blank? ? "Search" : option) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if env.get? "user" %>
|
||||
<div class="pure-control-group">
|
||||
<label for="show_nick"><%= translate(locale, "preferences_show_nick_label") %></label>
|
||||
<label for="show_nick"><%= I18n.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><%= translate(locale, "preferences_category_misc") %></legend>
|
||||
<legend><%= I18n.translate(locale, "preferences_category_misc") %></legend>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="automatic_instance_redirect"><%= translate(locale, "preferences_automatic_instance_redirect_label") %></label>
|
||||
<label for="automatic_instance_redirect"><%= I18n.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><%= translate(locale, "preferences_category_subscription") %></legend>
|
||||
<legend><%= I18n.translate(locale, "preferences_category_subscription") %></legend>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="watch_history"><%= translate(locale, "preferences_watch_history_label") %></label>
|
||||
<label for="watch_history"><%= I18n.translate(locale, "preferences_watch_history_label") %></label>
|
||||
<input name="watch_history" id="watch_history" type="checkbox" <% if preferences.watch_history %>checked<% end %>>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="annotations_subscribed"><%= translate(locale, "preferences_annotations_subscribed_label") %></label>
|
||||
<label for="annotations_subscribed"><%= I18n.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"><%= translate(locale, "preferences_max_results_label") %></label>
|
||||
<label for="max_results"><%= I18n.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"><%= translate(locale, "preferences_sort_label") %></label>
|
||||
<label for="sort"><%= I18n.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 %>><%= translate(locale, option) %></option>
|
||||
<option value="<%= option %>" <% if preferences.sort == option %> selected <% end %>><%= I18n.translate(locale, option) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<% if preferences.unseen_only %>
|
||||
<label for="latest_only"><%= translate(locale, "Only show latest unwatched video from channel: ") %></label>
|
||||
<label for="latest_only"><%= I18n.translate(locale, "Only show latest unwatched video from channel: ") %></label>
|
||||
<% else %>
|
||||
<label for="latest_only"><%= translate(locale, "Only show latest video from channel: ") %></label>
|
||||
<label for="latest_only"><%= I18n.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"><%= translate(locale, "preferences_unseen_only_label") %></label>
|
||||
<label for="unseen_only"><%= I18n.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"><%= translate(locale, "preferences_notifications_only_label") %></label>
|
||||
<label for="notifications_only"><%= I18n.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"><%= translate(locale, "Enable web notifications") %></a>
|
||||
<a href="#" data-onclick="notification_requestPermission"><%= I18n.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><%= translate(locale, "preferences_category_admin") %></legend>
|
||||
<legend><%= I18n.translate(locale, "preferences_category_admin") %></legend>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="admin_default_home"><%= translate(locale, "preferences_default_home_label") %></label>
|
||||
<label for="admin_default_home"><%= I18n.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 %>><%= translate(locale, option.blank? ? "none" : option) %></option>
|
||||
<option value="<%= option %>" <% if CONFIG.default_user_preferences.default_home == option %> selected <% end %>><%= I18n.translate(locale, option.blank? ? "none" : option) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="admin_feed_menu"><%= translate(locale, "preferences_feed_menu_label") %></label>
|
||||
<label for="admin_feed_menu"><%= I18n.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 %>><%= translate(locale, option.blank? ? "none" : option) %></option>
|
||||
<option value="<%= option %>" <% if CONFIG.default_user_preferences.feed_menu[index]? == option %> selected <% end %>><%= I18n.translate(locale, option.blank? ? "none" : option) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="popular_enabled"><%= translate(locale, "Popular enabled: ") %></label>
|
||||
<label for="popular_enabled"><%= I18n.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"><%= translate(locale, "CAPTCHA enabled: ") %></label>
|
||||
<label for="captcha_enabled"><%= I18n.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"><%= translate(locale, "Login enabled: ") %></label>
|
||||
<label for="login_enabled"><%= I18n.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"><%= translate(locale, "Registration enabled: ") %></label>
|
||||
<label for="registration_enabled"><%= I18n.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"><%= translate(locale, "Report statistics: ") %></label>
|
||||
<label for="statistics_enabled"><%= I18n.translate(locale, "Report statistics: ") %></label>
|
||||
<input name="statistics_enabled" id="statistics_enabled" type="checkbox" <% if CONFIG.statistics_enabled %>checked<% end %>>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="modified_source_code_url"><%= translate(locale, "adminprefs_modified_source_code_url_label") %></label>
|
||||
<label for="modified_source_code_url"><%= I18n.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><%= translate(locale, "preferences_category_data") %></legend>
|
||||
<legend><%= I18n.translate(locale, "preferences_category_data") %></legend>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<a href="/clear_watch_history?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Clear watch history") %></a>
|
||||
<a href="/clear_watch_history?referer=<%= URI.encode_www_form(referer) %>"><%= I18n.translate(locale, "Clear watch history") %></a>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<a href="/change_password?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Change password") %></a>
|
||||
<a href="/change_password?referer=<%= URI.encode_www_form(referer) %>"><%= I18n.translate(locale, "Change password") %></a>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<a href="/data_control?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Import/export data") %></a>
|
||||
<a href="/data_control?referer=<%= URI.encode_www_form(referer) %>"><%= I18n.translate(locale, "Import/export data") %></a>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<a href="/subscription_manager"><%= translate(locale, "Manage subscriptions") %></a>
|
||||
<a href="/subscription_manager"><%= I18n.translate(locale, "Manage subscriptions") %></a>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<a href="/token_manager"><%= translate(locale, "Manage tokens") %></a>
|
||||
<a href="/token_manager"><%= I18n.translate(locale, "Manage tokens") %></a>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<a href="/feed/playlists"><%= translate(locale, "View all playlists") %></a>
|
||||
<a href="/feed/playlists"><%= I18n.translate(locale, "View all playlists") %></a>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<a href="/feed/history"><%= translate(locale, "Watch history") %></a>
|
||||
<a href="/feed/history"><%= I18n.translate(locale, "Watch history") %></a>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<a href="/delete_account?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Delete account") %></a>
|
||||
<a href="/delete_account?referer=<%= URI.encode_www_form(referer) %>"><%= I18n.translate(locale, "Delete account") %></a>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="pure-controls">
|
||||
<button type="submit" class="pure-button pure-button-primary"><%= translate(locale, "Save preferences") %></button>
|
||||
<button type="submit" class="pure-button pure-button-primary"><%= I18n.translate(locale, "Save preferences") %></button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
<% content_for "header" do %>
|
||||
<title><%= translate(locale, "Subscription manager") %> - Invidious</title>
|
||||
<title><%= I18n.translate(locale, "Subscription manager") %> - Invidious</title>
|
||||
<% end %>
|
||||
|
||||
<div class="pure-g h-box">
|
||||
<div class="pure-u-1-3">
|
||||
<h3>
|
||||
<a href="/feed/subscriptions">
|
||||
<%= translate_count(locale, "generic_subscriptions_count", subscriptions.size, NumberFormatting::HtmlSpan) %>
|
||||
<%= I18n.translate_count(locale, "generic_subscriptions_count", subscriptions.size, I18n::NumberFormatting::HtmlSpan) %>
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="pure-u-1-3">
|
||||
<h3 style="text-align:center">
|
||||
<a href="/feed/history">
|
||||
<%= translate(locale, "Watch history") %>
|
||||
<%= I18n.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) %>">
|
||||
<%= translate(locale, "Import/export") %>
|
||||
<%= I18n.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="<%= translate(locale, "unsubscribe") %>">
|
||||
<input style="all:unset" type="submit" data-onclick="remove_subscription" data-ucid="<%= channel.id %>" value="<%= I18n.translate(locale, "unsubscribe") %>">
|
||||
</form>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<% content_for "header" do %>
|
||||
<title><%= translate(locale, "Token manager") %> - Invidious</title>
|
||||
<title><%= I18n.translate(locale, "Token manager") %> - Invidious</title>
|
||||
<% end %>
|
||||
|
||||
<div class="pure-g h-box">
|
||||
<div class="pure-u-1-3">
|
||||
<h3>
|
||||
<%= translate_count(locale, "tokens_count", tokens.size, NumberFormatting::HtmlSpan) %>
|
||||
<%= I18n.translate_count(locale, "tokens_count", tokens.size, I18n::NumberFormatting::HtmlSpan) %>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="pure-u-1-3"></div>
|
||||
<div class="pure-u-1-3" style="text-align:right">
|
||||
<h3>
|
||||
<a href="/preferences?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Preferences") %></a>
|
||||
<a href="/preferences?referer=<%= URI.encode_www_form(referer) %>"><%= I18n.translate(locale, "Preferences") %></a>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,13 +25,13 @@
|
||||
</h4>
|
||||
</div>
|
||||
<div class="pure-u-1-5" style="text-align:center">
|
||||
<h4><%= translate(locale, "`x` ago", recode_date(token[:issued], locale)) %></h4>
|
||||
<h4><%= I18n.translate(locale, "`x` ago", recode_date(token[:issued], locale)) %></h4>
|
||||
</div>
|
||||
<div class="pure-u-1-5" style="text-align:right">
|
||||
<h3 style="padding-right:0.5em">
|
||||
<form data-onsubmit="return_false" action="/token_ajax?action=revoke_token&session=<%= token[:session] %>&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="revoke_token" data-session="<%= token[:session] %>" value="<%= translate(locale, "revoke") %>">
|
||||
<input style="all:unset" type="submit" data-onclick="revoke_token" data-session="<%= token[:session] %>" value="<%= I18n.translate(locale, "revoke") %>">
|
||||
</form>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -35,11 +35,11 @@ we're going to need to do it here in order to allow for translations.
|
||||
-->
|
||||
<style>
|
||||
#descexpansionbutton ~ label > a::after {
|
||||
content: "<%= translate(locale, "Show more") %>"
|
||||
content: "<%= I18n.translate(locale, "Show more") %>"
|
||||
}
|
||||
|
||||
#descexpansionbutton:checked ~ label > a::after {
|
||||
content: "<%= translate(locale, "Show less") %>"
|
||||
content: "<%= I18n.translate(locale, "Show less") %>"
|
||||
}
|
||||
</style>
|
||||
<% end %>
|
||||
@@ -53,12 +53,12 @@ we're going to need to do it here in order to allow for translations.
|
||||
"length_seconds" => video.length_seconds.to_f,
|
||||
"play_next" => !video.related_videos.empty? && !plid && params.continue,
|
||||
"next_video" => video.related_videos.select { |rv| rv["id"]? }[0]?.try &.["id"],
|
||||
"youtube_comments_text" => HTML.escape(translate(locale, "View YouTube comments")),
|
||||
"reddit_comments_text" => HTML.escape(translate(locale, "View Reddit comments")),
|
||||
"reddit_permalink_text" => HTML.escape(translate(locale, "View more comments on Reddit")),
|
||||
"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")),
|
||||
"youtube_comments_text" => HTML.escape(I18n.translate(locale, "View YouTube comments")),
|
||||
"reddit_comments_text" => HTML.escape(I18n.translate(locale, "View Reddit comments")),
|
||||
"reddit_permalink_text" => HTML.escape(I18n.translate(locale, "View more comments on Reddit")),
|
||||
"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")),
|
||||
"params" => params,
|
||||
"preferences" => preferences,
|
||||
"premiere_timestamp" => video.premiere_timestamp.try &.to_unix,
|
||||
@@ -79,11 +79,11 @@ we're going to need to do it here in order to allow for translations.
|
||||
<h1>
|
||||
<%= title %>
|
||||
<% if params.listen %>
|
||||
<a title="<%=translate(locale, "Video mode")%>" href="/watch?<%= env.params.query %>&listen=0">
|
||||
<a title="<%=I18n.translate(locale, "Video mode")%>" id="link-iv-listen" data-base-url="/watch?<%= env.params.query %>&listen=0" href="/watch?<%= env.params.query %>&listen=0">
|
||||
<i class="icon ion-ios-videocam"></i>
|
||||
</a>
|
||||
<% else %>
|
||||
<a title="<%=translate(locale, "Audio mode")%>" href="/watch?<%= env.params.query %>&listen=1">
|
||||
<a title="<%=I18n.translate(locale, "Audio mode")%>" id="link-iv-listen" data-base-url="/watch?<%= env.params.query %>&listen=1" href="/watch?<%= env.params.query %>&listen=1">
|
||||
<i class="icon ion-md-headset"></i>
|
||||
</a>
|
||||
<% end %>
|
||||
@@ -91,7 +91,7 @@ we're going to need to do it here in order to allow for translations.
|
||||
|
||||
<% if !video.is_listed %>
|
||||
<h3>
|
||||
<i class="icon ion-ios-unlock"></i> <%= translate(locale, "Unlisted") %>
|
||||
<i class="icon ion-ios-unlock"></i> <%= I18n.translate(locale, "Unlisted") %>
|
||||
</h3>
|
||||
<% end %>
|
||||
|
||||
@@ -101,11 +101,11 @@ we're going to need to do it here in order to allow for translations.
|
||||
</h3>
|
||||
<% elsif video.premiere_timestamp.try &.> Time.utc %>
|
||||
<h3>
|
||||
<%= video.premiere_timestamp.try { |t| translate(locale, "Premieres in `x`", recode_date((t - Time.utc).ago, locale)) } %>
|
||||
<%= video.premiere_timestamp.try { |t| I18n.translate(locale, "Premieres in `x`", recode_date((t - Time.utc).ago, locale)) } %>
|
||||
</h3>
|
||||
<% elsif video.live_now %>
|
||||
<h3>
|
||||
<%= video.premiere_timestamp.try { |t| translate(locale, "videoinfo_started_streaming_x_ago", recode_date((Time.utc - t).ago, locale)) } %>
|
||||
<%= video.premiere_timestamp.try { |t| I18n.translate(locale, "videoinfo_started_streaming_x_ago", recode_date((Time.utc - t).ago, locale)) } %>
|
||||
</h3>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -124,13 +124,13 @@ we're going to need to do it here in order to allow for translations.
|
||||
link_yt_embed = IV::HttpServer::Utils.add_params_to_url(link_yt_embed, link_yt_param)
|
||||
end
|
||||
-%>
|
||||
<a id="link-yt-watch" rel="noreferrer noopener" data-base-url="<%= link_yt_watch %>" href="<%= link_yt_watch %>"><%= translate(locale, "videoinfo_watch_on_youTube") %></a>
|
||||
(<a id="link-yt-embed" rel="noreferrer noopener" data-base-url="<%= link_yt_embed %>" href="<%= link_yt_embed %>"><%= translate(locale, "videoinfo_youTube_embed_link") %></a>)
|
||||
<a id="link-yt-watch" rel="noreferrer noopener" data-base-url="<%= link_yt_watch %>" href="<%= link_yt_watch %>"><%= I18n.translate(locale, "videoinfo_watch_on_youTube") %></a>
|
||||
(<a id="link-yt-embed" referrerpolicy="origin-when-cross-origin" rel="noopener" data-base-url="<%= link_yt_embed %>" href="<%= link_yt_embed %>"><%= I18n.translate(locale, "videoinfo_youTube_embed_link") %></a>)
|
||||
</span>
|
||||
|
||||
<p id="watch-on-another-invidious-instance">
|
||||
<%- link_iv_other = IV::Frontend::Misc.redirect_url(env) -%>
|
||||
<a id="link-iv-other" data-base-url="<%= link_iv_other %>" href="<%= link_iv_other %>"><%= translate(locale, "Switch Invidious Instance") %></a>
|
||||
<a id="link-iv-other" data-base-url="<%= link_iv_other %>" href="<%= link_iv_other %>"><%= I18n.translate(locale, "Switch Invidious Instance") %></a>
|
||||
</p>
|
||||
|
||||
<p id="embed-link">
|
||||
@@ -141,17 +141,17 @@ we're going to need to do it here in order to allow for translations.
|
||||
link_iv_embed = URI.new(path: "/embed/#{id}")
|
||||
link_iv_embed = IV::HttpServer::Utils.add_params_to_url(link_iv_embed, params_iv_embed)
|
||||
-%>
|
||||
<a id="link-iv-embed" data-base-url="<%= link_iv_embed %>" href="<%= link_iv_embed %>"><%= translate(locale, "videoinfo_invidious_embed_link") %></a>
|
||||
<a id="link-iv-embed" data-base-url="<%= link_iv_embed %>" href="<%= link_iv_embed %>"><%= I18n.translate(locale, "videoinfo_invidious_embed_link") %></a>
|
||||
</p>
|
||||
|
||||
<p id="annotations">
|
||||
<% if params.annotations %>
|
||||
<a href="/watch?<%= env.params.query %>&iv_load_policy=3">
|
||||
<%= translate(locale, "Hide annotations") %>
|
||||
<%= I18n.translate(locale, "Hide annotations") %>
|
||||
</a>
|
||||
<% else %>
|
||||
<a href="/watch?<%= env.params.query %>&iv_load_policy=1">
|
||||
<%=translate(locale, "Show annotations")%>
|
||||
<%=I18n.translate(locale, "Show annotations")%>
|
||||
</a>
|
||||
<% end %>
|
||||
</p>
|
||||
@@ -161,7 +161,7 @@ we're going to need to do it here in order to allow for translations.
|
||||
<% if !playlists.empty? %>
|
||||
<form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax?action=add_video" method="post" target="_blank">
|
||||
<div class="pure-control-group">
|
||||
<label for="playlist_id"><%= translate(locale, "Add to playlist: ") %></label>
|
||||
<label for="playlist_id"><%= I18n.translate(locale, "Add to playlist: ") %></label>
|
||||
<select style="width:100%" name="playlist_id" id="playlist_id">
|
||||
<% playlists.each do |plid, playlist_title| %>
|
||||
<option data-plid="<%= plid %>" value="<%= plid %>" <%= "selected" if user.preferences.default_playlist == plid %>><%= HTML.escape(playlist_title) %></option>
|
||||
@@ -172,7 +172,7 @@ we're going to need to do it here in order to allow for translations.
|
||||
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
|
||||
<input type="hidden" name="video_id" value="<%= video.id %>">
|
||||
<button data-onclick="add_playlist_video" data-id="<%= video.id %>" type="submit" class="pure-button pure-button-primary">
|
||||
<b><%= translate(locale, "Add to playlist") %></b>
|
||||
<b><%= I18n.translate(locale, "Add to playlist") %></b>
|
||||
</button>
|
||||
</form>
|
||||
<script id="playlist_data" type="application/json">
|
||||
@@ -191,7 +191,7 @@ we're going to need to do it here in order to allow for translations.
|
||||
<p id="views"><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p>
|
||||
<p id="likes"><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p>
|
||||
<p id="dislikes" style="display: none; visibility: hidden;"></p>
|
||||
<p id="genre"><%= translate(locale, "Genre: ") %>
|
||||
<p id="genre"><%= I18n.translate(locale, "Genre: ") %>
|
||||
<% if !video.genre_url %>
|
||||
<%= video.genre %>
|
||||
<% else %>
|
||||
@@ -200,21 +200,21 @@ we're going to need to do it here in order to allow for translations.
|
||||
</p>
|
||||
<% if video.license %>
|
||||
<% if video.license.empty? %>
|
||||
<p id="license"><%= translate(locale, "License: ") %><%= translate(locale, "Standard YouTube license") %></p>
|
||||
<p id="license"><%= I18n.translate(locale, "License: ") %><%= I18n.translate(locale, "Standard YouTube license") %></p>
|
||||
<% else %>
|
||||
<p id="license"><%= translate(locale, "License: ") %><%= video.license %></p>
|
||||
<p id="license"><%= I18n.translate(locale, "License: ") %><%= video.license %></p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<p id="family_friendly"><%= translate(locale, "Family friendly? ") %><%= translate_bool(locale, video.is_family_friendly) %></p>
|
||||
<p id="family_friendly"><%= I18n.translate(locale, "Family friendly? ") %><%= I18n.translate_bool(locale, video.is_family_friendly) %></p>
|
||||
<p id="wilson" style="display: none; visibility: hidden;"></p>
|
||||
<p id="rating" style="display: none; visibility: hidden;"></p>
|
||||
<p id="engagement" style="display: none; visibility: hidden;"></p>
|
||||
<% if video.allowed_regions.size != REGIONS.size %>
|
||||
<p id="allowed_regions">
|
||||
<% if video.allowed_regions.size < REGIONS.size // 2 %>
|
||||
<%= translate(locale, "Whitelisted regions: ") %><%= video.allowed_regions.join(", ") %>
|
||||
<%= I18n.translate(locale, "Whitelisted regions: ") %><%= video.allowed_regions.join(", ") %>
|
||||
<% else %>
|
||||
<%= translate(locale, "Blacklisted regions: ") %><%= (REGIONS.to_a - video.allowed_regions).join(", ") %>
|
||||
<%= I18n.translate(locale, "Blacklisted regions: ") %><%= (REGIONS.to_a - video.allowed_regions).join(", ") %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
@@ -246,9 +246,9 @@ we're going to need to do it here in order to allow for translations.
|
||||
<div class="h-box">
|
||||
<p id="published-date">
|
||||
<% if video.premiere_timestamp.try &.> Time.utc %>
|
||||
<b><%= video.premiere_timestamp.try { |t| translate(locale, "Premieres `x`", t.to_s("%B %-d, %R UTC")) } %></b>
|
||||
<b><%= video.premiere_timestamp.try { |t| I18n.translate(locale, "Premieres `x`", t.to_s("%B %-d, %R UTC")) } %></b>
|
||||
<% else %>
|
||||
<b><%= translate(locale, "Shared `x`", video.published.to_s("%B %-d, %Y")) %></b>
|
||||
<b><%= I18n.translate(locale, "Shared `x`", video.published.to_s("%B %-d, %Y")) %></b>
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
@@ -270,7 +270,7 @@ we're going to need to do it here in order to allow for translations.
|
||||
<input id="music-desc-expansion" type="checkbox"/>
|
||||
<label for="music-desc-expansion">
|
||||
<h3 id="music-description-title">
|
||||
<%= translate(locale, "Music in this video") %>
|
||||
<%= I18n.translate(locale, "Music in this video") %>
|
||||
<span class="icon ion-ios-arrow-up"></span>
|
||||
<span class="icon ion-ios-arrow-down"></span>
|
||||
</h3>
|
||||
@@ -279,9 +279,9 @@ we're going to need to do it here in order to allow for translations.
|
||||
<div id="music-description-box">
|
||||
<% video.music.each do |music| %>
|
||||
<div class="music-item">
|
||||
<p class="music-song"><%= translate(locale, "Song: ") %><%= music.song %></p>
|
||||
<p class="music-artist"><%= translate(locale, "Artist: ") %><%= music.artist %></p>
|
||||
<p class="music-album"><%= translate(locale, "Album: ") %><%= music.album %></p>
|
||||
<p class="music-song"><%= I18n.translate(locale, "Song: ") %><%= music.song %></p>
|
||||
<p class="music-artist"><%= I18n.translate(locale, "Artist: ") %><%= music.artist %></p>
|
||||
<p class="music-album"><%= I18n.translate(locale, "Album: ") %><%= music.album %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -294,7 +294,7 @@ we're going to need to do it here in order to allow for translations.
|
||||
<% else %>
|
||||
<noscript>
|
||||
<a href="/watch?<%= env.params.query %>&nojs=1">
|
||||
<%= 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.") %>
|
||||
<%= 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.") %>
|
||||
</a>
|
||||
</noscript>
|
||||
<% end %>
|
||||
@@ -313,7 +313,7 @@ we're going to need to do it here in order to allow for translations.
|
||||
<% if !video.related_videos.empty? %>
|
||||
<div <% if plid %>style="display:none"<% end %>>
|
||||
<div class="pure-control-group">
|
||||
<label for="continue"><%= translate(locale, "preferences_continue_label") %></label>
|
||||
<label for="continue"><%= I18n.translate(locale, "preferences_continue_label") %></label>
|
||||
<input name="continue" id="continue" type="checkbox" <% if params.continue %>checked<% end %>>
|
||||
</div>
|
||||
<hr>
|
||||
@@ -356,7 +356,7 @@ we're going to need to do it here in order to allow for translations.
|
||||
<div class="pure-u-10-24" style="text-align:right">
|
||||
<b class="width:100%"><%=
|
||||
views = short_text_to_number(rv["short_view_count"]? || "0")
|
||||
translate_count(locale, "generic_views_count", views, NumberFormatting::Short)
|
||||
I18n.translate_count(locale, "generic_views_count", views, I18n::NumberFormatting::Short)
|
||||
%></b>
|
||||
</div>
|
||||
</h5>
|
||||
|
||||
@@ -630,13 +630,14 @@ private module Parsers
|
||||
end
|
||||
end
|
||||
|
||||
# Parses an InnerTube lockupViewModel into a SearchPlaylist.
|
||||
# Parses an InnerTube lockupViewModel into a SearchPlaylist or a SearchVideo
|
||||
# Returns nil when the given object is not a lockupViewModel.
|
||||
#
|
||||
# This structure is present since November 2024 on the "podcasts" and
|
||||
# "playlists" tabs of the channel page. It is usually encapsulated in either
|
||||
# a richItemRenderer or a richGridRenderer.
|
||||
#
|
||||
# Since 2026-05-21, now channel videos are encapsulated in a lockupViewModel.
|
||||
module LockupViewModelParser
|
||||
extend self
|
||||
include BaseParser
|
||||
@@ -648,59 +649,185 @@ private module Parsers
|
||||
end
|
||||
|
||||
private def parse_internal(item_contents, author_fallback)
|
||||
playlist_id = item_contents["contentId"].as_s
|
||||
content_type = item_contents["contentType"].as_s
|
||||
|
||||
thumbnail_view_model = item_contents.dig(
|
||||
"contentImage", "collectionThumbnailViewModel",
|
||||
"primaryThumbnail", "thumbnailViewModel"
|
||||
)
|
||||
if content_type == "LOCKUP_CONTENT_TYPE_VIDEO"
|
||||
thumbnail_view_model = item_contents.dig(
|
||||
"contentImage", "thumbnailViewModel"
|
||||
)
|
||||
thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s
|
||||
|
||||
thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s
|
||||
video_id = item_contents["contentId"].as_s
|
||||
|
||||
# This complicated sequences tries to extract the following data structure:
|
||||
# "overlays": [{
|
||||
# "thumbnailOverlayBadgeViewModel": {
|
||||
# "thumbnailBadges": [{
|
||||
# "thumbnailBadgeViewModel": {
|
||||
# "text": "430 episodes",
|
||||
# "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT"
|
||||
# }
|
||||
# }]
|
||||
# }
|
||||
# }]
|
||||
#
|
||||
# NOTE: this simplistic `.to_i` conversion might not work on larger
|
||||
# playlists and hasn't been tested.
|
||||
video_count = thumbnail_view_model.dig("overlays").as_a
|
||||
.compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a)
|
||||
.flatten
|
||||
.find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node|
|
||||
{"episodes", "videos"}.any? { |str| node.as_s.ends_with?(str) }
|
||||
metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
|
||||
title = metadata.dig("title", "content").as_s
|
||||
# Contains the views of the video and the published time of the video.
|
||||
metadata_parts = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows", 0, "metadataParts").try &.as_a
|
||||
|
||||
view_count_text = metadata_parts.try &.find { |item| item["icon"]?.nil? && item.dig?("text", "content").try &.as_s.includes?("views") }
|
||||
.try &.dig("text", "content").as_s
|
||||
published = metadata_parts.try &.find { |item| item["icon"]?.nil? && item.dig?("text", "content").try &.as_s.includes?("ago") }
|
||||
.try { |item| decode_date(item.dig("text", "content").as_s) } || Time.local
|
||||
|
||||
view_count = short_text_to_number(view_count_text || "0")
|
||||
|
||||
length = thumbnail_view_model.dig("overlays", 0, "thumbnailBottomOverlayViewModel", "badges", 0, "thumbnailBadgeViewModel", "text").try &.as_s
|
||||
|
||||
length_seconds = decode_length_seconds(length) if length
|
||||
|
||||
return SearchVideo.new({
|
||||
title: title,
|
||||
id: video_id,
|
||||
author: author_fallback.name,
|
||||
ucid: author_fallback.id,
|
||||
published: published,
|
||||
views: view_count,
|
||||
description_html: "",
|
||||
length_seconds: length_seconds || 0,
|
||||
premiere_timestamp: Time.unix(0),
|
||||
author_verified: false,
|
||||
author_thumbnail: nil,
|
||||
badges: VideoBadges::None,
|
||||
})
|
||||
.try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false)
|
||||
# If it's a playlist, it's content_type would be "LOCKUP_CONTENT_TYPE_PLAYLIST"
|
||||
# If it's a podcast, it's content_type would be "LOCKUP_CONTENT_TYPE_PODCAST"
|
||||
# Playlist and Podcasts structures are quite similar, so we can use the same logic
|
||||
# we use to parse Playlists data, for Podcasts.
|
||||
else
|
||||
thumbnail_view_model = item_contents.dig(
|
||||
"contentImage", "collectionThumbnailViewModel",
|
||||
"primaryThumbnail", "thumbnailViewModel"
|
||||
)
|
||||
thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s
|
||||
|
||||
metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
|
||||
title = metadata.dig("title", "content").as_s
|
||||
playlist_id = item_contents["contentId"].as_s
|
||||
|
||||
# TODO: Retrieve "updated" info from metadata parts
|
||||
# rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").as_a
|
||||
# parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s)
|
||||
# One of these parts should contain a string like: "Updated 2 days ago"
|
||||
# This complicated sequences tries to extract the following data structure:
|
||||
#
|
||||
# "overlays": [
|
||||
# {
|
||||
# "thumbnailOverlayBadgeViewModel": {
|
||||
# "thumbnailBadges": [
|
||||
# {
|
||||
# "thumbnailBadgeViewModel": {
|
||||
# "icon": {
|
||||
# "sources": [
|
||||
# {
|
||||
# "clientResource": {
|
||||
# "imageName": "BROADCAST"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# },
|
||||
# "text": "5 episodes",
|
||||
# "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT",
|
||||
# "backgroundColor": {
|
||||
# "lightTheme": 991526,
|
||||
# "darkTheme": 991526
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# ],
|
||||
# "position": "THUMBNAIL_OVERLAY_BADGE_POSITION_BOTTOM_END"
|
||||
# }
|
||||
# },
|
||||
# ... <-- There is another item bellow the Object we use to extract episodes/videos
|
||||
# ]
|
||||
#
|
||||
# NOTE: this simplistic `.to_i` conversion might not work on larger
|
||||
# playlists and hasn't been tested.
|
||||
video_count = thumbnail_view_model.dig("overlays").as_a
|
||||
.compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a)
|
||||
.flatten
|
||||
.find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node|
|
||||
{"episodes", "videos", "lessons"}.any? { |str| node.as_s.ends_with?(str) }
|
||||
})
|
||||
.try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false)
|
||||
|
||||
# TODO: Maybe add a button to access the first video of the playlist?
|
||||
# item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint")
|
||||
# Available fields: "videoId", "playlistId", "params"
|
||||
metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
|
||||
title = metadata.dig("title", "content").as_s
|
||||
|
||||
return SearchPlaylist.new({
|
||||
title: title,
|
||||
id: playlist_id,
|
||||
author: author_fallback.name,
|
||||
ucid: author_fallback.id,
|
||||
video_count: video_count || -1,
|
||||
videos: [] of SearchPlaylistVideo,
|
||||
thumbnail: thumbnail,
|
||||
author_verified: false,
|
||||
})
|
||||
# metadataParts is not always in the first place of the metadataRows array, therefore,
|
||||
# we search for it iterating the array. We have only seen metadataRows with at least
|
||||
# 2 items inside it.
|
||||
#
|
||||
# It looks like this:
|
||||
# "metadataRows": [
|
||||
# {}, <-- empty Object
|
||||
# {
|
||||
# "metadataParts": [ ... ] <-- metadataParts with the information we are searching for.
|
||||
# }
|
||||
# ]
|
||||
#
|
||||
# Playlist on channels also contain metadataRows, but not with the type of data we are searching
|
||||
# for which are the channel name and channel ID, instead they have two fields depending of the playlist
|
||||
# updated date:
|
||||
#
|
||||
# It looks like this:
|
||||
# "metadataRows": [
|
||||
# {
|
||||
# "metadataParts": [
|
||||
# {
|
||||
# "text": {
|
||||
# "content": "Updated 4 days ago"
|
||||
# }
|
||||
# } <-- This object is missing if the playlist has not been updated in around 7
|
||||
# days
|
||||
# ]
|
||||
# },
|
||||
# {
|
||||
# "metadataParts": [
|
||||
# {
|
||||
# "text": {
|
||||
# "content": "View full playlist",
|
||||
# "commandRuns": [ ... ],
|
||||
# "styleRuns": [ ... ].
|
||||
# }
|
||||
# } <-- This object is always present, so we use this to determine if the
|
||||
# metadataParts can be used or not.
|
||||
# ]
|
||||
# }
|
||||
# ]
|
||||
#
|
||||
metadata_rows = metadata.dig?("metadata", "contentMetadataViewModel", "metadataRows").try &.as_a
|
||||
metadata_parts = metadata_rows.try &.find { |row|
|
||||
parts = row["metadataParts"]?.try &.as_a
|
||||
parts && !parts.any? { |item| item.dig?("text", "content").try &.as_s == "View full playlist" }
|
||||
}.try &.["metadataParts"].as_a
|
||||
|
||||
if author_info = metadata_parts.try &.find(&.dig?("text", "commandRuns"))
|
||||
.try &.["text"]
|
||||
author = author_info["content"].as_s
|
||||
author_id = author_info.dig?("commandRuns", 0, "onTap", "innertubeCommand", "browseEndpoint", "browseId")
|
||||
.try &.as_s || author_fallback.id
|
||||
author_verified = (author_info.dig?("attachmentRuns", 0, "element", "type", "imageType", "image", "sources", 0, "clientResource", "imageName")
|
||||
.try &.as_s) == "CHECK_CIRCLE_FILLED" || false
|
||||
else
|
||||
author = author_fallback.name
|
||||
author_id = author_fallback.id
|
||||
author_verified = false
|
||||
end
|
||||
|
||||
# TODO: Retrieve "updated" info from metadata parts
|
||||
# rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").as_a
|
||||
# parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s)
|
||||
# One of these parts should contain a string like: "Updated 2 days ago"
|
||||
|
||||
# TODO: Maybe add a button to access the first video of the playlist?
|
||||
# item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint")
|
||||
# Available fields: "videoId", "playlistId", "params"
|
||||
|
||||
return SearchPlaylist.new({
|
||||
title: title,
|
||||
id: playlist_id,
|
||||
author: author,
|
||||
ucid: author_id,
|
||||
video_count: video_count || -1,
|
||||
videos: [] of SearchPlaylistVideo,
|
||||
thumbnail: thumbnail,
|
||||
author_verified: author_verified,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
def self.parser_name
|
||||
|
||||
@@ -480,7 +480,7 @@ module YoutubeAPI
|
||||
#
|
||||
# ```
|
||||
# # Valid channel "brand URL" gives the related UCID and browse ID
|
||||
# channel_a = YoutubeAPI.resolve_url("https://youtube.com/c/google")
|
||||
# channel_a = YoutubeAPI.resolve_url("https://www.youtube.com/c/google")
|
||||
# channel_a # => {
|
||||
# "endpoint": {
|
||||
# "browseEndpoint": {
|
||||
@@ -492,7 +492,7 @@ module YoutubeAPI
|
||||
# }
|
||||
#
|
||||
# # Invalid URL returns throws an InfoException
|
||||
# channel_b = YoutubeAPI.resolve_url("https://youtube.com/c/invalid")
|
||||
# channel_b = YoutubeAPI.resolve_url("https://www.youtube.com/c/invalid")
|
||||
# ```
|
||||
#
|
||||
def resolve_url(url : String, client_config : ClientConfig | Nil = nil)
|
||||
|
||||
Reference in New Issue
Block a user