Compare commits

..

62 Commits

Author SHA1 Message Date
Fijxu 63cb6db1a3 Dockerfile: Switch to 84codes crystal compiler container image 2026-05-30 16:56:50 -04:00
Fijxu e96ad036ca fix: Do not append query params quality=medium to videos that are about to premiere (#5755)
* fix: Do not append query params `quality=medium` to videos that are about to premiere

Video premieres do not have `audio_streams` and they aren't `video.live_now == true` either,
therefore we skip the checks that are made for older videos (`if audio_streams.empty? && !video.live_now` closure)
if the video is a premiere.

This commit also adds the `microformat` field to the `info` instance
variable of the `Video` struct, since it's needed for the function
`Video#premiere_timestamp : Time?`. This is more like a quick fix than a
proper fix because in `Invidious::Videos::Parser#parse_video_info`,
`premiere_timestamp` is gathered from the `microformat` JSON Object but
is used to set the `published` hash key for the `params` variable.

The parsers and structs really need a rework :/

* Revert "fix: Do not append query params `quality=medium` to videos that are about to premiere"

This reverts commit 5cfc8dace8.

* chore: build premiere_timestamp using video_type and published time
2026-05-30 15:49:21 -04:00
Fijxu 1a5a71b086 Fix Youtube and Invidious links not rewinding their time when video timestamp is rewinded (#5601)
Revert "Fix Youtube and Invidious links not rewinding their time when video timestamp is rewinded"

This reverts commit c3ee8a5d90.
2026-05-29 23:54:08 -04:00
Fijxu 3a35552a66 fix: fix wrong call to I18n.translate() function 2026-05-29 23:51:34 -04:00
Fijxu 8ef5ea03d4 feat: Add support for POST requests on searches for privacy (#5551)
Use already set preferences variable

Use span for description, grammar fix from Copilot
2026-05-29 23:48:11 -04:00
shiny-comic 86c425b43f Fix disappearing end of the comments with emoji (#5587)
Previous code use UTF-8 to count characters however Emojis are UTF-16 units.
This difference leads to misalignment of index offsets.

Co-authored-by: shiny-comic <shiny-comic@winPC>
2026-05-28 16:51:06 -04:00
Fijxu 4ae227ce91 Encapsulate videos parser and clip functions inside it's own Invidious::Videos::Parser and Invidious::Videos::Clip module (#5745)
Part of https://github.com/iv-org/invidious/issues/5744
2026-05-28 13:11:41 -04:00
Fijxu 8b183caa2a fix: fix author verification in channels (#5751)
* fix author verification in channels

Fixes https://github.com/iv-org/invidious/issues/5730

Author verification badge is not longer located in
`c4TabbedHeaderRenderer`. It may be deprecated.

* Also detect AUDIO_BADGE for verified author
2026-05-28 13:10:58 -04:00
Fijxu edb3a0fc36 Add support for /pl_c and /tvfilm_banner paths (thumbnails used in some playlists) (#5742)
* feat: add support for /pl_c/ images

This path can be found on Podcast images.

* add support for /pl_c and /tvfilm_banner paths

* add support for /pl_c and /tvfilm_banner paths 2

* remove leftover comment
2026-05-26 18:13:51 -04:00
Fijxu 6659cbbbd8 fix: fix channel videos and playlists on searches (#5736)
* fix: fix channel videos and playlists on searches

Channel videos are now encapsulated in a `lockupViewModel`.

There is 3 types of content that can be inside a `lockupViewModel`:

- LOCKUP_CONTENT_TYPE_VIDEO
- LOCKUP_CONTENT_TYPE_PLAYLIST
- LOCKUP_CONTENT_TYPE_PODCAST

This commit parses `LOCKUP_CONTENT_TYPE_VIDEO`, `LOCKUP_CONTENT_TYPE_PLAYLIST`, `LOCKUP_CONTENT_TYPE_PODCAST` types
to fix videos in channels, playlists in channels, podcast in channels, and other parts of Invidious were playlists and videos are displayed.

* remove unused variable `author_verified`

* fix parsing for podcasts

For some reason, Podcasts contains an empty JSON Object that we have to
skip, therefore we just iterate metadataRows until finding metadataParts
since metadataRows will not always contain a single Object.

* fix length_seconds for channel videos

* fix playlists parsing for playlists without metadataParts

On some channels like MrBeast, metadataParts is absent, missing the
author information, this is intended behaviour by Youtube since there is
no author information attached to them.

Example URL: https://www.youtube.com/channel/UCX6OQ3DkcsbYNE6H8uQQuVA/playlists

* restore author_verified functionality

* more robust metadata_parts parsing

Videos that have two or more authors (in collaborations), have their
author information in JSON Objects inside metadataParts, alongside with
their view count and their published date, therefore, we need to iterate
the metadataParts array and do some filtering based on the content of
each JSON Object to find the view count and published date for some
videos.

Example:

```json
"metadataParts": [
    {
        "text": {
            "content": "Veritasium"
        },
        "icon": {
            "name": "CHECK_CIRCLE_FILLED",
            "height": 14,
            "width": 14,
            "accessibilityLabel": "Verified"
        }
    },
    {
        "text": {
            "content": "and Linus Tech Tips"
        },
        "icon": {
            "name": "CHECK_CIRCLE_FILLED",
            "height": 14,
            "width": 14,
            "accessibilityLabel": "Verified"
        }
    },
    {
        "text": {
            "content": "10M"
        },
        "accessibilityLabel": "10 million views"
    },
    {
        "text": {
            "content": "1y ago"
        }
    }
]
```

* improve playlist metadataRows and metadataParts parsing for channel playlists

* apply ameba suggestion

* Also parse lessons for playlists that are a course
2026-05-26 17:46:32 -04:00
Fijxu 99390d065d fix: fix Missing hash key: "collectionThumbnailViewModel" for channel video thumbnails (#5725)
Now the Innertube structure doesn't include `collectionThumbnailViewModel`
and `primaryThumbnail` keys.

Now the structure looks like this:
```
"horizontalListRenderer": {
    "items": [
        {
            "lockupViewModel": {
                "contentImage": {
                    "thumbnailViewModel": {
                        "image": {
                            "sources": []
...
```

Fixes: https://github.com/iv-org/invidious/issues/5516
2026-05-20 21:36:50 -04:00
dependabot[bot] e82ac674ae chore(deps): bump int128/docker-manifest-create-action (#5721)
Bumps [int128/docker-manifest-create-action](https://github.com/int128/docker-manifest-create-action) from 2.20.0 to 2.21.0.
- [Release notes](https://github.com/int128/docker-manifest-create-action/releases)
- [Commits](https://github.com/int128/docker-manifest-create-action/compare/v2.20.0...v2.21.0)

---
updated-dependencies:
- dependency-name: int128/docker-manifest-create-action
  dependency-version: 2.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 14:08:06 -04:00
Fijxu 103f80e617 CI: update Crystal 1.20.1 to 1.20.2 in ci.yml matrix 2026-05-17 16:59:49 -04:00
Fijxu 9ee39bc437 chore: update Crystal to 1.20.2 in OCI 2026-05-17 16:58:36 -04:00
Fijxu 529fc8a8a3 fix: restore referrerpolicy for embed youtube link that got removed in commit bc64cd9 2026-05-12 23:13:37 -04:00
Fijxu b4728b81dc chore: update openssl to 3.6.2 in OCI (#5701) 2026-05-12 15:28:41 -04:00
dependabot[bot] f914ce8040 Bump int128/docker-manifest-create-action from 2.19.0 to 2.20.0 (#5705)
Bumps [int128/docker-manifest-create-action](https://github.com/int128/docker-manifest-create-action) from 2.19.0 to 2.20.0.
- [Release notes](https://github.com/int128/docker-manifest-create-action/releases)
- [Commits](https://github.com/int128/docker-manifest-create-action/compare/v2.19.0...v2.20.0)

---
updated-dependencies:
- dependency-name: int128/docker-manifest-create-action
  dependency-version: 2.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 09:31:29 -04:00
Fijxu 57ba257233 CI: Unify ARM64 and AMD64 Dockerfiles (#5700)
* CI: Unify ARM64 and AMD64 Dockerfiles

* chore: remove replacement of Dockerfile in docker-compose.yml for ci.yml build-docker job

* Combine images into manifest list

* fix wrong tag for nightly container release

* forgot this ^^'
2026-05-04 14:59:24 -04:00
Fijxu e012334975 chore: update Crystal to 1.20.1 in OCI 2026-05-01 14:12:15 -04:00
Fijxu 85a078a580 CI: update Crystal 1.20.0 to 1.20.1 in ci.yml matrix (#5703) 2026-04-30 13:18:49 -04:00
Fijxu afea61bb8f CI: display progress and stats when compiling Invidious in ci.yml matrix (#5696) 2026-04-28 06:42:45 -04:00
Fijxu fd313e0107 CI: Bump Crystal version matrix (#5691)
* CI: Bump Crystal version matrix

Add Crystal 1.19.2 and 1.20.0

* update crystal 1.19.1 to 1.19.2
2026-04-27 20:15:11 -04:00
Fijxu 0c600988ca chore: update Crystal to 1.20.0 in OCI (#5692)
* chore: update Crystal to 1.20.0 in amd64 OCI

* use alpine:edge with crystal 1.20.0 to build Invidious, use alpine:3.23 for final layer
2026-04-27 20:11:51 -04:00
Cameron Radmore 264e7c24e9 player: Use correct time parameter for YouTube embed redirects (#5660) 2026-04-26 14:02:11 -04:00
Fijxu 9eda6e5bc4 chore: lint api/v1/channels.cr (#5693) 2026-04-25 16:57:45 -04:00
Fijxu 73c749f13f Encapsulate helpers constants and functions inside it's own Helpers module (#5639)
It encapsulates all related code from helpers.cr into it's own module.
2026-04-25 16:57:33 -04:00
Fijxu bc64cd9b67 Encapsulate translation constants and functions inside it's own module (#5637)
It encapsulates all related code from translation into it's own module.

Required for the migration to the crystal stdlib logger: https://github.com/iv-org/invidious/pull/5426
2026-04-25 16:55:55 -04:00
dependabot[bot] 54365c0e2a Bump crystal-lang/install-crystal from 1.9.1 to 1.9.2 (#5686)
Bumps [crystal-lang/install-crystal](https://github.com/crystal-lang/install-crystal) from 1.9.1 to 1.9.2.
- [Release notes](https://github.com/crystal-lang/install-crystal/releases)
- [Commits](https://github.com/crystal-lang/install-crystal/compare/v1.9.1...v1.9.2)

---
updated-dependencies:
- dependency-name: crystal-lang/install-crystal
  dependency-version: 1.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 01:11:37 +02:00
ChunkyProgrammer 606467c693 Playlists: fix parsing error when some videos are paid for in a course (#5207)
* Playlists: fix parsing error when some videos are paid for in a course

* Remove redundant casting to string

fix rebase error

Co-Authored-By: syeopite <70992037+syeopite@users.noreply.github.com>

* Fix rebase issues

---------

Co-authored-by: syeopite <70992037+syeopite@users.noreply.github.com>
2026-04-09 14:52:03 +02:00
dependabot[bot] 749791cdf1 Bump docker/login-action from 3 to 4 (#5661)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 09:59:52 +01:00
dependabot[bot] d7361cbb9a Bump docker/build-push-action from 6 to 7 (#5662)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 09:59:44 +01:00
dependabot[bot] f07c9a7209 Bump docker/metadata-action from 5 to 6 (#5663)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 09:59:34 +01:00
dependabot[bot] cf9b6c4fcb Bump docker/setup-buildx-action from 3 to 4 (#5664)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 09:59:26 +01:00
Ashley :3 21d0d1041a Remove noreferrer since youtube now requires referrers on embeds (#5642)
* Remove noreferer since youtube now requires referers on embeds

* Update src/invidious/views/watch.ecr

---------

Co-authored-by: Fijxu <fijxu@nadeko.net>
2026-02-23 21:36:12 -03:00
Fijxu fda8d1b528 Remove trailing whitespaces from codebase (#5634)
Removes trailing whitespaces found across the codebase using `find . -type f -exec grep -lE ' +$' {} +`

[skip ci]
2026-02-19 14:28:22 -03:00
Jeroen Boersma e7f8b15b21 Add title listen button time updates (#5625)
When switching between Listen and Watching the timestamp in the url of
the listen of watch button is now updated automatically.

This means if you switch between listening and viewing you keep in sync
with time.
2026-02-16 16:39:44 -03:00
Fijxu 60c31e3069 Remove sort by rating and date in video search filters (#5629)
* Remove sort by rating and date in video search filters

Closes https://github.com/iv-org/invidious/issues/5626

* Remove check of protobug generation of rating and date sort filters in Invidious spec
2026-02-16 14:06:06 -03:00
Emilien 11db343cfb Prepare for next release 2026-02-07 22:10:11 +01:00
Émilien (perso) 118d635650 Release v2.20260207.0 (#5621)
* Release v2.20260207.0

* Fix release notes for Crystal/OpenSSL

* fix comment about pr #5566, #5338

Co-authored-by: Fijxu <fijxu@nadeko.net>

* fix comment about memory leaks

Co-authored-by: Fijxu <fijxu@nadeko.net>

* Clarify release notes for proxy header stripping

---------

Co-authored-by: Fijxu <fijxu@nadeko.net>
2026-02-07 21:47:19 +01:00
Fijxu 29c29f7c8d Update src/invidious/routes/routes.cr
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-06 18:50:36 +01:00
Fijxu 067a426235 refactor: Move top level constants to it's own modules 2026-02-06 18:50:36 +01:00
Samantaz Fox ffd9f4b112 pages/watch: HTML escape 'action' in download widget
Caught in the review of PR 5224, but forgot to click on "send review" in time.
I realized that too late, after the PR was already merged.
2026-02-06 18:44:37 +01:00
Fijxu cc7cb94095 Document use of unix sockets for db 2026-02-06 18:39:53 +01:00
Fijxu 0ee92e3298 Update src/invidious/routes/before_all.cr
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-06 18:35:20 +01:00
Fijxu a3a97ccf07 Only generate companion CSP one time to reuse it 2026-02-06 18:35:20 +01:00
ThatMatrix ce9494133d fix(user/imports.cr): double header removal caused first video to be skipped 2026-02-06 18:33:58 +01:00
ThatMatrix e4beb00413 fix(user/imports.cr): splitting error fixed 2026-02-06 18:33:58 +01:00
ThatMatrix 050032b188 fix(docker-compose.yml): removed hmac_key (randomly generated) used for testing 2026-02-06 18:33:58 +01:00
ThatMatrix 471857ce8b Fix(user/importers): Fixed typos 2026-02-06 18:33:58 +01:00
ThatMatrix 7be6fbd75c Fix(user/importers): Fixed youtube csv playlist importer 2026-02-06 18:33:58 +01:00
Cameron Radmore 84a699f7b7 Playlist API: return empty author url if ucid is empty (#5618) 2026-02-05 11:59:27 -03:00
Cameron Radmore 864893f4c7 Channels: parse pronouns and display them on channel page (#5617) 2026-02-05 11:58:52 -03:00
Cameron Radmore ecbc21b067 playlist: parse playlist thumbnails for topic autogenerated playlists (#5616) 2026-02-04 12:57:16 -03:00
Fijxu a9f812799c fix: add missing embedded protobuf message in continuation token for channel videos (#5614)
* fix: add missing embedded protobuf message in continuation token for channel videos

* fix: add missing embedded protobuf message in continuation token for channel shorts

* fix: add missing embedded protobuf message in continuation token for channel livestreams
2026-02-03 16:18:15 -03:00
Harm133 48be830544 Update shard.yml to include target (#5608)
[shard.yml]
- Include a target for LSPs to use as an entrypoint:
  (https://github.com/elbywan/crystalline?tab=readme-ov-file#entry-point)
2026-01-30 23:39:07 +01:00
Fijxu b521e3be6c chore: Do not convert thin_mode preference to string to compare it (#5568) 2026-01-30 18:01:16 -03:00
Fijxu abb0aa436c Fix thin_mode preference for channel community page (#5567)
thin_mode only took in account the query param because
env.get("preferences").as(Preferences).thin_mode returned a boolean and
not a string to be able to compare it with the string `"true"`
2026-01-30 18:01:04 -03:00
Kiril Isakov d51a7a44ad Fix commit command in README instructions, as per #5606 (#5607) 2026-01-23 13:18:41 +01:00
Émilien (perso) 7e36cfb667 Revert "Bump crystallang/crystal from 1.16.3-alpine to 1.19.0-alpine in /dock…" (#5604)
This reverts commit d25cc9570c.
2026-01-19 23:39:01 +01:00
dependabot[bot] d25cc9570c Bump crystallang/crystal from 1.16.3-alpine to 1.19.0-alpine in /docker (#5603)
Bumps crystallang/crystal from 1.16.3-alpine to 1.19.0-alpine.

---
updated-dependencies:
- dependency-name: crystallang/crystal
  dependency-version: 1.19.0-alpine
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 22:59:44 +01:00
Émilien (perso) 66c67f4c7a doc: Update HTTP proxy configuration comments (#5586)
* doc: Update HTTP proxy configuration comments

Added information about proxy configuration for YouTube streams.

* Document supported proxy types in config.example.yml

Added note about supported proxy types in configuration.
2026-01-17 00:15:32 +01:00
Fijxu 344bc2d8e9 Strip unwanted headers from response headers in images and videoplayback (#5595)
Image responses contained the following unwanted headers that should not
be passed to the clients:

```
"Cross-Origin-Resource-Policy"
["cross-origin"]
"Cross-Origin-Opener-Policy-Report-Only"
["same-origin; report-to=\"youtube\""]
"Report-To"
["{\"group\":\"youtube\",\"max_age\":2592000,\"endpoints\":[{\"url\":\"https://csp.withgoogle.com/csp/report-to/youtube\"}]}"]
"Timing-Allow-Origin"
["*"]
```
2026-01-16 19:39:44 -03:00
101 changed files with 1898 additions and 1577 deletions
+27 -6
View File
@@ -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.21.0
with:
push: true
tags: quay.io/invidious/invidious:master
sources: |
quay.io/invidious/invidious:master
quay.io/invidious/invidious:master-arm64
+27 -6
View File
@@ -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.21.0
with:
push: true
tags: quay.io/invidious/invidious:latest
sources: |
quay.io/invidious/invidious:latest
quay.io/invidious/invidious:latest-arm64
+5 -7
View File
@@ -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
+90
View File
@@ -2,6 +2,96 @@
## vX.Y.0 (future)
## v2.20260207.0
### Wrap-up
This release hardens the Invidious companion pipeline and cleans up a long list of UI papercuts. Companion downloads now work end-to-end, CSP headers and check identifiers are generated once and reused, proxy responses strip stray headers, and the final traces of the legacy signature helper are gone so the helper can be rolled out safely.
Livestream navigation, playlists, and channel metadata also see overdue fixes: Trending once again lists livestreams, "Watch on YouTube" buttons stop jumping to arbitrary timestamps, playlist imports/API calls handle missing data, and channel pages now display creator pronouns and playlist thumbnails. Deployments benefit from compiling OpenSSL into docker images to mitigate a long-standing memory leak observed with Alpine-provided OpenSSL, Crystal pinned back to 1.16.3 for docker and OCI builds, a rewritten static file handler, clarified README/HTTP proxy/unix socket docs, and dozens of smaller cleanups.
### New features & important changes
#### For Users
- Livestream experiences are restored: Trending shows livestreams again, the gaming feed remains accessible, and "Watch on YouTube" links stop carrying stale timestamps (#5480, #5555, #5481)
- Channel and playlist metadata is richer thanks to pronoun support, topic playlist thumbnails, and accurate related video counts (#5617, #5616, #5446)
- Downloads get smoother because download actions are URL-safe and downloads can flow through Invidious companion when available (#5367, #5561)
- Users see clearer feedback with Erroneous CAPTCHA messages, DMCA controls restored, and a footer link pointing at the current release (#5508, #5228, #4702)
#### For instance owners
- Companion integration is sturdier: CSP is generated once, check identifiers persist, and the helper hyperlink is fixed (#5497, #5575, #5491)
- Proxied images and videoplayback strip unwanted response headers (shared header-strip list) (#5595)
- Runtime and packaging updates pin docker/OCI builds to Crystal 1.16.3, bring an optional Crystal 1.18.2 + Alpine 3.23 image, and compile OpenSSL from source to mitigate the memory leak seen with Alpine-provided OpenSSL (#5604, #5577, #5574, #5441)
- Configuration docs saw polish with unix socket instructions, refreshed HTTP proxy comments, and corrected README commands (#5347, #5586, #5607)
- Server stability improves via a larger `max_request_line_size` that is required to be able to access some next pages of Youtube channels videos and a rewritten static file handler (#5566, #5338)
#### For developers
- Top-level constants moved into dedicated modules, preferences handling was cleaned up, and the legacy signature helper is finally removed (#5596, #5450, #5550)
- Crystal API updates replaced the deprecated `Socket#blocking` property and restored the shard target plus SPDX license metadata (#5538, #5608, #5552)
- CI/tooling stayed current with newer GitHub Actions, install-crystal releases, and cache/checkout bumps (#5569, #5544, #5530, #5499)
### Bugs fixed
#### User-side
- Playlist importer edge cases, playlist API author URLs, and channel continuation tokens now handle empty values without crashing (#4787, #5618, #5614)
- Thin mode community posts, posts that reference unavailable videos, and DMCA content toggles work again (#5567, #5549, #5228)
- UI cleanups prevent channel name/button overflow, show explicit Erroneous CAPTCHA errors, and keep livestream timestamps clean (#5553, #5452, #5508, #5481)
- Trending feeds and related video counts regained accuracy alongside livestream/gaming categories (#5555, #5480, #5446)
#### For instance owners
- Companion downloads, CSP reuse, and check id generation behave predictably even under load (#5561, #5497, #5575)
- Proxy responses drop stray headers and HTTP proxy examples in the config were clarified (#5595, #5586)
- Docker/OCI builds were pinned to stable Crystal releases with OpenSSL bundled to avoid memory leaks (#5604, #5577, #5441)
#### For developers
- README commit instructions, shard targets, and unix socket docs were corrected (#5607, #5608, #5347)
- Thin mode preference comparisons no longer convert unnecessary strings (#5568)
- URL encoding fixes in the download widget and socket API updates prevent regressions when upgrading Crystal (#5367, #5538)
### Full list of pull requests merged since the last release (newest first)
* refactor: Move top level constants to it's own modules (https://github.com/iv-org/invidious/pull/5596, by @Fijxu)
* pages/watch: URL encode 'action' in download widget (https://github.com/iv-org/invidious/pull/5367, by @SamantazFox)
* Document use of unix sockets for `db` (https://github.com/iv-org/invidious/pull/5347, by @Fijxu)
* Generate companion CSP only once to reuse it (https://github.com/iv-org/invidious/pull/5497, by @Fijxu)
* Fix youtube CSV playlist importer (https://github.com/iv-org/invidious/pull/4787, by @ThatMatrix)
* Playlist API: return empty author url if ucid is empty (https://github.com/iv-org/invidious/pull/5618, by @radmorecameron)
* Channels: parse pronouns and display them on channel page (https://github.com/iv-org/invidious/pull/5617, by @radmorecameron)
* playlist: parse playlist thumbnails for topic autogenerated playlists (https://github.com/iv-org/invidious/pull/5616, by @radmorecameron)
* fix: add missing embedded protobuf message in continuation token for channel videos (https://github.com/iv-org/invidious/pull/5614, by @Fijxu)
* Update shard.yml to include target that was removed in commit 9d54cf9 (https://github.com/iv-org/invidious/pull/5608, by @Harm133)
* chore: Do not convert thin_mode preference to string to compare it in before_all (https://github.com/iv-org/invidious/pull/5568, by @Fijxu)
* Fix thin_mode preference for channel community page (https://github.com/iv-org/invidious/pull/5567, by @Fijxu)
* Fix commit command in README instructions, as per #5606 (https://github.com/iv-org/invidious/pull/5607, by @kirisakow)
* Revert "Bump crystallang/crystal from 1.16.3-alpine to 1.19.0-alpine in /docker" (https://github.com/iv-org/invidious/pull/5604, by @unixfox)
* Bump crystallang/crystal from 1.16.3-alpine to 1.19.0-alpine in /docker (https://github.com/iv-org/invidious/pull/5603, by @dependabot[bot])
* doc: Update HTTP proxy configuration comments (https://github.com/iv-org/invidious/pull/5586, by @unixfox)
* Strip unwanted headers from response headers in images and videoplayback (https://github.com/iv-org/invidious/pull/5595, by @Fijxu)
* Generate companion check id one time and add missing companion check id on captions (https://github.com/iv-org/invidious/pull/5575, by @Fijxu)
* Downgrade Crystal to 1.16.3 in OCI (https://github.com/iv-org/invidious/pull/5577, by @Fijxu)
* Allow downloading via companion (https://github.com/iv-org/invidious/pull/5561, by @JeroenBoersma)
* chore: crystal 1.8.2 + alpine 3.23 (https://github.com/iv-org/invidious/pull/5574, by @unixfox)
* Replace deprecated `blocking` property of `Socket` (https://github.com/iv-org/invidious/pull/5538, by @Fijxu)
* Replace `Kemal::StaticFileHandler` with direct subclass of stdlib `HTTP::StaticFileHandler` on Crystal >= 1.17.0 (https://github.com/iv-org/invidious/pull/5338, by @syeopite)
* dockerfile: compile openssl instead of using the one bundled on the crystal alpine image. (https://github.com/iv-org/invidious/pull/5441, by @Fijxu)
* Bump actions/cache from 4 to 5 (https://github.com/iv-org/invidious/pull/5569, by @dependabot[bot])
* Set Kemal `max_request_line_size` to 16384 for large channel continuation query parameters. (https://github.com/iv-org/invidious/pull/5566, by @Fijxu)
* Add link to GitHub release/tag/commit in footer (https://github.com/iv-org/invidious/pull/4702, by @shaedrich)
* Display "Erroneous CAPTCHA" for invalid captchas (https://github.com/iv-org/invidious/pull/5508, by @Fijxu)
* Fix channel name overflow (https://github.com/iv-org/invidious/pull/5553, by @Fijxu)
* Fix trending page by leaving livestream and gaming trending pages (https://github.com/iv-org/invidious/pull/5555, by @Fijxu)
* fix: restore dmca_content functionality (https://github.com/iv-org/invidious/pull/5228, by @Fijxu)
* Remove signature helper completely from Invidious (https://github.com/iv-org/invidious/pull/5550, by @Fijxu)
* Fix community posts when there is a unavailable video in a post (https://github.com/iv-org/invidious/pull/5549, by @Fijxu)
* chore: Update shard.yml to use SPDX license identifier (https://github.com/iv-org/invidious/pull/5552, by @Fijxu)
* Store `preferences` in a variable when reused and rename `prefs` to `preferences` (https://github.com/iv-org/invidious/pull/5450, by @Fijxu)
* Bump actions/checkout from 5 to 6 (https://github.com/iv-org/invidious/pull/5544, by @dependabot[bot])
* Bump crystal-lang/install-crystal from 1.8.3 to 1.9.1 (https://github.com/iv-org/invidious/pull/5530, by @dependabot[bot])
* Fix 0 view count on related videos section (https://github.com/iv-org/invidious/pull/5446, by @shiny-comic)
* Prevent timestamp from being set for Livestreams on "Watch on Youtube" links (https://github.com/iv-org/invidious/pull/5481, by @Fijxu)
* Add Livestreams to trending page (https://github.com/iv-org/invidious/pull/5480, by @Fijxu)
* Fix button overflow (https://github.com/iv-org/invidious/pull/5452, by @Fijxu)
* Bump crystal-lang/install-crystal from 1.8.2 to 1.8.3 (https://github.com/iv-org/invidious/pull/5499, by @dependabot[bot])
* Fixed broken companion hyperlink (https://github.com/iv-org/invidious/pull/5491, by @ndsvw)
## v2.20250913.0
### Wrap-up
+1 -1
View File
@@ -129,7 +129,7 @@ You can read more here: https://docs.invidious.io/applications/
1. Fork it ( https://github.com/iv-org/invidious/fork ).
1. Create your feature branch (`git checkout -b my-new-feature`).
1. Stage your files (`git add .`).
1. Commit your changes (`git commit -am 'Add some feature'`).
1. Commit your changes (`git commit -m 'Add some feature'`).
1. Push to the branch (`git push origin my-new-feature`).
1. Create a new pull request ( https://github.com/iv-org/invidious/compare ).
+23 -1
View File
@@ -75,6 +75,16 @@ body {
height: auto;
}
.channel-profile > .channel-name-pronouns {
display: inline-block;
}
.channel-profile > .channel-name-pronouns > .channel-pronouns {
font-style: italic;
font-size: .8em;
font-weight: lighter;
}
body a.channel-owner {
background-color: #008bec;
color: #fff;
@@ -406,7 +416,12 @@ input[type="search"]::-webkit-search-cancel-button {
p.channel-name { margin: 0; overflow-wrap: anywhere;}
p.video-data { margin: 0; font-weight: bold; font-size: 80%; }
.channel-profile > .channel-name { overflow-wrap: anywhere;}
.channel-profile > .channel-name,
.channel-profile > .channel-name-pronouns > .channel-name
{
overflow-wrap: anywhere;
}
/*
@@ -887,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;
}
+2 -2
View File
@@ -211,9 +211,9 @@ window.helpers = window.helpers || {
helpers.storage.remove(key);
}
},
set: function (key, value) {
set: function (key, value) {
let encoded_value = encodeURIComponent(JSON.stringify(value))
localStorage.setItem(key, encoded_value);
localStorage.setItem(key, encoded_value);
},
remove: function (key) { localStorage.removeItem(key); }
};
+15 -8
View File
@@ -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;
+11 -4
View File
@@ -8,6 +8,13 @@
## Database configuration with separate parameters.
## This setting is MANDATORY, unless 'database_url' is used.
##
## Note: The 'db' setting allows the use of UNIX
## sockets. To do so, set 'host' to ""
## E.g:
## password: kemal
## host: ""
## port: 5432
##
db:
user: kemal
password: kemal
@@ -46,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
@@ -56,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.
@@ -225,7 +232,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
@@ -878,7 +885,7 @@ default_user_preferences:
## Default: true
##
#vr_mode: true
##
## Save the playback position
## Allow to continue watching at the previous position when
+5 -5
View File
@@ -1,8 +1,8 @@
# https://github.com/openssl/openssl/releases/tag/openssl-3.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}
-83
View File
@@ -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" ]
+2
View File
@@ -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",
+1 -1
View File
@@ -24,7 +24,7 @@ def create_licence_tr(path, file_name, licence_name, licence_link, source_locati
"<tr>
<td><a href=\\"/#{path}\\">#{file_name}</a></td>
<td><a href=\\"#{licence_link}\\">#{licence_name}</a></td>
<td><a href=\\"#{source_location}\\">\#{translate(locale, "source")}</a></td>
<td><a href=\\"#{source_location}\\">\#{I18n.translate(locale, "source")}</a></td>
</tr>"
HTML
+1 -1
View File
@@ -3,7 +3,7 @@
# Crystal linter
# This is a modified version of the pre-commit hook from the crystal repo. https://github.com/crystal-lang/crystal/blob/master/scripts/git/pre-commit
# Please refer to that if you'd like an version that doesn't automatically format staged files.
# Please refer to that if you'd like an version that doesn't automatically format staged files.
changed_cr_files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.cr$')
if [ ! -z "$changed_cr_files" ]; then
if [ -x bin/crystal ]; then
+5 -1
View File
@@ -1,10 +1,14 @@
name: invidious
version: 2.20250913.0-dev
version: 2.20260207.0-dev
authors:
- Invidious team <contact@invidious.io>
- Contributors!
targets:
invidious:
main: src/invidious.cr
description: |
Invidious is an alternative front-end to YouTube
-2
View File
@@ -48,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))
+2 -13
View File
@@ -67,20 +67,9 @@ rescue ex
puts "Check your 'config.yml' database settings or PostgreSQL settings."
exit(1)
end
ARCHIVE_URL = URI.parse("https://archive.org")
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
REDDIT_URL = URI.parse("https://www.reddit.com")
YT_URL = URI.parse("https://www.youtube.com")
HOST_URL = make_host_url(Kemal.config)
CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
HOST_URL = make_host_url(Kemal.config)
MAX_ITEMS_PER_PAGE = 1500
REQUEST_HEADERS_WHITELIST = {"accept", "accept-encoding", "cache-control", "content-length", "if-none-match", "range"}
RESPONSE_HEADERS_BLACKLIST = {"access-control-allow-origin", "alt-svc", "server"}
HTTP_CHUNK_SIZE = 10485760 # ~10MB
CURRENT_BRANCH = {{ "#{`git branch | sed -n '/* /s///p'`.strip}" }}
CURRENT_COMMIT = {{ "#{`git rev-list HEAD --max-count=1 --abbrev-commit`.strip}" }}
CURRENT_VERSION = {{ "#{`git log -1 --format=%ci | awk '{print $1}' | sed s/-/./g`.strip}" }}
@@ -97,7 +86,7 @@ SOFTWARE = {
"branch" => "#{CURRENT_BRANCH}",
}
YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size)
YT_POOL = YoutubeConnectionPool.new(URI.parse("https://www.youtube.com"), capacity: CONFIG.pool_size)
# Image request pool
+20 -6
View File
@@ -12,6 +12,7 @@ record AboutChannel,
sub_count : Int32,
joined : Time,
is_family_friendly : Bool,
pronouns : String?,
allowed_regions : Array(String),
tabs : Array(String),
tags : Array(String),
@@ -82,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?
@@ -160,14 +166,21 @@ def get_about_info(ucid, locale) : AboutChannel
end
sub_count = 0
pronouns = nil
if (metadata_rows = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "metadata", "contentMetadataViewModel", "metadataRows").try &.as_a)
metadata_rows.each do |row|
metadata_part = row.dig?("metadataParts").try &.as_a.find { |i| i.dig?("text", "content").try &.as_s.includes?("subscribers") }
if !metadata_part.nil?
sub_count = short_text_to_number(metadata_part.dig("text", "content").as_s.split(" ")[0]).to_i32
subscribe_metadata_part = row.dig?("metadataParts").try &.as_a.find { |i| i.dig?("text", "content").try &.as_s.includes?("subscribers") }
if !subscribe_metadata_part.nil?
sub_count = short_text_to_number(subscribe_metadata_part.dig("text", "content").as_s.split(" ")[0]).to_i32
end
break if sub_count != 0
pronoun_metadata_part = row.dig?("metadataParts").try &.as_a.find { |i| i.dig?("tooltip").try &.as_s.includes?("Pronouns") }
if !pronoun_metadata_part.nil?
pronouns = pronoun_metadata_part.dig("text", "content").as_s
end
break if sub_count != 0 && !pronouns.nil?
end
end
@@ -184,6 +197,7 @@ def get_about_info(ucid, locale) : AboutChannel
sub_count: sub_count,
joined: joined,
is_family_friendly: is_family_friendly,
pronouns: pronouns,
allowed_regions: allowed_regions,
tabs: tab_names,
tags: tags,
+1 -1
View File
@@ -38,7 +38,7 @@ struct ChannelVideo
json.field "authorId", self.ucid
json.field "authorUrl", "/channel/#{self.ucid}"
json.field "published", self.published.to_unix
json.field "publishedText", 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
+2 -2
View File
@@ -127,11 +127,11 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_sing
reply_count = short_text_to_number(post.dig?("actionButtons", "commentActionButtonsRenderer", "replyButton", "buttonRenderer", "text", "simpleText").try &.as_s || "0")
json.field "content", 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
+15 -3
View File
@@ -114,7 +114,11 @@ module Invidious::Channel::Tabs
"2:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
},
"4:varint" => sort_options_videos_short(sort_by),
"4:varint" => sort_options_videos_short(sort_by),
"8:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
"3:varint" => sort_options_videos_short(sort_by),
},
},
}
@@ -130,7 +134,11 @@ module Invidious::Channel::Tabs
"2:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
},
"4:varint" => sort_options_videos_short(sort_by),
"4:varint" => sort_options_videos_short(sort_by),
"7:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
"3:varint" => sort_options_videos_short(sort_by),
},
},
}
@@ -154,7 +162,11 @@ module Invidious::Channel::Tabs
"2:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
},
"5:varint" => sort_by_numerical,
"5:varint" => sort_by_numerical,
"8:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
"3:varint" => sort_by_numerical,
},
},
}
+1
View File
@@ -1,5 +1,6 @@
module Invidious::Comments
extend self
private REDDIT_URL = URI.parse("https://www.reddit.com")
def fetch_reddit(id, sort_by = "confidence")
client = make_client(REDDIT_URL)
+2 -2
View File
@@ -254,7 +254,7 @@ module Invidious::Comments
end
content_html = html_content || ""
json.field "content", 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"]?
+1
View File
@@ -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 %}
+2 -2
View File
@@ -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
+3 -3
View File
@@ -32,9 +32,9 @@ module Invidious::Frontend::Comments
<p>
<a href="javascript:void(0)" data-onclick="toggle_parent">[ ]</a>
<b><a href="https://www.reddit.com/user/#{child.author}">#{child.author}</a></b>
#{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}
+13 -13
View File
@@ -6,10 +6,10 @@ module Invidious::Frontend::Comments
root = comments["comments"].as_a
root.each do |child|
if child["replies"]?
replies_count_text = 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
&nbsp;
<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>
+9 -9
View File
@@ -6,16 +6,16 @@ module Invidious::Frontend::Pagination
private def first_page(str : String::Builder, locale : String?, url : String)
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
if 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 << "&nbsp;&nbsp;"
str << %(<i class="icon ion-ios-arrow-forward"></i>)
else
# Regular arrow ("first" points to the left)
str << %(<i class="icon ion-ios-arrow-back"></i>)
str << "&nbsp;&nbsp;"
str << 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 << "&nbsp;&nbsp;"
str << %(<i class="icon ion-ios-arrow-forward"></i>)
else
# Regular arrow ("previous" points to the left)
str << %(<i class="icon ion-ios-arrow-back"></i>)
str << "&nbsp;&nbsp;"
str << 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 << "&nbsp;&nbsp;"
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 << "&nbsp;&nbsp;"
str << %(<i class="icon ion-ios-arrow-forward"></i>)
end
+8 -8
View File
@@ -6,7 +6,7 @@ module Invidious::Frontend::SearchFilters
return String.build(8000) do |str|
str << "<div id='filters'>\n"
str << "\t<details id='filters-collapse'>"
str << "\t\t<summary>" << 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
+6 -6
View File
@@ -20,11 +20,11 @@ module Invidious::Frontend::WatchPage
def download_widget(locale : String, video : Video, video_assets : VideoAssets) : String
if CONFIG.disabled?("downloads")
return "<p id=\"download\">#{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"
@@ -36,7 +36,7 @@ module Invidious::Frontend::WatchPage
return String.build(4000) do |str|
str << "<form"
str << " class=\"pure-form pure-form-stacked\""
str << " action='#{url}'"
str << " action='" << HTML.escape(url) << "'"
str << " method='post'"
str << " rel='noopener noreferrer'"
str << " target='_blank'>"
@@ -49,7 +49,7 @@ module Invidious::Frontend::WatchPage
str << "\t<div class=\"pure-control-group\">\n"
str << "\t\t<label for='download_widget'>"
str << 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"
+12 -12
View File
@@ -63,19 +63,19 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce
error_message = <<-END_HTML
<div class="error_message">
<h2>#{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>
+140 -134
View File
@@ -22,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}"
@@ -84,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
@@ -150,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
View File
@@ -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
+8 -8
View File
@@ -53,7 +53,7 @@ struct SearchVideo
xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg")
end
xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text 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
+9 -7
View File
@@ -1,3 +1,5 @@
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
# See http://www.evanmiller.org/how-not-to-sort-by-average-rating.html
def ci_lower_bound(pos, n)
if n == 0
@@ -144,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
+2 -2
View File
@@ -22,7 +22,7 @@ module Invidious::JSONify::APIv1
json.field "description", video.description
json.field "descriptionHtml", video.description_html
json.field "published", video.published.to_unix
json.field "publishedText", 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
+1 -1
View File
@@ -27,7 +27,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
video_id = "CvFH_6DNRCY" if rdid.starts_with? "OLAK5uy_"
response = YT_POOL.client &.get("/watch?v=#{video_id}&list=#{rdid}&gl=US&hl=en", headers)
initial_data = 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.")
+15 -8
View File
@@ -107,7 +107,11 @@ struct Playlist
json.field "author", self.author
json.field "authorId", self.ucid
json.field "authorUrl", "/channel/#{self.ucid}"
if !self.ucid.empty?
json.field "authorUrl", "/channel/#{self.ucid}"
else
json.field "authorUrl", ""
end
json.field "subtitle", self.subtitle
json.field "authorThumbnails" do
@@ -195,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
@@ -359,6 +363,9 @@ def fetch_playlist(plid : String)
thumbnail = playlist_info.dig?(
"thumbnailRenderer", "playlistVideoThumbnailRenderer",
"thumbnail", "thumbnails", 0, "url"
).try &.as_s || playlist_info.dig?(
"thumbnailRenderer", "playlistCustomThumbnailRenderer",
"thumbnail", "thumbnails", 0, "url"
).try &.as_s
views = 0_i64
@@ -377,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
@@ -438,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
@@ -447,7 +454,7 @@ def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32,
end
end
def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
def extract_playlist_videos(playlist_id : String, initial_data : Hash(String, JSON::Any))
videos = [] of PlaylistVideo | ProblematicTimelineItem
if initial_data["contents"]?
@@ -473,9 +480,9 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
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
video_id = i.dig?("navigationEndpoint", "watchEndpoint", "videoId").try &.as_s || i.dig("videoId").as_s
plid = i.dig?("navigationEndpoint", "watchEndpoint", "playlistId").try &.as_s || playlist_id
index = i.dig?("navigationEndpoint", "watchEndpoint", "index").try &.as_i64 || i.dig("index", "simpleText").as_s.to_i64
title = i["title"].try { |t| t["simpleText"]? || t["runs"]?.try &.[0]["text"]? }.try &.as_s || ""
author = i["shortBylineText"]?.try &.["runs"][0]["text"].as_s || ""
+2 -2
View File
@@ -8,7 +8,7 @@ module Invidious::Routes::API::V1::Authenticated
# topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000)
# topics ||= [] of String
# 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
+2 -2
View File
@@ -97,13 +97,14 @@ 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
json.field "tabs", channel.tabs
json.field "tags", channel.tags
json.field "authorVerified", channel.verified
json.field "pronouns", channel.pronouns
json.field "latestVideos" do
json.array do
@@ -127,7 +128,6 @@ module Invidious::Routes::API::V1::Channels
end
end
end # relatedChannels
end
end
end
+6 -3
View File
@@ -1,6 +1,9 @@
require "html"
module Invidious::Routes::API::V1::Videos
private INTERNET_ARCHIVE_URL = URI.parse("https://archive.org")
private CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
def self.videos(env)
locale = env.get("preferences").as(Preferences).locale
@@ -279,7 +282,7 @@ module Invidious::Routes::API::V1::Videos
file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml")
location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}"))
location = make_client(INTERNET_ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}"))
if !location.headers["Location"]?
env.response.status_code = location.status_code
@@ -297,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}")
@@ -407,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
+17 -5
View File
@@ -1,4 +1,16 @@
module Invidious::Routes::BeforeAll
struct CompanionCSP
property companion_urls : String = ""
def initialize
self.companion_urls = CONFIG.invidious_companion.reject(&.builtin_proxy).map do |companion|
"#{companion.public_url.scheme}://#{companion.public_url.host}#{companion.public_url.port ? ":#{companion.public_url.port}" : ""}"
end.join(" ")
end
end
private COMPANION_CSP = CompanionCSP.new
def self.handle(env)
preferences = Preferences.from_json("{}")
@@ -7,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
@@ -35,9 +47,9 @@ module Invidious::Routes::BeforeAll
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'self'",
"connect-src 'self' " + COMPANION_CSP.companion_urls,
"manifest-src 'self'",
"media-src 'self' blob:",
"media-src 'self' blob: " + COMPANION_CSP.companion_urls,
"child-src 'self' blob:",
"frame-src 'self'",
"frame-ancestors " + frame_ancestors,
@@ -94,8 +106,8 @@ module Invidious::Routes::BeforeAll
end
dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s
thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode.to_s
thin_mode = thin_mode == "true"
thin_mode = env.params.query["thin_mode"]?
thin_mode = (thin_mode == "true") || preferences.thin_mode
locale = env.params.query["hl"]? || preferences.locale
preferences.dark_mode = dark_mode
+5 -3
View File
@@ -231,8 +231,10 @@ module Invidious::Routes::Channels
env.redirect "/post/#{URI.encode_www_form(lb)}?ucid=#{URI.encode_www_form(ucid)}"
end
thin_mode = env.params.query["thin_mode"]? || env.get("preferences").as(Preferences).thin_mode
thin_mode = thin_mode == "true"
preferences = env.get("preferences").as(Preferences)
thin_mode = env.params.query["thin_mode"]?
thin_mode = (thin_mode == "true") || preferences.thin_mode
continuation = env.params.query["continuation"]?
@@ -352,7 +354,7 @@ module Invidious::Routes::Channels
resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}")
ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"]
rescue ex : InfoException | KeyError
return error_template(404, 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"]?
+3 -14
View File
@@ -10,7 +10,7 @@ module Invidious::Routes::Embed
videos = get_playlist_videos(playlist, offset: offset)
if videos.empty?
url = "/playlist?list=#{plid}"
raise NotFoundException.new(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
@@ -208,17 +208,6 @@ module Invidious::Routes::Embed
if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
invidious_companion_urls = CONFIG.invidious_companion.reject(&.builtin_proxy).map do |companion|
uri =
"#{companion.public_url.scheme}://#{companion.public_url.host}#{companion.public_url.port ? ":#{companion.public_url.port}" : ""}"
end.join(" ")
if !invidious_companion_urls.empty?
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src #{invidious_companion_urls}")
.gsub("connect-src", "connect-src #{invidious_companion_urls}")
end
end
rendered "embed"
+2 -2
View File
@@ -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)
+14 -4
View File
@@ -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
+1 -1
View File
@@ -112,7 +112,7 @@ module Invidious::Routes::Login
user, sid = create_user(sid, email, password)
if language_header = env.request.headers["Accept-Language"]?
if language = ANG.language_negotiator.best(language_header, LOCALES.keys)
if language = ANG.language_negotiator.best(language_header, I18n::LOCALES.keys)
user.preferences.locale = language.header
end
end
+5
View File
@@ -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"
+20
View File
@@ -0,0 +1,20 @@
module Invidious::Routes
private REQUEST_HEADERS_WHITELIST = {
"accept",
"accept-encoding",
"cache-control",
"content-length",
"if-none-match",
"range",
}
private RESPONSE_HEADERS_BLACKLIST = {
"access-control-allow-origin",
"alt-svc",
"server",
"cross-origin-opener-policy-report-only",
"report-to",
"cross-origin",
"timing-allow-origin",
"cross-origin-resource-policy",
}
end
+9 -2
View File
@@ -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
+4 -2
View File
@@ -1,4 +1,6 @@
module Invidious::Routes::VideoPlayback
private HTTP_CHUNK_SIZE = 10485760 # ~10MB
# /videoplayback
def self.get_video_playback(env)
locale = env.get("preferences").as(Preferences).locale
@@ -81,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
@@ -193,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"
+16 -24
View File
@@ -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
@@ -193,17 +196,6 @@ module Invidious::Routes::Watch
if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
invidious_companion_urls = CONFIG.invidious_companion.reject(&.builtin_proxy).map do |companion|
uri =
"#{companion.public_url.scheme}://#{companion.public_url.host}#{companion.public_url.port ? ":#{companion.public_url.port}" : ""}"
end.join(" ")
if !invidious_companion_urls.empty?
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src #{invidious_companion_urls}")
.gsub("connect-src", "connect-src #{invidious_companion_urls}")
end
end
templated "watch"
@@ -284,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
+3
View File
@@ -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
-2
View File
@@ -57,8 +57,6 @@ module Invidious::Search
# Values correspond to { "1:varint": <X> }
enum Sort
Relevance = 0
Rating = 1
Date = 2
Views = 3
end
+1 -1
View File
@@ -21,7 +21,7 @@ module Invidious::Search
if response.status_code == 404
response = YT_POOL.client &.get("/user/#{query.channel}")
response = YT_POOL.client &.get("/c/#{query.channel}") if response.status_code == 404
initial_data = 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
+1 -1
View File
@@ -15,7 +15,7 @@ struct Invidious::User
playlists.each do |playlist|
json.object do
json.field "title", playlist.title
json.field "description", 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
+11 -13
View File
@@ -30,28 +30,24 @@ struct Invidious::User
return subscriptions
end
def parse_playlist_export_csv(user : User, raw_input : String)
# Parse a CSV Google Takeout - Youtube Playlist file
def parse_playlist_export_csv(user : User, playlist_name : String, raw_input : String)
# Split the input into head and body content
raw_head, raw_body = raw_input.strip('\n').split("\n\n", limit: 2, remove_empty: true)
raw_head, raw_body = raw_input.split("\n", limit: 2, remove_empty: true)
# Create the playlist from the head content
csv_head = CSV.new(raw_head.strip('\n'), headers: true)
csv_head.next
title = csv_head[4]
description = csv_head[5]
visibility = csv_head[6]
title = playlist_name
if visibility.compare("Public", case_insensitive: true) == 0
privacy = PlaylistPrivacy::Public
else
privacy = PlaylistPrivacy::Private
end
description = "This is the default description of an imported playlist. Feel Free to change it as you see fit."
privacy = PlaylistPrivacy::Private
playlist = create_playlist(title, privacy, user)
Invidious::Database::Playlists.update_description(playlist.id, description)
# Add each video to the playlist from the body content
csv_body = CSV.new(raw_body.strip('\n'), headers: true)
csv_body = CSV.new(raw_body.strip('\n'), headers: false)
csv_body.each do |row|
video_id = row[0]
if playlist
@@ -204,10 +200,12 @@ struct Invidious::User
end
def from_youtube_pl(user : User, body : String, filename : String, type : String) : Bool
extension = filename.split(".").last
filename_array = filename.split(".")
playlist_name = filename_array.first
extension = filename_array.last
if extension == "csv" || type == "text/csv"
playlist = parse_playlist_export_csv(user, body)
playlist = parse_playlist_export_csv(user, playlist_name, body)
if playlist
return true
else
+1
View File
@@ -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)
+5 -4
View File
@@ -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. \
+19 -15
View File
@@ -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
-6
View File
@@ -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
View File
@@ -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
+164 -160
View File
@@ -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
+2 -2
View File
@@ -8,12 +8,12 @@
<div class="pure-u-1 pure-u-lg-3-5">
<div class="h-box">
<form class="pure-form pure-form-aligned" action="/add_playlist_items" method="get">
<legend><a href="/playlist?list=<%= playlist.id %>"><%= 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>
+4 -4
View File
@@ -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
%>
@@ -12,7 +12,10 @@
<div class="pure-u-1-2 flex-left flexible">
<div class="channel-profile">
<img src="/ggpht<%= channel_profile_pic %>" alt="" />
<span class="channel-name"><%= author %></span><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
<div class="channel-name-pronouns">
<span class="channel-name"><%= author %></span><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
<% if !channel.pronouns.nil? %><br /><span class="channel-pronouns"><%= channel.pronouns %></span><% end %>
</div>
</div>
</div>
@@ -24,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>&nbsp;<%= translate(locale, "generic_button_rss") %>
<i class="icon ion-logo-rss"></i>&nbsp;<%= I18n.translate(locale, "generic_button_rss") %>
</a>
</div>
</div>
@@ -37,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) %>
@@ -50,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 %>
+1 -1
View File
@@ -5,7 +5,7 @@
<% end %>
<% feed_menu.each do |feed| %>
<a href="/feed/<%= feed.downcase %>" class="feed-menu-item pure-menu-heading">
<%= translate(locale, feed) %>
<%= I18n.translate(locale, feed) %>
</a>
<% end %>
</div>
+12 -12
View File
@@ -27,8 +27,8 @@
</div>
<% if !item.channel_handle.nil? %><p class="channel-name" dir="auto"><%= item.channel_handle %></p><% end %>
<p><%= 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>&nbsp;<%= translate(locale, "LIVE") %></p>
<p class="length" dir="auto"><i class="icon ion-ios-play-circle"></i>&nbsp;<%= 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>
+1 -1
View File
@@ -25,7 +25,7 @@
audio_streams.each_with_index do |fmt, i|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
src_url += "&local=true" if params.local
src_url = invidious_companion.public_url.to_s + src_url +
src_url = invidious_companion.public_url.to_s + src_url +
"&check=#{invidious_companion_check_id}" if (invidious_companion)
bitrate = fmt["bitrate"]
+11 -3
View File
@@ -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>
+7 -7
View File
@@ -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>
+4 -4
View File
@@ -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>
+5 -5
View File
@@ -10,17 +10,17 @@
<div class="flex-right button-container">
<div class="pure-u">
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/playlist?list=<%= plid %>">
<i class="icon ion-md-close"></i>&nbsp;<%= translate(locale, "generic_button_cancel") %>
<i class="icon ion-md-close"></i>&nbsp;<%= 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>&nbsp;<%= translate(locale, "generic_button_save") %>
<i class="icon ion-md-save"></i>&nbsp;<%= 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>&nbsp;<%= translate(locale, "generic_button_delete") %>
<i class="icon ion-md-trash"></i>&nbsp;<%= 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>
+4 -4
View File
@@ -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>
+5 -5
View File
@@ -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>
+2 -2
View File
@@ -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 %>
+4 -4
View File
@@ -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? %>
+5 -5
View File
@@ -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 %>&region=<%= region %>">
<%= translate(locale, option) %>
<%= I18n.translate(locale, option) %>
</a>
<% end %>
</div>
+13 -13
View File
@@ -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 -1
View File
@@ -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>
+16 -16
View File
@@ -13,28 +13,28 @@
<%- if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email -%>
<div class="pure-u">
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/add_playlist_items?list=<%= plid %>">
<i class="icon ion-md-add"></i>&nbsp;<%= translate(locale, "playlist_button_add_items") %>
<i class="icon ion-md-add"></i>&nbsp;<%= 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>&nbsp;<%= translate(locale, "generic_button_edit") %>
<i class="icon ion-md-create"></i>&nbsp;<%= 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>&nbsp;<%= translate(locale, "generic_button_delete") %>
<i class="icon ion-md-trash"></i>&nbsp;<%= 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>&nbsp;<%= translate(locale, "Subscribe") %>
<i class="icon ion-md-add"></i>&nbsp;<%= 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>&nbsp;<%= translate(locale, "Unsubscribe") %>
<i class="icon ion-md-trash"></i>&nbsp;<%= 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>&nbsp;<%= translate(locale, "generic_button_rss") %>
<i class="icon ion-logo-rss"></i>&nbsp;<%= 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>
+6 -6
View File
@@ -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>
+9 -4
View File
@@ -1,5 +1,10 @@
<%
search_privacy = preferences.search_privacy
search_query = query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "&hellip;" : HTML.escape(query.text)
%>
<% content_for "header" do %>
<title><%= query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "&hellip;" : 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 -%>
+2 -2
View File
@@ -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 %>
+19 -19
View File
@@ -43,7 +43,7 @@
<div class="pure-u-1 pure-u-md-8-24 user-field">
<% if env.get? "user" %>
<div class="pure-u-1-4">
<a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading" title="<%= 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>&nbsp;/
<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>&nbsp;/
<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>
+8 -8
View File
@@ -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>
+9 -9
View File
@@ -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>
+14 -14
View File
@@ -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>
+4 -4
View File
@@ -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>
+8 -8
View File
@@ -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>
+81 -75
View File
@@ -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>
+5 -5
View File
@@ -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>
+36 -36
View File
@@ -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>

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